From 4734c667591d129b29021bc4c2c944c8a46c6564 Mon Sep 17 00:00:00 2001 From: FlorianBracq <97248273+FlorianBracq@users.noreply.github.com> Date: Fri, 13 Sep 2024 20:57:23 +0200 Subject: [PATCH] Provider and lookup typing (#795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve typing for export decorator * wip: Add typing to base providers * wip: Add typing to lookups * wip: Add typing to context providers * wip: Add typing to tiproviders * Minor changes on tests * wip: typing for context providers * WIP: Apply typing to various objects from msticpy.context * Inherit from httpx to define test objects * Add requests as a dependency for riskiq * WIP - Add typing to lookup * WIP - Fix linting errors * [WIP] Apply linting to ti providers * Adding requests to dependencies * [WIP] Apply linting to ti providers + fix bugs * Remove kwargs * Fix black linting * Fix pylint errors * Fix mypy errors * Fix Flake8 error * [WIP] Add typing to providers, entities and common classes * [WIP] Add typing to context classes * [WIP] Add typing to context classes, replace attrs by dataclass * [WIP] Continue adding typing and fixing ruff errors * Adding self/cls typing and replace print by logger.info * Remove unused parameter * Finish applying ruff standards to msticpy/context files * Merge branch 'main' of https://github.com/microsoft/msticpy into provider-and-driver-typing * [WIP] Fix typing and linting errors * Apply black linting to files * Add typing and removing some kwargs * Update test mocks to align with new hierarchy * Fix pylint issues * Fix pytest issue * Ignore errors that I don't know how to fix * Fix singleton typing * Make version requirements explicit * Reùove unrequired pylint disable * Remove unrequired TypeVar * Fix linting errors * Fix partially usage of Self typing * Rolling back changes on print for usage functions * Fix linting * Adding Crypto exceptions for CodeQL Also some minor spacing/formatting things --------- Co-authored-by: Ian Hellen --- msticpy/aiagents/config_utils.py | 1 - msticpy/aiagents/rag_agents.py | 1 - msticpy/auth/azure_auth.py | 8 +- msticpy/auth/azure_auth_core.py | 176 ++-- msticpy/common/pkg_config.py | 12 +- msticpy/common/provider_settings.py | 100 +-- msticpy/common/utility/types.py | 145 ++-- msticpy/context/__init__.py | 6 +- msticpy/context/azure/azure_data.py | 705 +++++++++------- msticpy/context/azure/sentinel_analytics.py | 165 ++-- msticpy/context/azure/sentinel_bookmarks.py | 98 ++- msticpy/context/azure/sentinel_core.py | 177 ++-- .../context/azure/sentinel_dynamic_summary.py | 323 +++++--- .../azure/sentinel_dynamic_summary_types.py | 348 ++++---- msticpy/context/azure/sentinel_incidents.py | 280 ++++--- msticpy/context/azure/sentinel_search.py | 116 +-- msticpy/context/azure/sentinel_ti.py | 366 ++++++--- msticpy/context/azure/sentinel_utils.py | 147 ++-- msticpy/context/azure/sentinel_watchlists.py | 180 +++-- msticpy/context/azure/sentinel_workspaces.py | 213 +++-- msticpy/context/contextlookup.py | 28 +- .../contextproviders/context_provider_base.py | 4 +- .../contextproviders/http_context_provider.py | 2 +- .../context/contextproviders/servicenow.py | 4 +- msticpy/context/domain_utils.py | 134 ++-- msticpy/context/geoip.py | 412 ++++++---- msticpy/context/http_provider.py | 31 +- msticpy/context/ip_utils.py | 453 ++++++----- msticpy/context/lookup.py | 167 ++-- msticpy/context/preprocess_observable.py | 22 +- msticpy/context/provider_base.py | 30 +- msticpy/context/tilookup.py | 22 +- msticpy/context/tiproviders/alienvault_otx.py | 4 +- msticpy/context/tiproviders/binaryedge.py | 7 +- msticpy/context/tiproviders/ibm_xforce.py | 4 +- msticpy/context/tiproviders/intsights.py | 4 +- msticpy/context/tiproviders/kql_base.py | 26 +- .../context/tiproviders/result_severity.py | 2 +- msticpy/context/tiproviders/riskiq.py | 2 +- .../context/tiproviders/ti_http_provider.py | 2 +- .../context/tiproviders/ti_provider_base.py | 4 +- msticpy/context/tiproviders/tor_exit_nodes.py | 2 +- msticpy/context/vtlookupv3/vtfile_behavior.py | 43 +- msticpy/context/vtlookupv3/vtlookup.py | 47 +- msticpy/context/vtlookupv3/vtlookupv3.py | 77 +- msticpy/data/azure/__init__.py | 1 - msticpy/datamodel/entities/entity.py | 16 +- msticpy/datamodel/entities/ip_address.py | 33 +- msticpy/init/azure_ml_tools.py | 4 +- .../init/pivot_core/pivot_register_reader.py | 1 + msticpy/init/pivot_init/vt_pivot.py | 3 +- msticpy/init/user_config.py | 2 +- msticpy/transform/base64unpack.py | 8 +- msticpy/transform/iocextract.py | 86 +- msticpy/transform/proc_tree_build_winlx.py | 8 +- msticpy/transform/proc_tree_schema.py | 61 +- test_cache.ipynb | 753 ++++++++++++++++++ tests/context/azure/sentinel_test_fixtures.py | 8 +- tests/context/azure/test_sentinel_core.py | 16 +- .../azure/test_sentinel_dynamic_summary.py | 13 +- tests/context/azure/test_sentinel_ti.py | 6 +- tests/context/test_ip_utils.py | 9 +- tests/context/test_vtlookupv3.py | 2 +- 63 files changed, 3971 insertions(+), 2159 deletions(-) create mode 100644 test_cache.ipynb diff --git a/msticpy/aiagents/config_utils.py b/msticpy/aiagents/config_utils.py index 6dc28fd85..c7399f5c5 100644 --- a/msticpy/aiagents/config_utils.py +++ b/msticpy/aiagents/config_utils.py @@ -13,7 +13,6 @@ from ..common.exceptions import MsticpyUserConfigError from ..common.pkg_config import get_config - ConfigItem = Dict[str, Union[str, Callable]] ConfigList = List[ConfigItem] Config = Dict[str, Union[str, float, ConfigList]] diff --git a/msticpy/aiagents/rag_agents.py b/msticpy/aiagents/rag_agents.py index 844818fe5..2723f5b3f 100644 --- a/msticpy/aiagents/rag_agents.py +++ b/msticpy/aiagents/rag_agents.py @@ -11,7 +11,6 @@ """ import sys - from pathlib import Path from typing import List, Optional diff --git a/msticpy/auth/azure_auth.py b/msticpy/auth/azure_auth.py index 13863a0a5..bf24492b3 100644 --- a/msticpy/auth/azure_auth.py +++ b/msticpy/auth/azure_auth.py @@ -21,6 +21,7 @@ AzCredentials, AzureCloudConfig, AzureCredEnvNames, + ChainedTokenCredential, az_connect_core, ) from .cred_wrapper import CredentialWrapper @@ -99,7 +100,10 @@ def az_connect( az_cli_config.args.get("clientSecret") or "" ) credentials = az_connect_core( - auth_methods=auth_methods, tenant_id=tenant_id, silent=silent, **kwargs + auth_methods=auth_methods, + tenant_id=tenant_id, + silent=silent, + **kwargs, ) sub_client = SubscriptionClient( credential=credentials.modern, @@ -174,7 +178,7 @@ def fallback_devicecode_creds( if not creds: raise CloudError("Could not obtain credentials.") - return AzCredentials(legacy_creds, creds) + return AzCredentials(legacy_creds, ChainedTokenCredential(creds)) # type: ignore[arg-type] def get_default_resource_name(resource_uri: str) -> str: diff --git a/msticpy/auth/azure_auth_core.py b/msticpy/auth/azure_auth_core.py index 098d72f2d..84eaddc0d 100644 --- a/msticpy/auth/azure_auth_core.py +++ b/msticpy/auth/azure_auth_core.py @@ -4,16 +4,18 @@ # license information. # -------------------------------------------------------------------------- """Azure KeyVault pre-authentication.""" +from __future__ import annotations import logging import os import sys -from collections import namedtuple +from dataclasses import dataclass from datetime import datetime from enum import Enum -from typing import List, Optional, Tuple, Union +from typing import Callable, ClassVar from azure.common.credentials import get_cli_profile +from azure.core.credentials import TokenCredential from azure.identity import ( AzureCliCredential, AzurePowerShellCredential, @@ -38,46 +40,67 @@ __version__ = VERSION __author__ = "Pete Bryan" -logger = logging.getLogger(__name__) +logger: logging.Logger = logging.getLogger(__name__) -AzCredentials = namedtuple("AzCredentials", ["legacy", "modern"]) _HELP_URI = ( - "https://msticpy.readthedocs.io/en/latest/" - "getting_started/AzureAuthentication.html" + "https://msticpy.readthedocs.io/en/latest/getting_started/AzureAuthentication.html" ) +@dataclass +class AzCredentials: + """Class holding legacy(ADAL) and modern(MSAL) credentials.""" + + legacy: TokenCredential + modern: ChainedTokenCredential + + # pylint: disable=too-few-public-methods class AzureCredEnvNames: """Enumeration of Azure environment credential names.""" - AZURE_CLIENT_ID = "AZURE_CLIENT_ID" # The app ID for the service principal - AZURE_TENANT_ID = "AZURE_TENANT_ID" # The service principal's Azure AD tenant ID - # pylint: disable=line-too-long - # [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="This is an enum of env variable names")] - AZURE_CLIENT_SECRET = "AZURE_CLIENT_SECRET" # nosec # noqa + AZURE_CLIENT_ID: ClassVar[str] = ( + "AZURE_CLIENT_ID" # The app ID for the service principal + ) + AZURE_TENANT_ID: ClassVar[str] = ( + "AZURE_TENANT_ID" # The service principal's Azure AD tenant ID + ) + # [SuppressMessage( + # "Microsoft.Security", + # "CS002:SecretInNextLine", + # Justification="This is an enum of env variable names" + # )] + AZURE_CLIENT_SECRET: ClassVar[str] = "AZURE_CLIENT_SECRET" # nosec # noqa # Certificate auth: # A path to certificate and private key pair in PEM or PFX format - AZURE_CLIENT_CERTIFICATE_PATH = "AZURE_CLIENT_CERTIFICATE_PATH" + AZURE_CLIENT_CERTIFICATE_PATH: ClassVar[str] = "AZURE_CLIENT_CERTIFICATE_PATH" # (Optional) The password protecting the certificate file # (for PFX (PKCS12) certificates). - AZURE_CLIENT_CERTIFICATE_PASSWORD = ( + AZURE_CLIENT_CERTIFICATE_PASSWORD: ClassVar[str] = ( "AZURE_CLIENT_CERTIFICATE_PASSWORD" # nosec # noqa ) # (Optional) Specifies whether an authentication request will include an x5c # header to support subject name / issuer based authentication. # When set to `true` or `1`, authentication requests include the x5c header. - AZURE_CLIENT_SEND_CERTIFICATE_CHAIN = "AZURE_CLIENT_SEND_CERTIFICATE_CHAIN" + AZURE_CLIENT_SEND_CERTIFICATE_CHAIN: ClassVar[str] = ( + "AZURE_CLIENT_SEND_CERTIFICATE_CHAIN" + ) # Username and password: - AZURE_USERNAME = "AZURE_USERNAME" # The username/upn of an AAD user account. - # [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="This is an enum of env variable names")] - AZURE_PASSWORD = "AZURE_PASSWORD" # User password # nosec # noqa + AZURE_USERNAME: ClassVar[str] = ( + "AZURE_USERNAME" # The username/upn of an AAD user account. + ) + # [SuppressMessage( + # "Microsoft.Security", + # "CS002:SecretInNextLine", + # Justification="This is an enum of env variable names" + # )] + AZURE_PASSWORD: ClassVar[str] = "AZURE_PASSWORD" # User password # nosec # noqa -_VALID_ENV_VAR_COMBOS = ( +_VALID_ENV_VAR_COMBOS: tuple[tuple[str, ...], ...] = ( ( AzureCredEnvNames.AZURE_CLIENT_ID, AzureCredEnvNames.AZURE_CLIENT_SECRET, @@ -98,13 +121,14 @@ class AzureCredEnvNames: def _build_env_client( - aad_uri: Optional[str] = None, **kwargs -) -> Optional[EnvironmentCredential]: + aad_uri: str | None = None, + **kwargs, +) -> EnvironmentCredential | None: """Build a credential from environment variables.""" del kwargs for env_vars in _VALID_ENV_VAR_COMBOS: if all(var in os.environ for var in env_vars): - return EnvironmentCredential(authority=aad_uri) # type: ignore + return EnvironmentCredential(authority=aad_uri) # avoid creating env credential if require envs not set. logger.info("'env' credential requested but required env vars not set") @@ -118,7 +142,9 @@ def _build_cli_client(**kwargs) -> AzureCliCredential: def _build_msi_client( - tenant_id: Optional[str] = None, aad_uri: Optional[str] = None, **kwargs + tenant_id: str | None = None, + aad_uri: str | None = None, + **kwargs, ) -> ManagedIdentityCredential: """Build a credential from Managed Identity.""" msi_kwargs = kwargs.copy() @@ -126,12 +152,16 @@ def _build_msi_client( msi_kwargs["client_id"] = os.environ[AzureCredEnvNames.AZURE_CLIENT_ID] return ManagedIdentityCredential( - tenant_id=tenant_id, authority=aad_uri, **msi_kwargs + tenant_id=tenant_id, + authority=aad_uri, + **msi_kwargs, ) def _build_vscode_client( - tenant_id: Optional[str] = None, aad_uri: Optional[str] = None, **kwargs + tenant_id: str | None = None, + aad_uri: str | None = None, + **kwargs, ) -> VisualStudioCodeCredential: """Build a credential from Visual Studio Code.""" del kwargs @@ -139,24 +169,32 @@ def _build_vscode_client( def _build_interactive_client( - tenant_id: Optional[str] = None, aad_uri: Optional[str] = None, **kwargs + tenant_id: str | None = None, + aad_uri: str | None = None, + **kwargs, ) -> InteractiveBrowserCredential: """Build a credential from Interactive Browser logon.""" return InteractiveBrowserCredential( - authority=aad_uri, tenant_id=tenant_id, **kwargs + authority=aad_uri, + tenant_id=tenant_id, + **kwargs, ) def _build_device_code_client( - tenant_id: Optional[str] = None, aad_uri: Optional[str] = None, **kwargs + tenant_id: str | None = None, + aad_uri: str | None = None, + **kwargs, ) -> DeviceCodeCredential: """Build a credential from Device Code.""" return DeviceCodeCredential(authority=aad_uri, tenant_id=tenant_id, **kwargs) def _build_client_secret_client( - tenant_id: Optional[str] = None, aad_uri: Optional[str] = None, **kwargs -) -> Optional[ClientSecretCredential]: + tenant_id: str | None = None, + aad_uri: str | None = None, + **kwargs, +) -> ClientSecretCredential | None: """Build a credential from Client Secret.""" client_id = kwargs.pop("client_id", None) client_secret = kwargs.pop("client_secret", None) @@ -173,8 +211,10 @@ def _build_client_secret_client( def _build_certificate_client( - tenant_id: Optional[str] = None, aad_uri: Optional[str] = None, **kwargs -) -> Optional[CertificateCredential]: + tenant_id: str | None = None, + aad_uri: str | None = None, + **kwargs, +) -> CertificateCredential | None: """Build a credential from Certificate.""" client_id = kwargs.pop("client_id", None) if not client_id: @@ -196,7 +236,7 @@ def _build_powershell_client(**kwargs) -> AzurePowerShellCredential: return AzurePowerShellCredential() -_CLIENTS = dict( +_CLIENTS: dict[str, Callable] = dict( { "env": _build_env_client, "cli": _build_cli_client, @@ -219,16 +259,19 @@ def _build_powershell_client(**kwargs) -> AzurePowerShellCredential: ) -def list_auth_methods() -> List[str]: +def list_auth_methods() -> list[str]: """Return list of accepted authentication methods.""" return sorted(_CLIENTS.keys()) def _az_connect_core( - auth_methods: Optional[List[str]] = None, - cloud: Optional[str] = None, - tenant_id: Optional[str] = None, + auth_methods: list[str] | None = None, + cloud: str | None = None, + tenant_id: str | None = None, silent: bool = False, + *, + region: str | None = None, + credential: AzCredentials | None = None, **kwargs, ) -> AzCredentials: """ @@ -236,7 +279,7 @@ def _az_connect_core( Parameters ---------- - auth_methods : List[str], optional + auth_methods : list[str], optional List of authentication methods to try For a list of possible authentication methods use the `list_auth_methods` function. @@ -284,7 +327,7 @@ def _az_connect_core( """ # Create the auth methods with the specified cloud region - cloud = cloud or kwargs.pop("region", AzureCloudConfig().cloud) + cloud = cloud or region or AzureCloudConfig().cloud az_config = AzureCloudConfig(cloud) aad_uri = az_config.authority_uri logger.info("az_connect_core - using %s cloud and endpoint: %s", cloud, aad_uri) @@ -296,17 +339,9 @@ def _az_connect_core( tenant_id, ", ".join(auth_methods or ["none"]), ) - creds = kwargs.pop("credential", None) - if not creds: - creds = _build_chained_creds( - aad_uri=aad_uri, - requested_clients=auth_methods, - tenant_id=tenant_id, - **kwargs, - ) # Filter and replace error message when credentials not found - azure_identity_logger = logging.getLogger("azure.identity") + azure_identity_logger: logging.Logger = logging.getLogger("azure.identity") handler = logging.StreamHandler(sys.stdout) if silent: handler.addFilter(_filter_all_warnings) @@ -315,26 +350,41 @@ def _az_connect_core( azure_identity_logger.setLevel(logging.WARNING) azure_identity_logger.handlers = [handler] - # Connect to the subscription client to validate - legacy_creds = CredentialWrapper(creds, resource_id=az_config.token_uri) - if not creds: + if not credential: + chained_credential: ChainedTokenCredential = _build_chained_creds( + aad_uri=aad_uri, + requested_clients=auth_methods, + tenant_id=tenant_id, + **kwargs, + ) + legacy_creds: CredentialWrapper = CredentialWrapper( + chained_credential, resource_id=az_config.token_uri + ) + else: + # Connect to the subscription client to validate + legacy_creds = CredentialWrapper(credential, resource_id=az_config.token_uri) + + if not credential: + err_msg: str = ( + "Cannot authenticate with specified credential types. " + "At least one valid authentication method required." + ) raise MsticpyAzureConfigError( - "Cannot authenticate with specified credential types.", - "At least one valid authentication method required.", + err_msg, help_uri=_HELP_URI, title="Authentication failure", ) - return AzCredentials(legacy_creds, creds) + return AzCredentials(legacy_creds, ChainedTokenCredential(credential)) # type: ignore[arg-type] -az_connect_core = _az_connect_core +az_connect_core: Callable[..., AzCredentials] = _az_connect_core def _build_chained_creds( aad_uri, - requested_clients: Union[List[str], None] = None, - tenant_id: Optional[str] = None, + requested_clients: list[str] | None = None, + tenant_id: str | None = None, **kwargs, ) -> ChainedTokenCredential: """ @@ -342,7 +392,7 @@ def _build_chained_creds( Parameters ---------- - requested_clients : List[str] + requested_clients : list[str] List of clients to chain. aad_uri : str The URI of the Azure AD cloud to connect to @@ -365,15 +415,17 @@ def _build_chained_creds( requested_clients = ["env", "cli", "msi", "interactive"] logger.info("No auth methods requested defaulting to: %s", requested_clients) cred_list = [] - invalid_cred_types: List[str] = [] - unusable_cred_type: List[str] = [] + invalid_cred_types: list[str] = [] + unusable_cred_type: list[str] = [] for cred_type in requested_clients: # type: ignore[union-attr] if cred_type not in _CLIENTS: invalid_cred_types.append(cred_type) logger.info("Unknown authentication type requested: %s", cred_type) continue - cred_client = _CLIENTS[cred_type]( # type: ignore[operator] - tenant_id=tenant_id, aad_uri=aad_uri, **kwargs + cred_client = _CLIENTS[cred_type]( + tenant_id=tenant_id, + aad_uri=aad_uri, + **kwargs, ) if cred_client is not None: cred_list.append(cred_client) @@ -453,7 +505,7 @@ class AzureCliStatus(Enum): CLI_UNKNOWN_ERROR = 4 -def check_cli_credentials() -> Tuple[AzureCliStatus, Optional[str]]: +def check_cli_credentials() -> tuple[AzureCliStatus, str | None]: """Check to see if there is a CLI session with a valid AAD token.""" try: cli_profile = get_cli_profile() diff --git a/msticpy/common/pkg_config.py b/msticpy/common/pkg_config.py index 82ac2e135..222bc78c6 100644 --- a/msticpy/common/pkg_config.py +++ b/msticpy/common/pkg_config.py @@ -13,14 +13,13 @@ """ import contextlib -from contextlib import AbstractContextManager import numbers import os from collections import UserDict - +from contextlib import AbstractContextManager from importlib.util import find_spec from pathlib import Path -from typing import Any, Callable, Dict, Optional, Tuple, Union, List +from typing import Any, Callable, Dict, List, Optional, Tuple, Union import httpx import yaml @@ -298,17 +297,20 @@ def _get_default_config(): package = "msticpy" try: from importlib.resources import ( # pylint: disable=import-outside-toplevel - files, as_file, + files, ) package_path: AbstractContextManager = as_file( files(package).joinpath(_CONFIG_FILE) ) except ImportError: + # If importlib.resources is not available we fall back to + # older Python method from importlib.resources import path # pylint: disable=import-outside-toplevel - package_path = path(package, _CONFIG_FILE) + # pylint: disable=deprecated-method + package_path = path(package, _CONFIG_FILE) # noqa: W4902 try: with package_path as config_path: diff --git a/msticpy/common/provider_settings.py b/msticpy/common/provider_settings.py index 04f9dc2b0..6d566a4ac 100644 --- a/msticpy/common/provider_settings.py +++ b/msticpy/common/provider_settings.py @@ -4,13 +4,13 @@ # license information. # -------------------------------------------------------------------------- """Helper functions for configuration settings.""" +from __future__ import annotations + +from dataclasses import dataclass, field import os import warnings from collections import UserDict -from typing import Any, Callable, Dict, List, Optional, Union - -import attr -from attr import Factory +from typing import Any, Callable from .._version import VERSION from .exceptions import MsticpyImportExtraError @@ -28,11 +28,10 @@ __author__ = "Ian Hellen" -# pylint: disable=too-few-public-methods, too-many-ancestors class ProviderArgs(UserDict): """ProviderArgs dictionary.""" - def __getitem__(self, key): + def __getitem__(self, key) -> Any: """Return key value via SecretsClient.read_secret.""" if key not in self.data: raise KeyError(key) @@ -41,25 +40,22 @@ def __getitem__(self, key): return self.data[key] -@attr.s(auto_attribs=True) +@dataclass class ProviderSettings: """Provider settings.""" name: str description: str - provider: Optional[str] = None - args: ProviderArgs = Factory(ProviderArgs) # type: ignore - primary: bool = False - - -# pylint: enable=too-few-public-methods, too-many-ancestors + provider: str | None = field(default=None) + args: ProviderArgs = field(default_factory=ProviderArgs) + primary: bool = field(default=False) def _secrets_enabled() -> bool: return _SECRETS_ENABLED and _SECRETS_CLIENT -def get_secrets_client_func() -> Callable[..., Optional["SecretsClient"]]: +def get_secrets_client_func() -> Callable[..., "SecretsClient" | None]: """ Return function to get or create secrets client. @@ -82,11 +78,11 @@ def get_secrets_client_func() -> Callable[..., Optional["SecretsClient"]]: replace the SecretsClient instance and return that. """ - _secrets_client: Optional["SecretsClient"] = None + _secrets_client: "SecretsClient" | None = None def _return_secrets_client( - secrets_client: Optional["SecretsClient"] = None, **kwargs - ) -> Optional["SecretsClient"]: + secrets_client: "SecretsClient" | None = None, **kwargs + ) -> "SecretsClient" | None: """Return (optionally setting or creating) a SecretsClient.""" nonlocal _secrets_client if not _SECRETS_ENABLED: @@ -104,16 +100,14 @@ def _return_secrets_client( # the module is imported. _SECRETS_CLIENT: Any = None # Create the secrets client closure -_SET_SECRETS_CLIENT: Callable[..., Optional["SecretsClient"]] = ( - get_secrets_client_func() -) +_SET_SECRETS_CLIENT: Callable[..., "SecretsClient" | None] = get_secrets_client_func() # Create secrets client instance if SecretsClient can be imported # and config has KeyVault settings. if get_config("KeyVault", None) and _SECRETS_ENABLED: _SECRETS_CLIENT = _SET_SECRETS_CLIENT() -def get_provider_settings(config_section="TIProviders") -> Dict[str, ProviderSettings]: +def get_provider_settings(config_section="TIProviders") -> dict[str, ProviderSettings]: """ Read Provider settings from package config. @@ -124,7 +118,7 @@ def get_provider_settings(config_section="TIProviders") -> Dict[str, ProviderSet Returns ------- - Dict[str, ProviderSettings] + dict[str, ProviderSettings] Provider settings indexed by provider name. """ @@ -180,11 +174,11 @@ def clear_keyring(): def auth_secrets_client( - tenant_id: Optional[str] = None, - auth_methods: List[str] = None, + tenant_id: str | None = None, + auth_methods: list[str] | None = None, credential: Any = None, **kwargs, -): +) -> None: """ Authenticate the Secrets/Key Vault client. @@ -221,7 +215,7 @@ def auth_secrets_client( """ if _secrets_enabled(): - secrets_client = SecretsClient( + secrets_client: SecretsClient = SecretsClient( tenant_id=tenant_id, auth_methods=auth_methods, credential=credential, @@ -238,12 +232,14 @@ def get_protected_setting(config_path, setting_name) -> Any: def _get_setting_args( - config_path: str, provider_name: str, prov_args: Optional[Dict[str, Any]] + config_path: str, + provider_name: str, + prov_args: dict[str, Any] | None, ) -> ProviderArgs: """Extract the provider args from the settings.""" if not prov_args: return ProviderArgs() - name_map = { + name_map: dict[str, str] = { "workspaceid": "workspace_id", "tenantid": "tenant_id", "subscriptionid": "subscription_id", @@ -257,8 +253,8 @@ def _get_setting_args( def _get_protected_settings( setting_path: str, - section_settings: Optional[Dict[str, Any]], - name_map: Optional[Dict[str, str]] = None, + section_settings: dict[str, Any] | None, + name_map: dict[str, str] | None = None, ) -> ProviderArgs: """ Lookup configuration values config, environment or KeyVault. @@ -267,9 +263,9 @@ def _get_protected_settings( ---------- setting_path : str Dotted path to the setting - section_settings : Optional[Dict[str, Any]] + section_settings : Optional[dict[str, Any]] The configuration settings for this path. - name_map : Optional[Dict[str, str]], optional + name_map : Optional[dict[str, str]], optional Optional mapping to re-write setting names, by default None @@ -284,7 +280,7 @@ def _get_protected_settings( setting_dict: ProviderArgs = ProviderArgs(section_settings.copy()) for arg_name, arg_value in section_settings.items(): - target_name = arg_name + target_name: str = arg_name if name_map: target_name = name_map.get(target_name.casefold(), target_name) @@ -299,8 +295,8 @@ def _get_protected_settings( def _fetch_secret_setting( setting_path: str, - config_setting: Union[str, Dict[str, Any]], -) -> Union[Optional[str], Callable[[], Any]]: + config_setting: str | dict[str, Any], +) -> str | Callable[[], Any] | None: """ Return required value for potential secret setting. @@ -308,7 +304,7 @@ def _fetch_secret_setting( ---------- setting_path : str Dotted path to the setting - config_setting : Union[str, Dict[str, Any]] + config_setting : Union[str, dict[str, Any]] Setting value (str or Dict) Returns @@ -327,37 +323,41 @@ def _fetch_secret_setting( if isinstance(config_setting, str): return config_setting if not isinstance(config_setting, dict): - raise NotImplementedError( - "Configuration setting format not recognized.", - f"'{setting_path}' should be a string or dictionary", - "with either 'EnvironmentVar' or 'KeyVault' entry.", + err_msg: str = ( + "Configuration setting format not recognized. " + f"'{setting_path}' should be a string or dictionary " + "with either 'EnvironmentVar' or 'KeyVault' entry." ) + raise NotImplementedError(err_msg) if "EnvironmentVar" in config_setting: - env_value = os.environ.get(config_setting["EnvironmentVar"]) + env_value: str | None = os.environ.get(config_setting["EnvironmentVar"]) if not env_value: warnings.warn( f"Environment variable {config_setting['EnvironmentVar']}" - + f" ({setting_path})" - + " was not set" + f" ({setting_path})" + " was not set" ) return env_value if "KeyVault" in config_setting: if not _SECRETS_ENABLED: + err_msg = "Cannot use this feature without Key Vault support installed" raise MsticpyImportExtraError( - "Cannot use this feature without Key Vault support installed", + err_msg, title="Error importing Loading Key Vault and/or keyring libraries.", extra="keyvault", ) if not _SECRETS_CLIENT: warnings.warn( "Cannot use a KeyVault configuration setting without" - + "a KeyVault configuration section in msticpyconfig.yaml" - + f" ({setting_path})" + "a KeyVault configuration section in msticpyconfig.yaml" + f" ({setting_path})", + stacklevel=1, ) return None return _SECRETS_CLIENT.get_secret_accessor(setting_path) - raise NotImplementedError( - "Configuration setting format not recognized.", - f"'{setting_path}' should be a string or dictionary", - "with either 'EnvironmentVar' or 'KeyVault' entry.", + err_msg = ( + "Configuration setting format not recognized. " + f"'{setting_path}' should be a string or dictionary " + "with either 'EnvironmentVar' or 'KeyVault' entry." ) + raise NotImplementedError(err_msg) diff --git a/msticpy/common/utility/types.py b/msticpy/common/utility/types.py index 3ecb4ef39..a3f78b613 100644 --- a/msticpy/common/utility/types.py +++ b/msticpy/common/utility/types.py @@ -4,44 +4,39 @@ # license information. # -------------------------------------------------------------------------- """Utility classes and functions.""" +from __future__ import annotations + import difflib import inspect import sys from enum import Enum from functools import wraps from types import ModuleType -from typing import ( - Any, - Callable, - Dict, - Iterable, - List, - Optional, - Type, - TypeVar, - Union, - overload, -) +from typing import Any, Callable, Iterable, TypeVar, overload + +from typing_extensions import Self from ..._version import VERSION __version__ = VERSION __author__ = "Ian Hellen" +T = TypeVar("T") + @overload -def export(obj: Type) -> Type: ... # noqa: E704 +def export(obj: type[T]) -> type[T]: ... # noqa: E704 @overload def export(obj: Callable) -> Callable: ... # noqa: E704 -def export(obj): +def export(obj: type | Callable) -> type | Callable: """Decorate function or class to export to __all__.""" mod: ModuleType = sys.modules[obj.__module__] if hasattr(mod, "__all__"): - all_list: List[str] = getattr(mod, "__all__") + all_list: list[str] = getattr(mod, "__all__") all_list.append(obj.__name__) else: all_list = [obj.__name__] @@ -74,13 +69,16 @@ def checked_kwargs(legal_args: Iterable[str]): """ def arg_check_wrapper(func): - func_args = inspect.signature(func).parameters.keys() - {"args", "kwargs"} - valid_arg_names = set(legal_args) | func_args + func_args: set[str] = inspect.signature(func).parameters.keys() - { + "args", + "kwargs", + } + valid_arg_names: set[str] = set(legal_args) | func_args @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args, **kwargs) -> Callable: """Inner argument name checker.""" - name_errs = [] + name_errs: list[Exception] = [] for name in kwargs: try: check_kwarg(name, valid_arg_names) @@ -96,7 +94,7 @@ def wrapper(*args, **kwargs): @export -def check_kwarg(arg_name: str, legal_args: List[str]): +def check_kwarg(arg_name: str, legal_args: list[str]) -> None: """ Check argument names against a list. @@ -104,7 +102,7 @@ def check_kwarg(arg_name: str, legal_args: List[str]): ---------- arg_name : str Argument to check - legal_args : List[str] + legal_args : list[str] List of possible arguments. Raises @@ -116,29 +114,29 @@ def check_kwarg(arg_name: str, legal_args: List[str]): """ if arg_name not in legal_args: - closest = difflib.get_close_matches(arg_name, legal_args) - mssg = f"'{arg_name}' is not a recognized argument or attribute. " + closest: list[str] = difflib.get_close_matches(arg_name, legal_args) + msg: str = f"'{arg_name}' is not a recognized argument or attribute. " if len(closest) == 1: - mssg += f"Closest match is '{closest[0]}'" + msg += f"Closest match is '{closest[0]}'" elif closest: - match_list = [f"'{match}'" for match in closest] - mssg += f"Closest matches are {', '.join(match_list)}" + match_list: list[str] = [f"'{match}'" for match in closest] + msg += f"Closest matches are {', '.join(match_list)}" else: - valid_opts = [f"'{arg}'" for arg in legal_args] - mssg += f"Valid options are {', '.join(valid_opts)}" - raise NameError(arg_name, mssg) + valid_opts: list[str] = [f"'{arg}'" for arg in legal_args] + msg += f"Valid options are {', '.join(valid_opts)}" + raise NameError(arg_name, msg) @export -def check_kwargs(supplied_args: Dict[str, Any], legal_args: List[str]): +def check_kwargs(supplied_args: dict[str, Any], legal_args: list[str]) -> None: """ Check all kwargs names against a list. Parameters ---------- - supplied_args : Dict[str, Any] + supplied_args : dict[str, Any] Arguments to check - legal_args : List[str] + legal_args : list[str] List of possible arguments. Raises @@ -149,7 +147,7 @@ def check_kwargs(supplied_args: Dict[str, Any], legal_args: List[str]): returned in the exception. """ - name_errs = [] + name_errs: list[Exception] = [] for name in supplied_args: try: check_kwarg(name, legal_args) @@ -161,11 +159,11 @@ def check_kwargs(supplied_args: Dict[str, Any], legal_args: List[str]): # Define generic type so enum_parse returns the same type as # passed in 'enum_class -EnumType = TypeVar("EnumType") # pylint: disable=invalid-name +EnumT = TypeVar("EnumT", bound=Enum) @export -def enum_parse(enum_cls: Type[EnumType], value: str) -> Optional[EnumType]: +def enum_parse(enum_cls: type[EnumT], value: str) -> EnumT | None: """ Try to parse a string value to an Enum member. @@ -187,14 +185,15 @@ def enum_parse(enum_cls: Type[EnumType], value: str) -> Optional[EnumType]: If something other than an Enum subclass is passed. """ - if not issubclass(enum_cls, Enum): # type: ignore - raise TypeError("Can only be used with classes derived from enum.Enum.") - if value in enum_cls.__members__: # type: ignore - return enum_cls.__members__[value] # type: ignore - val_lc = value.casefold() - val_map = {name.casefold(): name for name in enum_cls.__members__} # type: ignore + if not issubclass(enum_cls, Enum): + err_msg: str = "Can only be used with classes derived from enum.Enum." + raise TypeError(err_msg) + if value in enum_cls.__members__: + return enum_cls.__members__[value] + val_lc: str = value.casefold() + val_map: dict[str, str] = {name.casefold(): name for name in enum_cls.__members__} if val_lc in val_map: - return enum_cls.__members__[val_map[val_lc]] # type: ignore + return enum_cls.__members__[val_map[val_lc]] return None @@ -202,26 +201,26 @@ def enum_parse(enum_cls: Type[EnumType], value: str) -> Optional[EnumType]: class ParseableEnum: """Mix-in class for parseable Enum sub-classes.""" - def parse(self, value: str): + def parse(self: Self, value: str) -> Enum | None: """Return enumeration matching (case-insensitive) string value.""" return enum_parse(enum_cls=self.__class__, value=value) @export -def arg_to_list(arg: Union[str, List[str]], delims=",; ") -> List[str]: +def arg_to_list(arg: str | list[str], delims: str = ",; ") -> list[str]: """ Convert an optional list/str/str with delims into a list. Parameters ---------- - arg : Union[str, List[str]] + arg : Union[str, list[str]] A string, delimited string or list delims : str, optional The default delimiters to use, by default ",; " Returns ------- - List[str] + list[str] List of string components Raises @@ -241,25 +240,25 @@ def arg_to_list(arg: Union[str, List[str]], delims=",; ") -> List[str]: @export -def collapse_dicts(*dicts: Dict) -> Dict: +def collapse_dicts(*dicts: dict) -> dict: """Merge multiple dictionaries - later dicts have higher precedence.""" if len(dicts) == 0: return {} if len(dicts) == 1: return dicts[0] - out_dict: Dict = dicts[0] + out_dict: dict = dicts[0] for p_dict in dicts[1:]: out_dict = _merge_dicts(out_dict, p_dict) return out_dict -def _merge_dicts(dict1: Dict[Any, Any], dict2: Dict[Any, Any]): +def _merge_dicts(dict1: dict[Any, Any], dict2: dict[Any, Any]) -> dict: """Merge dict2 into dict1.""" if not dict2: return dict1 or {} if not dict1: return dict2 or {} - out_dict = {} + out_dict: dict = {} for key in set().union(dict1, dict2): if ( key in dict1 @@ -267,7 +266,7 @@ def _merge_dicts(dict1: Dict[Any, Any], dict2: Dict[Any, Any]): and key in dict2 and isinstance(dict2[key], dict) ): - d_val = _merge_dicts(dict1[key], dict2[key]) + d_val: dict = _merge_dicts(dict1[key], dict2[key]) elif key in dict2: d_val = dict2[key] else: @@ -276,11 +275,11 @@ def _merge_dicts(dict1: Dict[Any, Any], dict2: Dict[Any, Any]): return out_dict -def singleton(cls): +def singleton(cls: type) -> Callable: """Class decorator for singleton classes.""" - instances = {} + instances: dict[type[object], object] = {} - def get_instance(*args, **kwargs): + def get_instance(*args, **kwargs) -> object: nonlocal instances if cls not in instances: instances[cls] = cls(*args, **kwargs) @@ -307,23 +306,23 @@ class SingletonClass: """ - def __init__(self, wrapped_cls): + def __init__(self: SingletonClass, wrapped_cls: type[Any]) -> None: """Instantiate the class wrapper.""" - self.wrapped_cls = wrapped_cls - self.instance = None + self.wrapped_cls: type[Any] = wrapped_cls + self.instance: Self | None = None self.__doc__ = wrapped_cls.__doc__ - def __call__(self, *args, **kwargs): + def __call__(self: Self, *args, **kwargs) -> object: """Override the __call__ method for the wrapper class.""" if self.instance is None: self.instance = self.wrapped_cls(*args, **kwargs) return self.instance - def current(self): + def current(self: Self) -> object: """Return the current instance of the wrapped class.""" return self.instance - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: """Return the attribute `name` from the wrapped class.""" if hasattr(self.wrapped_cls, name): return getattr(self.wrapped_cls, name) @@ -354,7 +353,12 @@ class SingletonArgsClass(SingletonClass): """ - def __call__(self, *args, **kwargs): + def __init__(self: SingletonArgsClass, wrapped_cls: type[Any]) -> None: + super().__init__(wrapped_cls) + self.kwargs: dict[str, Any] | None = None + self.args: tuple[Any] | None = None + + def __call__(self, *args, **kwargs) -> object: """Override the __call__ method for the wrapper class.""" if ( self.instance is None @@ -362,8 +366,9 @@ def __call__(self, *args, **kwargs): or getattr(self.instance, "args", None) != args ): self.instance = self.wrapped_cls(*args, **kwargs) - self.instance.kwargs = kwargs - self.instance.args = args + if self.instance: + self.instance.kwargs = kwargs + self.instance.args = args return self.instance @@ -371,27 +376,27 @@ def __call__(self, *args, **kwargs): class ImportPlaceholder: """Placeholder class for optional imports.""" - def __init__(self, name: str, required_pkgs: List[str]): + def __init__(self, name: str, required_pkgs: list[str]) -> None: """Initialize class with imported item name and reqd. packages.""" - self.name = name - self.required_pkgs = required_pkgs - self.message = ( + self.name: str = name + self.required_pkgs: list[str] = required_pkgs + self.message: str = ( f"{self.name} cannot be loaded without the following packages" f" installed: {', '.join(self.required_pkgs)}" ) self._mssg_displayed = False - def _print_req_packages(self): + def _print_req_packages(self) -> None: if not self._mssg_displayed: print(self.message, "\nPlease install and restart the notebook.") self._mssg_displayed = True - def __getattr__(self, name): + def __getattr__(self, name) -> None: """When any attribute is accessed, print requirements.""" self._print_req_packages() raise ImportError(self.name) - def __call__(self, *args, **kwargs): + def __call__(self, *args, **kwargs) -> None: """If object is called, print requirements.""" del args, kwargs self._print_req_packages() diff --git a/msticpy/context/__init__.py b/msticpy/context/__init__.py index ab84b40c4..8bb52c998 100644 --- a/msticpy/context/__init__.py +++ b/msticpy/context/__init__.py @@ -4,6 +4,8 @@ # license information. # -------------------------------------------------------------------------- """Context Providers Subpackage.""" +from __future__ import annotations + from typing import Any from ..common.utility import ImportPlaceholder @@ -15,13 +17,13 @@ from .vtlookupv3 import vtlookupv3 else: # vtlookup3 will not load if vt package not installed - vtlookupv3 = ImportPlaceholder( # type: ignore + vtlookupv3 = ImportPlaceholder( "vtlookupv3", ["vt-py", "vt-graph-api", "nest_asyncio"], ) -_LAZY_IMPORTS = { +_LAZY_IMPORTS: set[str] = { "msticpy.context.geoip.GeoLiteLookup", "msticpy.context.geoip.IPStackLookup", "msticpy.context.tilookup.TILookup", diff --git a/msticpy/context/azure/azure_data.py b/msticpy/context/azure/azure_data.py index 4e926739c..8c048aef9 100644 --- a/msticpy/context/azure/azure_data.py +++ b/msticpy/context/azure/azure_data.py @@ -4,16 +4,18 @@ # license information. # -------------------------------------------------------------------------- """Uses the Azure Python SDK to collect and return details related to Azure.""" +from __future__ import annotations + import datetime import logging -from typing import Any, Dict, List, Optional, Tuple +from dataclasses import asdict, dataclass, field +from importlib.metadata import version +from typing import TYPE_CHECKING, Any, Callable, Iterable -import attr import numpy as np import pandas as pd -from azure.common.exceptions import CloudError -from azure.core.exceptions import ClientAuthenticationError -from azure.mgmt.resource.subscriptions import SubscriptionClient +from packaging.version import Version, parse +from typing_extensions import Self from ..._version import VERSION from ...auth.azure_auth import ( @@ -31,23 +33,35 @@ ) try: + from azure.common.exceptions import CloudError + from azure.core.exceptions import ClientAuthenticationError from azure.mgmt.network import NetworkManagementClient from azure.mgmt.resource import ResourceManagementClient + from azure.mgmt.resource.subscriptions import SubscriptionClient - try: + if parse(version("azure.mgmt.monitor")) > Version("1.0.1"): # Try new version but keep backward compat with 1.0.1 from azure.mgmt.monitor import MonitorManagementClient - except ImportError: - from azure.mgmt.monitor import MonitorClient as MonitorManagementClient # type: ignore + else: + from azure.mgmt.monitor import ( # type: ignore[attr-defined, no-redef] + MonitorClient as MonitorManagementClient, + ) from azure.mgmt.compute import ComputeManagementClient - from azure.mgmt.compute.models import VirtualMachineInstanceView + + if TYPE_CHECKING: + from azure.mgmt.compute.models import VirtualMachineInstanceView + from azure.mgmt.network.models import NetworkInterface + from azure.mgmt.subscription.models import Subscription except ImportError as imp_err: + error_msg: str = ( + "Cannot use this feature without these azure packages installed:\n" + "azure.mgmt.network\n" + "azure.mgmt.resource\n" + "azure.mgmt.monitor\n" + "azure.mgmt.compute\n" + ) raise MsticpyImportExtraError( - "Cannot use this feature without these azure packages installed", - "azure.mgmt.network", - "azure.mgmt.resource", - "azure.mgmt.monitor", - "azure.mgmt.compute", + error_msg, title="Error importing azure module", extra="azure", ) from imp_err @@ -55,9 +69,20 @@ __version__ = VERSION __author__ = "Pete Bryan" -logger = logging.getLogger(__name__) +# pylint:disable=too-many-lines -_CLIENT_MAPPING = { +logger: logging.Logger = logging.getLogger(__name__) + +_CLIENT_MAPPING: dict[ + str, + type[ + SubscriptionClient + | ResourceManagementClient + | NetworkManagementClient + | MonitorManagementClient + | ComputeManagementClient + ], +] = { "sub_client": SubscriptionClient, "resource_client": ResourceManagementClient, "network_client": NetworkManagementClient, @@ -66,82 +91,91 @@ } -# pylint: disable=too-few-public-methods, too-many-instance-attributes -# attr class doesn't need a method -@attr.s(auto_attribs=True) -class Items: +@dataclass +class Items: # pylint:disable=too-many-instance-attributes """attr class to build resource details dictionary.""" - resource_id: Optional[str] = None - name: Optional[str] = None - resource_type: Optional[str] = None - location: Optional[str] = None - tags: Optional[Any] = None - plan: Optional[Any] = None - properties: Optional[Any] = None - kind: Optional[str] = None - managed_by: Optional[str] = None - sku: Optional[str] = None - identity: Optional[str] = None + resource_id: str | None = None + name: str | None = None + resource_type: str | None = None + location: str | None = None + tags: Any = None + plan: Any = None + properties: Any = None + kind: str | None = None + managed_by: str | None = None + sku: str | None = None + identity: str | None = None state: Any = None -@attr.s(auto_attribs=True) +@dataclass class NsgItems: """attr class to build NSG rule dictionary.""" - rule_name: Optional[str] = None - description: Optional[str] = None - protocol: Optional[str] = None - direction: Optional[str] = None - src_ports: Optional[str] = None - dst_ports: Optional[str] = None - src_addrs: Optional[str] = None - dst_addrs: Optional[str] = None - action: Optional[str] = None + rule_name: str | None = None + description: str | None = None + protocol: str | None = None + direction: str | None = None + src_ports: str | None = None + dst_ports: str | None = None + src_addrs: str | None = None + dst_addrs: str | None = None + action: str | None = None -@attr.s(auto_attribs=True) +@dataclass class InterfaceItems: """attr class to build network interface details dictionary.""" - interface_id: Optional[str] = None - private_ip: Optional[str] = None - private_ip_allocation: Optional[str] = None - public_ip: Optional[str] = None - public_ip_allocation: Optional[str] = None - app_sec_group: Optional[List[Any]] = None - subnet: Optional[str] = None + interface_id: str | None = None + private_ip: str | None = None + private_ip_allocation: str | None = None + public_ip: str | None = None + public_ip_allocation: str | None = None + app_sec_group: list = field(default_factory=list) + subnet: str | None = None subnet_nsg: Any = None subnet_route_table: Any = None -class AzureData: +class AzureData: # pylint:disable=too-many-instance-attributes """Class for returning data on an Azure tenant.""" - def __init__(self, connect: bool = False, cloud: Optional[str] = None): + def __init__( + self: AzureData, + *, + connect: bool = False, + cloud: str | None = None, + ) -> None: """Initialize connector for Azure Python SDK.""" self.az_cloud_config = AzureCloudConfig(cloud) self.connected = False - self.credentials: Optional[AzCredentials] = None - self.sub_client: Optional[SubscriptionClient] = None - self.resource_client: Optional[ResourceManagementClient] = None - self.network_client: Optional[NetworkManagementClient] = None - self.monitoring_client: Optional[MonitorManagementClient] = None - self.compute_client: Optional[ComputeManagementClient] = None - self.cloud = cloud or self.az_cloud_config.cloud - self.endpoints = self.az_cloud_config.endpoints + self.credentials: AzCredentials | None = None + self.sub_client: SubscriptionClient | None = None + self.resource_client: ResourceManagementClient | None = None + self.network_client: NetworkManagementClient | None = None + self.monitoring_client: MonitorManagementClient | None = None + self.compute_client: ComputeManagementClient | None = None + self.cloud: str | None = cloud or self.az_cloud_config.cloud + self.endpoints: dict[str, Any] = self.az_cloud_config.endpoints + self._token: str | None = None + self.sent_urls: dict[str, Any] = {} + self.base_url: str = "" + self.url: str | None = None logger.info("Initialized AzureData") if connect: self.connect() def connect( - self, - auth_methods: Optional[List] = None, - tenant_id: Optional[str] = None, + self: Self, + auth_methods: list[str] | None = None, + tenant_id: str | None = None, + *, silent: bool = False, + cloud: str | None = None, **kwargs, - ): + ) -> None: """ Authenticate to the Azure SDK. @@ -171,20 +205,23 @@ def connect( msticpy.auth.azure_auth.az_connect : function to authenticate to Azure SDK """ - if kwargs.get("cloud"): - logger.info("Setting cloud to %s", kwargs["cloud"]) - self.cloud = kwargs["cloud"] + if cloud: + logger.info("Setting cloud to %s", cloud) + self.cloud = cloud self.az_cloud_config = AzureCloudConfig(self.cloud) auth_methods = auth_methods or self.az_cloud_config.auth_methods tenant_id = tenant_id or self.az_cloud_config.tenant_id self.credentials = az_connect( - auth_methods=auth_methods, tenant_id=tenant_id, silent=silent, **kwargs + auth_methods=auth_methods, + tenant_id=tenant_id, + silent=silent, + **kwargs, ) if not self.credentials: - raise CloudError("Could not obtain credentials.") - self._check_client("sub_client") + err_msg: str = "Could not obtain credentials." + raise CloudError(err_msg) if only_interactive_cred(self.credentials.modern) and not silent: - print("Check your default browser for interactive sign-in prompt.") + logger.warning("Check your default browser for interactive sign-in prompt.") self.sub_client = SubscriptionClient( credential=self.credentials.modern, @@ -192,11 +229,12 @@ def connect( credential_scopes=[self.az_cloud_config.token_uri], ) if not self.sub_client: - raise CloudError("Could not create a Subscription client.") + err_msg = "Could not create a Subscription client." + raise CloudError(err_msg) logger.info("Connected to Azure Subscription Client") self.connected = True - def get_subscriptions(self) -> pd.DataFrame: + def get_subscriptions(self: Self) -> pd.DataFrame: """ Get details of all subscriptions within the tenant. @@ -212,35 +250,43 @@ def get_subscriptions(self) -> pd.DataFrame: """ if self.connected is False: + err_msg: str = ( + "You need to connect to the service before using this function." + ) raise MsticpyNotConnectedError( - "You need to connect to the service before using this function.", + err_msg, help_uri=MsticpyAzureConfigError.DEF_HELP_URI, title="Please call connect() before continuing.", ) - subscription_ids = [] - display_names = [] - states = [] - try: - sub_list = list(self.sub_client.subscriptions.list()) # type: ignore - except AttributeError: - self._legacy_auth("sub_client") - sub_list = list(self.sub_client.subscriptions.list()) # type: ignore + subscription_ids: list[str] = [] + display_names: list[str] = [] + states: list[str] = [] + if self.sub_client: + try: + sub_list: Iterable[Any] = list( + self.sub_client.subscriptions.list(), + ) + except AttributeError: + self._legacy_auth("sub_client") + sub_list = list(self.sub_client.subscriptions.list()) - for item in sub_list: # type: ignore - subscription_ids.append(item.subscription_id) # type: ignore - display_names.append(item.display_name) # type: ignore - states.append(str(item.state)) # type: ignore + for item in sub_list: + if item.subscription_id: + subscription_ids.append(item.subscription_id) + if item.display_name: + display_names.append(item.display_name) + states.append(str(item.state)) return pd.DataFrame( { "Subscription ID": subscription_ids, "Display Name": display_names, "State": states, - } + }, ) - def get_subscription_info(self, sub_id: str) -> dict: + def get_subscription_info(self: Self, sub_id: str) -> dict: """ Get information on a specific subscription. @@ -261,28 +307,41 @@ def get_subscription_info(self, sub_id: str) -> dict: """ if self.connected is False: + err_msg: str = ( + "You need to connect to the service before using this function." + ) raise MsticpyNotConnectedError( - "You need to connect to the service before using this function.", + err_msg, help_uri=MsticpyAzureConfigError.DEF_HELP_URI, title="Please call connect() before continuing.", ) + if not self.sub_client: + err_msg = "sub_client must be defined to retrieve subscription info" + raise ValueError(err_msg) + try: - sub = self.sub_client.subscriptions.get(sub_id) # type: ignore + sub: Subscription = self.sub_client.subscriptions.get(sub_id) except AttributeError: self._legacy_auth("sub_client") - sub = self.sub_client.subscriptions.get(sub_id) # type: ignore + sub = self.sub_client.subscriptions.get(sub_id) + sub_loc: dict[str, Any] | None = None + quota_id: dict[str, Any] | None = None + spending_limit: dict[str, Any] | None = None + if sub.subscription_policies: + sub_loc = sub.subscription_policies.location_placement_id + quota_id = sub.subscription_policies.quota_id + spending_limit = sub.subscription_policies.spending_limit - sub_loc = sub.subscription_policies.location_placement_id # type: ignore return { "Subscription ID": sub.subscription_id, "Display Name": sub.display_name, "State": str(sub.state), "Subscription Location": sub_loc, - "Subscription Quota": sub.subscription_policies.quota_id, # type: ignore - "Spending Limit": sub.subscription_policies.spending_limit, # type: ignore + "Subscription Quota": quota_id, + "Spending Limit": spending_limit, } - def list_sentinel_workspaces(self, sub_id: str) -> Dict[str, str]: + def list_sentinel_workspaces(self: Self, sub_id: str) -> dict[str, str]: """ Return a list of Microsoft Sentinel workspaces in a Subscription. @@ -298,35 +357,40 @@ def list_sentinel_workspaces(self, sub_id: str) -> Dict[str, str]: A dictionary of workspace names and ids """ - print("Finding Microsoft Sentinel Workspaces...") - res = self.get_resources(sub_id=sub_id) # type: ignore + logger.info("Finding Microsoft Sentinel Workspaces...") + res: pd.DataFrame = self.get_resources(sub_id=sub_id) # handle no results if isinstance(res, pd.DataFrame) and not res.empty: - sentinel = res[ + sentinel: pd.DataFrame = res[ (res["resource_type"] == "Microsoft.OperationsManagement/solutions") & (res["name"].str.startswith("SecurityInsights")) ] - workspaces = [] + workspaces: list[str] = [] for wrkspace in sentinel["resource_id"]: - res_details = self.get_resource_details( - sub_id=sub_id, resource_id=wrkspace # type: ignore + res_details: dict[str, Any] = self.get_resource_details( + sub_id=sub_id, + resource_id=wrkspace, ) workspaces.append(res_details["properties"]["workspaceResourceId"]) - workspaces_dict = {} + workspaces_dict: dict[str, Any] = {} for wrkspace in workspaces: - name = wrkspace.split("/")[-1] + name: str = wrkspace.split("/")[-1] workspaces_dict[name] = wrkspace return workspaces_dict - print(f"No Microsoft Sentinel workspaces in {sub_id}") + logger.info("No Microsoft Sentinel workspaces in %s", sub_id) return {} # Get > List Aliases - get_sentinel_workspaces = list_sentinel_workspaces + get_sentinel_workspaces: Callable[..., dict[str, Any]] = list_sentinel_workspaces - def get_resources( # noqa: MC0001 - self, sub_id: str, rgroup: Optional[str] = None, get_props: bool = False + def get_resources( + self: Self, + sub_id: str, + rgroup: str | None = None, + *, + get_props: bool = False, ) -> pd.DataFrame: """ Return details on all resources in a subscription or Resource Group. @@ -349,59 +413,64 @@ def get_resources( # noqa: MC0001 """ # Check if connection and client required are already present if self.connected is False: + err_msg: str = ( + "You need to connect to the service before using this function." + ) raise MsticpyNotConnectedError( - "You need to connect to the service before using this function.", + err_msg, help_uri=MsticpyAzureConfigError.DEF_HELP_URI, title="Please call connect() before continuing.", ) self._check_client("resource_client", sub_id) + if not self.resource_client: + err_msg = "Resource client must be set to retrieve resources." + raise ValueError(err_msg) - resources = [] # type: List + resources: list[Any] = [] if rgroup is None: - resources.extend( - iter(self.resource_client.resources.list()) # type: ignore - ) + resources.extend(iter(self.resource_client.resources.list())) else: resources.extend( - iter( - self.resource_client.resources.list_by_resource_group( # type: ignore - rgroup - ) - ) + iter(self.resource_client.resources.list_by_resource_group(rgroup)), ) # Warn users about getting full properties for each resource if get_props: - print("Collecting properties for every resource may take some time...") + logger.info( + "Collecting properties for every resource may take some time...", + ) - resource_items = [] + resource_items: list[Any] = [] # Get properties for each resource for resource in resources: if get_props: if resource.type == "Microsoft.Compute/virtualMachines": - state = self._get_compute_state( - resource_id=resource.id, sub_id=sub_id + state: VirtualMachineInstanceView | None = self._get_compute_state( + resource_id=resource.id, + sub_id=sub_id, ) else: state = None try: - props = self.resource_client.resources.get_by_id( # type: ignore - resource.id, "2019-08-01" + props = self.resource_client.resources.get_by_id( + resource.id, + "2019-08-01", ).properties except CloudError: - props = self.resource_client.resources.get_by_id( # type: ignore - resource.id, self._get_api(resource.id, sub_id=sub_id) + props = self.resource_client.resources.get_by_id( + resource.id, + self._get_api(resource_id=resource.id, sub_id=sub_id), ).properties else: props = resource.properties state = None # Parse relevant resource attributes into a dataframe and return it - resource_details = attr.asdict( - Items( # type: ignore + resource_details = asdict( + Items( resource.id, resource.name, resource.type, @@ -414,17 +483,17 @@ def get_resources( # noqa: MC0001 resource.sku, resource.identity, state, - ) + ), ) resource_items.append(resource_details) return pd.DataFrame(resource_items) - def get_resource_details( # noqa: MC0001 - self, + def get_resource_details( + self: Self, sub_id: str, - resource_id: Optional[str] = None, - resource_details: Optional[dict] = None, + resource_id: str | None = None, + resource_details: dict[str, Any] | None = None, ) -> dict: """ Return the details of a specific Azure resource. @@ -451,23 +520,31 @@ def get_resource_details( # noqa: MC0001 """ # Check if connection and client required are already present if self.connected is False: + err_msg: str = ( + "You need to connect to the service before using this function." + ) raise MsticpyNotConnectedError( - "You need to connect to the service before using this function.", + err_msg, help_uri=MsticpyAzureConfigError.DEF_HELP_URI, title="Please call connect() before continuing.", ) self._check_client("resource_client", sub_id) + if not self.resource_client: + err_msg = "Cannot get resource details if resource client is not set." + raise ValueError(err_msg) # If a resource id is provided use get_by_id to get details if resource_id is not None: try: - resource = self.resource_client.resources.get_by_id( # type: ignore - resource_id, api_version=self._get_api(resource_id, sub_id=sub_id) + resource = self.resource_client.resources.get_by_id( + resource_id, + api_version=self._get_api(resource_id=resource_id, sub_id=sub_id), ) except AttributeError: self._legacy_auth("resource_client", sub_id) - resource = self.resource_client.resources.get_by_id( # type: ignore - resource_id, api_version=self._get_api(resource_id, sub_id=sub_id) + resource = self.resource_client.resources.get_by_id( + resource_id, + api_version=self._get_api(resource_id=resource_id, sub_id=sub_id), ) if resource.type == "Microsoft.Compute/virtualMachines": state = self._get_compute_state(resource_id=resource_id, sub_id=sub_id) @@ -476,7 +553,7 @@ def get_resource_details( # noqa: MC0001 # If resource details are provided use get to get details elif resource_details is not None: try: - resource = self.resource_client.resources.get( # type: ignore + resource = self.resource_client.resources.get( resource_details["resource_group_name"], resource_details["resource_provider_namespace"], resource_details["parent_resource_path"], @@ -493,7 +570,7 @@ def get_resource_details( # noqa: MC0001 ) except AttributeError: self._legacy_auth("resource_client", sub_id) - resource = self.resource_client.resources.get( # type: ignore + resource = self.resource_client.resources.get( resource_details["resource_group_name"], resource_details["resource_provider_namespace"], resource_details["parent_resource_path"], @@ -510,11 +587,12 @@ def get_resource_details( # noqa: MC0001 ) state = None else: - raise ValueError("Please provide either a resource ID or resource details") + err_msg = "Please provide either a resource ID or resource details" + raise ValueError(err_msg) # Parse relevent details into a dictionary to return - resource_details = attr.asdict( - Items( # type: ignore + return asdict( + Items( resource.id, resource.name, resource.type, @@ -527,16 +605,52 @@ def get_resource_details( # noqa: MC0001 resource.sku, resource.identity, state, - ) + ), ) - return resource_details + @staticmethod + def _normalize_resources( + resource_id: str | None = None, + resource_provider: str | None = None, + ) -> tuple[str, str]: + """Normalize elements depending on user input type.""" + if resource_id: + try: + return ( + resource_id.split("/")[6], + resource_id.split("/")[7], + ) + except IndexError as idx_err: + err_msg = ( + "Provided Resource ID isn't in the correct format. " + "It should look like:\n" + "/subscriptions/SUB_ID/resourceGroups/RESOURCE_GROUP/" + "providers/NAMESPACE/SERVICE_NAME/RESOURCE_NAME " + ) + raise MsticpyResourceError(err_msg) from idx_err + + elif resource_provider: + try: + return ( + resource_provider.split("/")[0], + resource_provider.split("/")[1], + ) + except IndexError as idx_err: + err_msg = ( + "Provided Resource Provider isn't in the correct format.\n" + "It should look like: NAMESPACE/SERVICE_NAME" + ) + raise MsticpyResourceError(err_msg) from idx_err + else: + err_msg = "Please provide an resource ID or resource provider namespace" + raise ValueError(err_msg) - def _get_api( # noqa: MC0001 - self, - resource_id: Optional[str] = None, - sub_id: Optional[str] = None, - resource_provider: Optional[str] = None, + def _get_api( + self: Self, + *, + sub_id: str, + resource_id: str | None = None, + resource_provider: str | None = None, ) -> str: """ Return the latest avaliable API version for the resource. @@ -558,75 +672,59 @@ def _get_api( # noqa: MC0001 """ # Check if connection and client required are already present if self.connected is False: + err_msg: str = ( + "You need to connect to the service before using this function." + ) raise MsticpyNotConnectedError( - "You need to connect to the service before using this function.", + err_msg, help_uri=MsticpyAzureConfigError.DEF_HELP_URI, title="Please call connect() before continuing.", ) - self._check_client("resource_client", sub_id) # type: ignore - - # Normalize elements depending on user input type - if resource_id is not None: - try: - namespace = resource_id.split("/")[6] - service = resource_id.split("/")[7] - except IndexError as idx_err: - raise MsticpyResourceError( - "Provided Resource ID isn't in the correct format.", - "It should look like:", - "/subscriptions/SUB_ID/resourceGroups/RESOURCE_GROUP/" - + "providers/NAMESPACE/SERVICE_NAME/RESOURCE_NAME ", - ) from idx_err + self._check_client("resource_client", sub_id) + if not self.resource_client: + err_msg = "Resource client must be set to get api." + raise ValueError(err_msg) - elif resource_provider is not None: - try: - namespace = resource_provider.split("/")[0] - service = resource_provider.split("/")[1] - except IndexError as idx_err: - raise MsticpyResourceError( - "Provided Resource Provider isn't in the correct format.", - "It should look like: NAMESPACE/SERVICE_NAME", - ) from idx_err - else: - raise ValueError( - "Please provide an resource ID or resource provider namespace" - ) + namespace, service = AzureData._normalize_resources( + resource_id=resource_id, + resource_provider=resource_provider, + ) # Get list of API versions for the service try: - provider = self.resource_client.providers.get(namespace) # type: ignore + provider = self.resource_client.providers.get(namespace) except AttributeError: - self._legacy_auth("resource_client", sub_id) # type: ignore - provider = self.resource_client.providers.get(namespace) # type: ignore - - resource_types = next( - ( - t - for t in provider.resource_types # type: ignore - if t.resource_type == service - ), - None, - ) + self._legacy_auth("resource_client", sub_id) + provider = self.resource_client.providers.get(namespace) + + if not provider.resource_types: + resource_types = None + else: + resource_types = next( + (t for t in provider.resource_types if t.resource_type == service), + None, + ) # Get first API version that isn't in preview if not resource_types: - raise MsticpyResourceError("Resource provider not found") + err_msg = "Resource provider not found" + raise MsticpyResourceError(err_msg) api_version = [ - v - for v in resource_types.api_versions # type: ignore - if "preview" not in v.lower() + v for v in resource_types.api_versions if "preview" not in v.lower() ] if api_version is None or not api_version: - api_ver = resource_types.api_versions[0] # type: ignore + api_ver = resource_types.api_versions[0] else: api_ver = api_version[0] return str(api_ver) def get_network_details( - self, network_id: str, sub_id: str - ) -> Tuple[pd.DataFrame, pd.DataFrame]: + self: Self, + network_id: str, + sub_id: str, + ) -> tuple[pd.DataFrame, pd.DataFrame]: """ Return details related to an Azure network interface and associated NSG. @@ -645,30 +743,38 @@ def get_network_details( """ # Check if connection and client required are already present if self.connected is False: + err_msg: str = ( + "You need to connect to the service before using this function." + ) raise MsticpyNotConnectedError( - "You need to connect to the service before using this function.", + err_msg, help_uri=MsticpyAzureConfigError.DEF_HELP_URI, title="Please call connect() before continuing.", ) self._check_client("network_client", sub_id) + if not self.network_client: + err_msg = "Cannot retrieve network details if the network client is not set" + raise ValueError(err_msg) # Get interface details and parse relevant elements into a dataframe try: - details = self.network_client.network_interfaces.get( # type: ignore - network_id.split("/")[4], network_id.split("/")[8] + details: NetworkInterface = self.network_client.network_interfaces.get( + network_id.split("/")[4], + network_id.split("/")[8], ) except AttributeError: self._legacy_auth("network_client", sub_id) - details = self.network_client.network_interfaces.get( # type: ignore - network_id.split("/")[4], network_id.split("/")[8] + details = self.network_client.network_interfaces.get( + network_id.split("/")[4], + network_id.split("/")[8], ) - ips = [] - for ip_addr in details.ip_configurations: # type: ignore - ip_details = attr.asdict( - InterfaceItems( # type: ignore - id=network_id, + ips: list[dict[str, Any]] = [] + for ip_addr in details.ip_configurations or []: + ip_details: dict[str, Any] = asdict( + InterfaceItems( + interface_id=network_id, private_ip=ip_addr.private_ip_address, private_ip_allocation=str(ip_addr.private_ip_allocation_method), public_ip=( @@ -681,27 +787,40 @@ def get_network_details( if ip_addr.public_ip_address else None ), - app_sec_group=ip_addr.application_security_groups, # type: ignore - subnet=ip_addr.subnet.name, # type: ignore - subnet_nsg=ip_addr.subnet.network_security_group, # type: ignore - subnet_route_table=ip_addr.subnet.route_table, # type: ignore - ) + app_sec_group=( + ip_addr.application_security_groups + if ip_addr.application_security_groups + else [] + ), + subnet=ip_addr.subnet.name if ip_addr.subnet else None, + subnet_nsg=( + ip_addr.subnet.network_security_group + if ip_addr.subnet + else None + ), + subnet_route_table=( + ip_addr.subnet.route_table if ip_addr.subnet else None + ), + ), ) ips.append(ip_details) ip_df = pd.DataFrame(ips) nsg_df = pd.DataFrame() - if details.network_security_group is not None: + if ( + details.network_security_group is not None + and details.network_security_group.id is not None + ): # Get NSG details and parse relevant elements into a dataframe - nsg_details = self.network_client.network_security_groups.get( # type: ignore - details.network_security_group.id.split("/")[4], # type: ignore - details.network_security_group.id.split("/")[8], # type: ignore + nsg_details = self.network_client.network_security_groups.get( + details.network_security_group.id.split("/")[4], + details.network_security_group.id.split("/")[8], ) nsg_rules = [] - for nsg in nsg_details.default_security_rules: # type: ignore - rules = attr.asdict( - NsgItems( # type: ignore + for nsg in nsg_details.default_security_rules: + rules = asdict( + NsgItems( rule_name=nsg.name, description=nsg.description, protocol=str(nsg.protocol), @@ -711,7 +830,7 @@ def get_network_details( src_addrs=nsg.source_address_prefix, dst_addrs=nsg.destination_address_prefix, action=str(nsg.access), - ) + ), ) nsg_rules.append(rules) @@ -719,14 +838,14 @@ def get_network_details( return ip_df, nsg_df - def get_metrics( # pylint: disable=too-many-locals - self, + def get_metrics( # pylint: disable=too-many-locals #noqa: PLR0913 + self: Self, metrics: str, resource_id: str, sub_id: str, sample_time: str = "hour", start_time: int = 30, - ) -> Dict[str, pd.DataFrame]: + ) -> dict[str, pd.DataFrame]: """ Return specified metrics on Azure Resource. @@ -756,44 +875,47 @@ def get_metrics( # pylint: disable=too-many-locals elif sample_time.casefold().startswith("m"): interval = "PT1M" else: - raise ValueError( - "invalid value for sample_time - specify 'hour', or 'minute'" - ) + err_msg: str = "invalid value for sample_time - specify 'hour', or 'minute'" + raise ValueError(err_msg) # Check if connection and client required are already present if self.connected is False: + err_msg = "You need to connect to the service before using this function." raise MsticpyNotConnectedError( - "You need to connect to the service before using this function.", + err_msg, help_uri=MsticpyAzureConfigError.DEF_HELP_URI, title="Please call connect() before continuing.", ) self._check_client("monitoring_client", sub_id) + if not self.monitoring_client: + err_msg = "Cannot get metrics if monitoring client is not set." + raise ValueError(err_msg) # Get metrics in one hour chunks for the last 30 days - start = datetime.datetime.now().date() + start = datetime.datetime.now(tz=datetime.timezone.utc).date() end = start - datetime.timedelta(days=start_time) try: - mon_details = self.monitoring_client.metrics.list( # type: ignore + mon_details = self.monitoring_client.metrics.list( resource_id, timespan=f"{end}/{start}", - interval=interval, # type: ignore + interval=interval, metricnames=f"{metrics}", aggregation="Total", ) except AttributeError: self._legacy_auth("monitoring_client", sub_id) - mon_details = self.monitoring_client.metrics.list( # type: ignore + mon_details = self.monitoring_client.metrics.list( resource_id, timespan=f"{end}/{start}", - interval=interval, # type: ignore + interval=interval, metricnames=f"{metrics}", aggregation="Total", ) - results = {} + results: dict[str, Any] = {} # Create a dict of all the results returned - for metric in mon_details.value: # type: ignore + for metric in mon_details.value: times: list = [] output = [] for time in metric.timeseries: @@ -801,14 +923,14 @@ def get_metrics( # pylint: disable=too-many-locals times.append(data.time_stamp) output.append(data.total) details = pd.DataFrame({"Time": times, "Data": output}) - details.replace(np.nan, 0, inplace=True) + details = details.replace(np.nan, 0) results[metric.name.value] = details return results - # pylint: enable=too-many-locals, too-many-arguments - def _get_compute_state( - self, resource_id: str, sub_id: str + self: Self, + resource_id: str, + sub_id: str, ) -> VirtualMachineInstanceView: """ Return the details on a Virtual Machine instance. @@ -827,13 +949,19 @@ def _get_compute_state( """ if self.connected is False: + err_msg: str = ( + "You need to connect to the service before using this function." + ) raise MsticpyNotConnectedError( - "You need to connect to the service before using this function.", + err_msg, help_uri=MsticpyAzureConfigError.DEF_HELP_URI, title="Please call connect() before continuing.", ) self._check_client("compute_client", sub_id) + if not self.compute_client: + err_msg = "Cannot provide compute state if compute_client is None." + raise ValueError(err_msg) # Parse the Resource ID to extract Resource Group and Resource Name r_details = resource_id.split("/") @@ -842,18 +970,22 @@ def _get_compute_state( # Get VM instance details and return them try: - instance_details = self.compute_client.virtual_machines.instance_view( # type: ignore - r_group, name + instance_details: VirtualMachineInstanceView = ( + self.compute_client.virtual_machines.instance_view( + r_group, + name, + ) ) except AttributeError: self._legacy_auth("compute_client", sub_id) - instance_details = self.compute_client.virtual_machines.instance_view( # type: ignore - r_group, name + instance_details = self.compute_client.virtual_machines.instance_view( + r_group, + name, ) - return instance_details # type: ignore + return instance_details - def _check_client(self, client_name: str, sub_id: Optional[str] = None): + def _check_client(self: Self, client_name: str, sub_id: str | None = None) -> None: """ Check required client is present, if not create it. @@ -865,34 +997,45 @@ def _check_client(self, client_name: str, sub_id: Optional[str] = None): The subscription ID for the client to connect to, by default None """ + if not self.credentials: + err_msg: str = "Credentials must be provided for _check_client to work." + raise ValueError(err_msg) if getattr(self, client_name) is None: - client = _CLIENT_MAPPING[client_name] + client: type[ + SubscriptionClient + | ResourceManagementClient + | NetworkManagementClient + | MonitorManagementClient + | ComputeManagementClient + ] = _CLIENT_MAPPING[client_name] if sub_id is None: - setattr( - self, - client_name, - client( - self.credentials.modern, # type: ignore - base_url=self.az_cloud_config.resource_manager, - credential_scopes=[self.az_cloud_config.token_uri], - ), - ) + if issubclass(client, SubscriptionClient): + setattr( + self, + client_name, + client( + self.credentials.modern, + base_url=self.az_cloud_config.resource_manager, + credential_scopes=[self.az_cloud_config.token_uri], + ), + ) else: setattr( self, client_name, client( - self.credentials.modern, # type: ignore - sub_id, + self.credentials.modern, + subscription_id=sub_id, base_url=self.az_cloud_config.resource_manager, credential_scopes=[self.az_cloud_config.token_uri], ), ) if getattr(self, client_name) is None: - raise CloudError("Could not create client") + err_msg = "Could not create client" + raise CloudError(err_msg) - def _legacy_auth(self, client_name: str, sub_id: Optional[str] = None): + def _legacy_auth(self: Self, client_name: str, sub_id: str | None = None) -> None: """ Create client with v1 authentication token. @@ -904,31 +1047,43 @@ def _legacy_auth(self, client_name: str, sub_id: Optional[str] = None): The subscription ID for the client to connect to, by default None """ - client = _CLIENT_MAPPING[client_name] - if sub_id is None: - setattr( - self, - client_name, - client( - self.credentials.legacy, # type: ignore - base_url=self.az_cloud_config.resource_manager, - credential_scopes=[self.az_cloud_config.token_uri], - ), + if not self.credentials: + err_msg: str = ( + "Credentials must be provided for legacy authentication to work." ) + raise ValueError(err_msg) + client: type[ + SubscriptionClient + | ResourceManagementClient + | NetworkManagementClient + | MonitorManagementClient + | ComputeManagementClient + ] = _CLIENT_MAPPING[client_name] + if sub_id is None: + if issubclass(client, SubscriptionClient): + setattr( + self, + client_name, + client( + self.credentials.legacy, + base_url=self.az_cloud_config.resource_manager, + credential_scopes=[self.az_cloud_config.token_uri], + ), + ) else: setattr( self, client_name, client( - self.credentials.legacy, # type: ignore - sub_id, + self.credentials.legacy, + subscription_id=sub_id, base_url=self.az_cloud_config.resource_manager, credential_scopes=[self.az_cloud_config.token_uri], ), ) -def get_api_headers(token: str) -> Dict: +def get_api_headers(token: str) -> dict: """ Return authorization header with current token. @@ -951,8 +1106,8 @@ def get_api_headers(token: str) -> Dict: def get_token( credential: AzCredentials, - tenant_id: Optional[str] = None, - cloud: Optional[str] = None, + tenant_id: str | None = None, + cloud: str | None = None, ) -> str: """ Extract token from a azure.identity object. @@ -983,12 +1138,14 @@ def get_token( else: try: token = credential.modern.get_token( - AzureCloudConfig().token_uri, tenant_id=tenant_id + AzureCloudConfig().token_uri, + tenant_id=tenant_id, ) except ClientAuthenticationError: credential = fallback_devicecode_creds(cloud=cloud, tenant_id=tenant_id) token = credential.modern.get_token( - AzureCloudConfig().token_uri, tenant_id=tenant_id + AzureCloudConfig().token_uri, + tenant_id=tenant_id, ) return token.token diff --git a/msticpy/context/azure/sentinel_analytics.py b/msticpy/context/azure/sentinel_analytics.py index cbafb1cfa..f0c94b8f3 100644 --- a/msticpy/context/azure/sentinel_analytics.py +++ b/msticpy/context/azure/sentinel_analytics.py @@ -4,27 +4,37 @@ # license information. # -------------------------------------------------------------------------- """Mixin Classes for Sentinel Analytics Features.""" -from typing import Optional +from __future__ import annotations + +import logging +from typing import Any, Callable from uuid import UUID, uuid4 import httpx import pandas as pd from azure.common.exceptions import CloudError from IPython.display import display +from typing_extensions import Self from ..._version import VERSION from ...common.exceptions import MsticpyUserError from .azure_data import get_api_headers -from .sentinel_utils import extract_sentinel_response, get_http_timeout +from .sentinel_utils import ( + SentinelUtilsMixin, + extract_sentinel_response, + get_http_timeout, +) __version__ = VERSION __author__ = "Pete Bryan" +logger: logging.Logger = logging.getLogger(__name__) + -class SentinelHuntingMixin: +class SentinelHuntingMixin(SentinelUtilsMixin): """Mixin class for Sentinel Hunting feature integrations.""" - def list_hunting_queries(self) -> pd.DataFrame: + def list_hunting_queries(self: Self) -> pd.DataFrame: """ Return all custom hunting queries in a Microsoft Sentinel workspace. @@ -34,16 +44,17 @@ def list_hunting_queries(self) -> pd.DataFrame: A table of the custom hunting queries. """ - saved_query_df = self._list_items( # type: ignore - item_type="ss_path", api_version="2020-08-01" + saved_query_df: pd.DataFrame = self._list_items( + item_type="ss_path", + api_version="2020-08-01", ) return saved_query_df[ saved_query_df["properties.category"] == "Hunting Queries" ] - get_hunting_queries = list_hunting_queries + get_hunting_queries: Callable[..., pd.DataFrame] = list_hunting_queries - def list_saved_queries(self) -> pd.DataFrame: + def list_saved_queries(self: Self) -> pd.DataFrame: """ Return all saved queries in a Microsoft Sentinel workspace. @@ -53,18 +64,17 @@ def list_saved_queries(self) -> pd.DataFrame: A table of the custom hunting queries. """ - saved_query_df = self._list_items( # type: ignore - item_type="ss_path", api_version="2020-08-01" + saved_query_df: pd.DataFrame = self._list_items( + item_type="ss_path", + api_version="2020-08-01", ) return saved_query_df - get_hunting_queries = list_hunting_queries - -class SentinelAnalyticsMixin: +class SentinelAnalyticsMixin(SentinelUtilsMixin): """Mixin class for Sentinel Analytics feature integrations.""" - def list_alert_rules(self) -> pd.DataFrame: + def list_alert_rules(self: Self) -> pd.DataFrame: """ Return all Microsoft Sentinel alert rules for a workspace. @@ -74,12 +84,13 @@ def list_alert_rules(self) -> pd.DataFrame: A table of the workspace's alert rules. """ - return self._list_items( # type: ignore - item_type="alert_rules", api_version="2024-01-01-preview" + return self._list_items( + item_type="alert_rules", + api_version="2024-01-01-preview", ) def _get_template_id( - self, + self: Self, template: str, ) -> str: """ @@ -105,29 +116,28 @@ def _get_template_id( """ try: UUID(template) - return template except ValueError as template_name: - templates = self.list_analytic_templates() - template_details = templates[ + templates: pd.DataFrame = self.list_analytic_templates() + template_details: pd.DataFrame = templates[ templates["properties.displayName"].str.contains(template) ] if len(template_details) > 1: display(template_details[["name", "properties.displayName"]]) - raise MsticpyUserError( - "More than one template found, please specify by GUID" - ) from template_name + err_msg: str = "More than one template found, please specify by GUID" + raise MsticpyUserError(err_msg) from template_name if not isinstance(template_details, pd.DataFrame) or template_details.empty: - raise MsticpyUserError( - f"Template {template_details} not found" - ) from template_name + err_msg = f"Template {template_details} not found" + raise MsticpyUserError(err_msg) from template_name return template_details["name"].iloc[0] + return template - def create_analytic_rule( # pylint: disable=too-many-arguments, too-many-locals - self, - template: str = None, - name: str = None, + def create_analytic_rule( # pylint: disable=too-many-arguments, too-many-locals #noqa:PLR0913 + self: Self, + template: str | None = None, + name: str | None = None, + *, enabled: bool = True, - query: str = None, + query: str | None = None, query_frequency: str = "PT5H", query_period: str = "PT5H", severity: str = "Medium", @@ -135,9 +145,9 @@ def create_analytic_rule( # pylint: disable=too-many-arguments, too-many-locals suppression_enabled: bool = False, trigger_operator: str = "GreaterThan", trigger_threshold: int = 0, - description: str = None, - tactics: list = None, - ) -> Optional[str]: + description: str | None = None, + tactics: list[str] | None = None, + ) -> str | None: """ Create a Sentinel Analytics Rule. @@ -173,7 +183,7 @@ def create_analytic_rule( # pylint: disable=too-many-arguments, too-many-locals Returns ------- - Optional[str] + str|None The name/ID of the analytic rule. Raises @@ -184,11 +194,13 @@ def create_analytic_rule( # pylint: disable=too-many-arguments, too-many-locals If the API returns an error. """ - self.check_connected() # type: ignore + self.check_connected() if template: - template_id = self._get_template_id(template) - templates = self.list_analytic_templates() - template_details = templates[templates["name"] == template_id].iloc[0] + template_id: str = self._get_template_id(template) + templates: pd.DataFrame = self.list_analytic_templates() + template_details: pd.Series = templates[ + templates["name"] == template_id + ].iloc[0] name = template_details["properties.displayName"] query = template_details["properties.query"] query_frequency = template_details["properties.queryFrequency"] @@ -207,13 +219,12 @@ def create_analytic_rule( # pylint: disable=too-many-arguments, too-many-locals tactics = [] if not name: - raise MsticpyUserError( - "Please specify either a template ID or analytic details." - ) + err_msg: str = "Please specify either a template ID or analytic details." + raise MsticpyUserError(err_msg) - rule_id = uuid4() - analytic_url = self.sent_urls["alert_rules"] + f"/{rule_id}" # type: ignore - data_items = { + rule_id: UUID = uuid4() + analytic_url: str = self.sent_urls["alert_rules"] + f"/{rule_id}" + data_items: dict[str, Any] = { "displayName": name, "query": query, "queryFrequency": query_frequency, @@ -227,22 +238,25 @@ def create_analytic_rule( # pylint: disable=too-many-arguments, too-many-locals "tactics": tactics, "enabled": str(enabled).lower(), } - data = extract_sentinel_response(data_items, props=True) + data: dict[str, Any] = extract_sentinel_response(data_items, props=True) data["kind"] = "Scheduled" - params = {"api-version": "2020-01-01"} - response = httpx.put( + params: dict[str, str] = {"api-version": "2020-01-01"} + if not self._token: + err_msg = "Token not found, can't create analytic rule." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( analytic_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data), timeout=get_http_timeout(), ) - if response.status_code != 201: + if not response.is_success: raise CloudError(response=response) - print("Analytic Created.") + logger.info("Analytic Created.") return response.json().get("name") - def _get_analytic_id(self, analytic: str) -> str: + def _get_analytic_id(self: Self, analytic: str) -> str: """ Get the GUID of an analytic rule. @@ -264,27 +278,25 @@ def _get_analytic_id(self, analytic: str) -> str: """ try: UUID(analytic) - return analytic except ValueError as analytic_name: - analytics = self.list_analytic_rules() - analytic_details = analytics[ + analytics: pd.DataFrame = self.list_analytic_rules() + analytic_details: pd.DataFrame = analytics[ analytics["properties.displayName"].str.contains(analytic) ] if len(analytic_details) > 1: display(analytic_details[["name", "properties.displayName"]]) - raise MsticpyUserError( - "More than one analytic found, please specify by GUID" - ) from analytic_name + err_msg: str = "More than one analytic found, please specify by GUID" + raise MsticpyUserError(err_msg) from analytic_name if not isinstance(analytic_details, pd.DataFrame) or analytic_details.empty: - raise MsticpyUserError( - f"Analytic {analytic_details} not found" - ) from analytic_name + err_msg = f"Analytic {analytic_details} not found" + raise MsticpyUserError(err_msg) from analytic_name return analytic_details["name"].iloc[0] + return analytic def delete_analytic_rule( - self, + self: Self, analytic_rule: str, - ): + ) -> None: """ Delete a deployed Analytic rule from a Sentinel workspace. @@ -299,19 +311,22 @@ def delete_analytic_rule( If the API returns an error. """ - self.check_connected() # type: ignore - analytic_id = self._get_analytic_id(analytic_rule) - analytic_url = self.sent_urls["alert_rules"] + f"/{analytic_id}" # type: ignore - params = {"api-version": "2020-01-01"} - response = httpx.delete( + self.check_connected() + analytic_id: str = self._get_analytic_id(analytic_rule) + analytic_url: str = self.sent_urls["alert_rules"] + f"/{analytic_id}" + params: dict[str, str] = {"api-version": "2020-01-01"} + if not self._token: + err_msg: str = "Token not found, can't delete analytic rule." + raise ValueError(err_msg) + response: httpx.Response = httpx.delete( analytic_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, timeout=get_http_timeout(), ) - if response.status_code != 200: + if response.is_error: raise CloudError(response=response) - print("Analytic Deleted.") + logger.info("Analytic Deleted.") def list_analytic_templates(self) -> pd.DataFrame: """ @@ -328,8 +343,8 @@ def list_analytic_templates(self) -> pd.DataFrame: If a valid result is not returned. """ - return self._list_items(item_type="alert_template") # type: ignore + return self._list_items(item_type="alert_template") - get_alert_rules = list_alert_rules - list_analytic_rules = list_alert_rules - get_analytic_rules = list_alert_rules + get_alert_rules: Callable[..., pd.DataFrame] = list_alert_rules + list_analytic_rules: Callable[..., pd.DataFrame] = list_alert_rules + get_analytic_rules: Callable[..., pd.DataFrame] = list_alert_rules diff --git a/msticpy/context/azure/sentinel_bookmarks.py b/msticpy/context/azure/sentinel_bookmarks.py index 32d28f936..07660ae9f 100644 --- a/msticpy/context/azure/sentinel_bookmarks.py +++ b/msticpy/context/azure/sentinel_bookmarks.py @@ -4,27 +4,37 @@ # license information. # -------------------------------------------------------------------------- """Mixin Classes for Sentinel Bookmark Features.""" -from typing import Dict, List, Optional, Union +from __future__ import annotations + +import logging +from typing import Any, Callable from uuid import UUID, uuid4 import httpx import pandas as pd from azure.common.exceptions import CloudError from IPython.display import display +from typing_extensions import Self from ..._version import VERSION from ...common.exceptions import MsticpyUserError from .azure_data import get_api_headers -from .sentinel_utils import extract_sentinel_response, get_http_timeout +from .sentinel_utils import ( + SentinelUtilsMixin, + extract_sentinel_response, + get_http_timeout, +) __version__ = VERSION __author__ = "Pete Bryan" +logger: logging.Logger = logging.getLogger(__name__) + -class SentinelBookmarksMixin: +class SentinelBookmarksMixin(SentinelUtilsMixin): """Mixin class with Sentinel Bookmark integrations.""" - def list_bookmarks(self) -> pd.DataFrame: + def list_bookmarks(self: Self) -> pd.DataFrame: """ Return a list of Bookmarks from a Sentinel workspace. @@ -34,16 +44,16 @@ def list_bookmarks(self) -> pd.DataFrame: A set of bookmarks. """ - return self._list_items(item_type="bookmarks") # type: ignore + return self._list_items(item_type="bookmarks") - def create_bookmark( - self, + def create_bookmark( # noqa:PLR0913 + self: Self, name: str, query: str, - results: str = None, - notes: str = None, - labels: List[str] = None, - ) -> Optional[str]: + results: str | None = None, + notes: str | None = None, + labels: list[str] | None = None, + ) -> str | None: """ Create a bookmark in the Sentinel Workspace. @@ -62,7 +72,7 @@ def create_bookmark( Returns ------- - Optional[str] + str|None The name/ID of the bookmark. Raises @@ -71,11 +81,11 @@ def create_bookmark( If API returns an error. """ - self.check_connected() # type: ignore + self.check_connected() # Generate or use resource ID bkmark_id = str(uuid4()) - bookmark_url = self.sent_urls["bookmarks"] + f"/{bkmark_id}" # type: ignore - data_items: Dict[str, Union[str, List]] = { + bookmark_url: str = self.sent_urls["bookmarks"] + f"/{bkmark_id}" + data_items: dict[str, str | list] = { "displayName": name, "query": query, } @@ -85,24 +95,27 @@ def create_bookmark( data_items["notes"] = notes if labels: data_items["labels"] = labels - data = extract_sentinel_response(data_items, props=True) - params = {"api-version": "2020-01-01"} - response = httpx.put( + data: dict[str, Any] = extract_sentinel_response(data_items, props=True) + params: dict[str, str] = {"api-version": "2020-01-01"} + if not self._token: + err_msg = "Token not found, can't create bookmark." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( bookmark_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data), timeout=get_http_timeout(), ) - if response.status_code == 200: - print("Bookmark created.") + if response.is_success: + logger.info("Bookmark created.") return response.json().get("name") raise CloudError(response=response) def delete_bookmark( - self, + self: Self, bookmark: str, - ): + ) -> None: """ Delete the selected bookmark. @@ -117,22 +130,25 @@ def delete_bookmark( If the API returns an error. """ - self.check_connected() # type: ignore - bookmark_id = self._get_bookmark_id(bookmark) - bookmark_url = self.sent_urls["bookmarks"] + f"/{bookmark_id}" # type: ignore - params = {"api-version": "2020-01-01"} - response = httpx.delete( + self.check_connected() + bookmark_id: str = self._get_bookmark_id(bookmark) + bookmark_url: str = self.sent_urls["bookmarks"] + f"/{bookmark_id}" + params: dict[str, str] = {"api-version": "2020-01-01"} + if not self._token: + err_msg = "Token not found, can't delete bookmatk." + raise ValueError(err_msg) + response: httpx.Response = httpx.delete( bookmark_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, timeout=get_http_timeout(), ) - if response.status_code == 200: - print("Bookmark deleted.") + if response.is_success: + logger.info("Bookmark deleted.") else: raise CloudError(response=response) - def _get_bookmark_id(self, bookmark: str) -> str: + def _get_bookmark_id(self: Self, bookmark: str) -> str: """ Get the ID of a bookmark. @@ -154,24 +170,22 @@ def _get_bookmark_id(self, bookmark: str) -> str: """ try: UUID(bookmark) - return bookmark except ValueError as bkmark_name: - bookmarks = self.list_bookmarks() - filtered_bookmarks = bookmarks[ + bookmarks: pd.DataFrame = self.list_bookmarks() + filtered_bookmarks: pd.DataFrame = bookmarks[ bookmarks["properties.displayName"].str.contains(bookmark) ] if len(filtered_bookmarks) > 1: display(filtered_bookmarks[["name", "properties.displayName"]]) - raise MsticpyUserError( - "More than one incident found, please specify by GUID" - ) from bkmark_name + err_msg: str = "More than one incident found, please specify by GUID" + raise MsticpyUserError(err_msg) from bkmark_name if ( not isinstance(filtered_bookmarks, pd.DataFrame) or filtered_bookmarks.empty ): - raise MsticpyUserError( - f"Incident {bookmark} not found" - ) from bkmark_name + err_msg = f"Incident {bookmark} not found" + raise MsticpyUserError(err_msg) from bkmark_name return filtered_bookmarks["name"].iloc[0] + return bookmark - get_bookmarks = list_bookmarks + get_bookmarks: Callable[..., pd.DataFrame] = list_bookmarks diff --git a/msticpy/context/azure/sentinel_core.py b/msticpy/context/azure/sentinel_core.py index 65e843c42..97c9ed15d 100644 --- a/msticpy/context/azure/sentinel_core.py +++ b/msticpy/context/azure/sentinel_core.py @@ -9,16 +9,15 @@ import logging import warnings from functools import partial -from typing import Any, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Callable -import pandas as pd +from typing_extensions import Self from ..._version import VERSION from ...common.exceptions import MsticpyUserConfigError from ...common.wsconfig import WorkspaceConfig -from .azure_data import AzureData, get_token +from .azure_data import get_token from .sentinel_analytics import SentinelAnalyticsMixin, SentinelHuntingMixin -from .sentinel_bookmarks import SentinelBookmarksMixin from .sentinel_dynamic_summary import SentinelDynamicSummaryMixin, SentinelQueryProvider from .sentinel_incidents import SentinelIncidentsMixin from .sentinel_search import SentinelSearchlistsMixin @@ -26,17 +25,19 @@ from .sentinel_utils import ( _PATH_MAPPING, SentinelInstanceDetails, - SentinelUtilsMixin, parse_resource_id, validate_resource_id, ) from .sentinel_watchlists import SentinelWatchlistsMixin from .sentinel_workspaces import SentinelWorkspacesMixin +if TYPE_CHECKING: + import pandas as pd + __version__ = VERSION __author__ = "Pete Bryan" -logger = logging.getLogger(__name__) +logger: logging.Logger = logging.getLogger(__name__) _SUB_ID = "subscription_id" _RES_GRP = "resource_group" @@ -58,16 +59,16 @@ def _create_ws_defaults( ) -_LEGACY_PARAM_NAMES = { +_LEGACY_PARAM_NAMES: dict[str, str] = { "sub_id": _SUB_ID, "res_grp": _RES_GRP, "ws_name": _WS_NAME, "workspace": _WS_NAME, "res_id": _RES_ID, } -_CORE_WS_PARAMETERS = [_SUB_ID, _RES_GRP, _WS_NAME] -_WS_PARAMETERS = _CORE_WS_PARAMETERS + [_RES_ID] -_MISSING_PARAMS_ERR = [ +_CORE_WS_PARAMETERS: list[str] = [_SUB_ID, _RES_GRP, _WS_NAME] +_WS_PARAMETERS: list[str] = [*_CORE_WS_PARAMETERS, _RES_ID] +_MISSING_PARAMS_ERR: list[str] = [ "Unable to build a valid resource ID from the parameters provided.", "This class requires either a valid Azure resource ID or a combination of", "subscription ID, resource group and workspace name.", @@ -88,7 +89,7 @@ def _create_ws_defaults( ] -def _map_legacy_param_names(**kwargs) -> Dict[str, Any]: +def _map_legacy_param_names(**kwargs) -> dict[str, Any]: """ Map legacy parameter names to current names. @@ -118,28 +119,26 @@ def _map_legacy_param_names(**kwargs) -> Dict[str, Any]: class MicrosoftSentinel( SentinelAnalyticsMixin, SentinelHuntingMixin, - SentinelBookmarksMixin, SentinelDynamicSummaryMixin, - SentinelIncidentsMixin, - SentinelUtilsMixin, SentinelWatchlistsMixin, SentinelSearchlistsMixin, SentinelWorkspacesMixin, SentinelTIMixin, - AzureData, + SentinelIncidentsMixin, ): """Class for returning key Microsoft Sentinel elements.""" - def __init__( - self, - resource_id: Optional[str] = None, - connect: Optional[bool] = False, - cloud: Optional[str] = None, - subscription_id: Optional[str] = None, - resource_group: Optional[str] = None, - workspace_name: Optional[str] = None, + def __init__( # pylint:disable=too-many-arguments # noqa:PLR0913 + self: MicrosoftSentinel, + resource_id: str | None = None, + *, + connect: bool = False, + cloud: str | None = None, + subscription_id: str | None = None, + resource_group: str | None = None, + workspace_name: str | None = None, **kwargs, - ): + ) -> None: """ Initialize connector for Azure APIs. @@ -168,6 +167,10 @@ def __init__( If not specifying a resource ID, the Workspace name of the Sentinel Workspace, by default None `ws_name` and `workspace` are aliases for workspace_name + workspace : str, optional + If not specifying a resource ID, the Workspace name of the + Sentinel Workspace, by default None + `ws_name` and `workspace` are aliases for workspace_name Notes ----- @@ -180,9 +183,9 @@ def __init__( the workspace details from the msticpyconfig configuration file. """ - super().__init__(connect=False, cloud=cloud) + super(SentinelIncidentsMixin, self).__init__(connect=False, cloud=cloud) - init_kwargs = _map_legacy_param_names(**kwargs) + init_kwargs: dict[str, Any] = _map_legacy_param_names(**kwargs) if resource_id: init_kwargs[_RES_ID] = resource_id if subscription_id: @@ -192,15 +195,13 @@ def __init__( if workspace_name: init_kwargs[_WS_NAME] = workspace_name - self._default_settings: Callable[..., SentinelInstanceDetails] = ( - self._set_ws_defaults(_create_ws_defaults, **init_kwargs) - ) - - self.base_url = self.az_cloud_config.resource_manager - self.sent_urls: Dict[str, str] = {} - self.sent_data_query: Optional[SentinelQueryProvider] = None # type: ignore - self.url: Optional[str] = None - self._token: Optional[str] = None + self._default_settings: Callable[ + ..., + SentinelInstanceDetails, + ] = self._set_ws_defaults(_create_ws_defaults, **init_kwargs) + self.base_url: str = self.az_cloud_config.resource_manager + self.sent_data_query: SentinelQueryProvider | None = None + self.url: str | None = None logger.info("Initializing Microsoft Sentinel connector") logger.info( @@ -215,13 +216,16 @@ def __init__( if connect: self.connect(**kwargs) - def connect( - self, - auth_methods: Optional[List] = None, - tenant_id: Optional[str] = None, + def connect( # noqa:PLR0913 + self: Self, + auth_methods: list[str] | None = None, + tenant_id: str | None = None, + *, silent: bool = False, + cloud: str | None = None, + token: str | None = None, **kwargs, - ): + ) -> None: """ Authenticate with the SDK & API. @@ -252,6 +256,8 @@ def connect( If specified, this will override the resource group name set during initialization. `res_grp` is an alias for resource_group. + token: str, optional + If specified, utilize this token to authenticate against Azure. Notes ----- @@ -273,16 +279,16 @@ def connect( set_default_workspace : method to set the default workspace settings """ - connect_kwargs = _map_legacy_param_names(**kwargs) + connect_kwargs: dict[str, Any] = _map_legacy_param_names(**kwargs) if any(connect_kwargs.get(ws_param) for ws_param in _CORE_WS_PARAMETERS): try: - sentinel_instance = SentinelInstanceDetails( # type: ignore + sentinel_instance: SentinelInstanceDetails = SentinelInstanceDetails( subscription_id=connect_kwargs.get(_SUB_ID) - or self.default_subscription_id, # type: ignore + or self.default_subscription_id, resource_group=connect_kwargs.get(_RES_GRP) - or self.default_resource_group, # type: ignore + or self.default_resource_group, workspace_name=connect_kwargs.get(_WS_NAME) - or self.default_workspace_name, # type: ignore + or self.default_workspace_name, ) except TypeError as err: raise MsticpyUserConfigError( @@ -292,7 +298,7 @@ def connect( else: try: sentinel_instance = SentinelInstanceDetails.from_resource_id( - connect_kwargs.get(_RES_ID) or self.default_resource_id # type: ignore + connect_kwargs.get(_RES_ID) or self.default_resource_id, ) except TypeError as err: raise MsticpyUserConfigError( @@ -301,45 +307,52 @@ def connect( ) from err self._create_api_paths_for_workspace(sentinel_instance) - if kwargs.get("cloud", self.cloud) != self.cloud: + if cloud is not None and cloud != self.cloud: + err_msg: str = ( + "Cannot switch to different cloud " + "and specify the new cloud name using the `cloud` parameter." + ) raise MsticpyUserConfigError( - "Cannot switch to different cloud", - f"Current cloud '{self.cloud}'", - f"Create a new instance of `{self.__class__.__name__}`", - "and specify the new cloud name using the `cloud` parameter.", + err_msg, title="Cannot switch cloud at connect time", ) logger.info("Using tenant id %s", tenant_id) - az_connect_kwargs = { + az_connect_kwargs: dict[str, Any] = { key: value for key, value in connect_kwargs.items() if key not in _WS_PARAMETERS } if tenant_id: az_connect_kwargs["tenant_id"] = tenant_id - self._token = az_connect_kwargs.pop("token", None) + self._token = token super().connect(auth_methods=auth_methods, silent=silent, **az_connect_kwargs) + if not self.credentials: + err_msg = "Could not connect." + raise ValueError(err_msg) if not self._token: logger.info("Getting token for %s", tenant_id) self._token = get_token( - self.credentials, tenant_id=tenant_id, cloud=self.cloud # type: ignore + self.credentials, + tenant_id=tenant_id, + cloud=self.cloud, ) def _create_api_paths_for_workspace( self, sentinel_instance: SentinelInstanceDetails, - ): + ) -> None: """Save configuration and build API URLs for workspace.""" try: validate_resource_id(sentinel_instance.resource_id) except MsticpyUserConfigError as err: - logger.error("Error validating resource ID %s", err) + logger.exception("Error validating resource ID") raise MsticpyUserConfigError( *_MISSING_PARAMS_ERR, title="Unable to build valid resource ID", ) from err self.url = self._build_sentinel_api_root( - sentinel_instance=sentinel_instance, base_url=self.base_url + sentinel_instance=sentinel_instance, + base_url=self.base_url, ) self.sent_urls = { @@ -347,19 +360,20 @@ def _create_api_paths_for_workspace( } logger.info("API URLs set to %s", self.sent_urls) - def set_default_subscription(self, subscription_id: str): + def set_default_subscription(self: Self, _: str) -> None: """Set the default subscription to use to `subscription_id`.""" - raise NotImplementedError( + err_msg: str = ( "This method is deprecated. Use `set_default_workspace` instead " "or set the subscription ID during initialization." ) + raise NotImplementedError(err_msg) def set_default_workspace( self, - workspace: Optional[str] = None, - resource_id: Optional[str] = None, + workspace: str | None = None, + resource_id: str | None = None, **kwargs, - ): + ) -> None: """ Set the default workspace from workspace name or resource id. @@ -378,7 +392,7 @@ def set_default_workspace( authenticate with the new workspace. """ - adjust_kwargs = _map_legacy_param_names(**kwargs) + adjust_kwargs: dict[str, Any] = _map_legacy_param_names(**kwargs) if workspace: adjust_kwargs[_WS_NAME] = workspace if resource_id: @@ -388,7 +402,8 @@ def set_default_workspace( "Setting the workspace from the `subscription_id` parameter " "no longer supported. Please use the `workspace` parameter " "instead or set the workspace or " - "Azure resource ID during initialization." + "Azure resource ID during initialization.", + stacklevel=1, ) workspace = adjust_kwargs.get(_WS_NAME, workspace) @@ -417,37 +432,37 @@ def set_default_workspace( ) @property - def default_workspace_settings(self) -> Dict[str, Any]: + def default_workspace_settings(self: Self) -> dict[str, Any]: """Return current default workspace settings.""" return WorkspaceConfig.from_settings( { WorkspaceConfig.CONF_SUB_ID: self.default_subscription_id, WorkspaceConfig.CONF_RES_GROUP: self.default_resource_group, WorkspaceConfig.CONF_WS_NAME: self.default_workspace_name, - } + }, ).mp_settings @property - def default_subscription_id(self) -> Optional[str]: + def default_subscription_id(self: Self) -> str: """Return the default subscription ID.""" return self._default_settings().subscription_id @property - def default_resource_group(self) -> Optional[str]: + def default_resource_group(self: Self) -> str: """Return the default resource group.""" return self._default_settings().resource_group @property - def default_workspace_name(self) -> Optional[str]: + def default_workspace_name(self: Self) -> str: """Return the default workspace Name.""" return self._default_settings().workspace_name @property - def default_resource_id(self) -> Optional[str]: + def default_resource_id(self: Self) -> str: """Return the default resource ID.""" return self._default_settings().resource_id - def list_data_connectors(self) -> pd.DataFrame: + def list_data_connectors(self: Self) -> pd.DataFrame: """ List deployed data connectors. @@ -465,9 +480,12 @@ def list_data_connectors(self) -> pd.DataFrame: return self._list_items(item_type="data_connectors") def _set_ws_defaults( - self, create_defaults_func: Callable[..., SentinelInstanceDetails], **kwargs + self, + create_defaults_func: Callable[..., SentinelInstanceDetails], + **kwargs, ) -> Callable: - """Create a partial function with the defaults set based on the kwargs. + """ + Create a partial function with the defaults set based on the kwargs. Parameters ---------- @@ -483,9 +501,11 @@ def _set_ws_defaults( A partial function with the defaults set based on the kwargs """ - non_null_kwargs = {key: value for key, value in kwargs.items() if value} - workspace_name = non_null_kwargs.get(_WS_NAME) - workspace_config: Optional[WorkspaceConfig] = None + non_null_kwargs: dict[str, Any] = { + key: value for key, value in kwargs.items() if value + } + workspace_name: str | None = non_null_kwargs.get(_WS_NAME) + workspace_config: WorkspaceConfig | None = None if not any(ws_param in non_null_kwargs for ws_param in _WS_PARAMETERS): # if we can't build a resource ID from the parameters, try to get the # default workspace settings from the configuration file. @@ -495,7 +515,7 @@ def _set_ws_defaults( elif workspace_name and workspace_name in WorkspaceConfig.list_workspaces(): workspace_config = WorkspaceConfig(workspace=workspace_name) if workspace_config: - config_values = { + config_values: dict[str, Any] = { _SUB_ID: workspace_config.get(WorkspaceConfig.CONF_SUB_ID), _RES_GRP: workspace_config.get(WorkspaceConfig.CONF_RES_GROUP), _WS_NAME: workspace_config.get(WorkspaceConfig.CONF_WS_NAME), @@ -513,7 +533,8 @@ def _set_ws_defaults( # This overrides any other settings. if resource_id := non_null_kwargs.get(_RES_ID): create_defaults_func = partial( - create_defaults_func, **parse_resource_id(resource_id) + create_defaults_func, + **parse_resource_id(resource_id), ) return create_defaults_func diff --git a/msticpy/context/azure/sentinel_dynamic_summary.py b/msticpy/context/azure/sentinel_dynamic_summary.py index 2109fd00e..c081dde72 100644 --- a/msticpy/context/azure/sentinel_dynamic_summary.py +++ b/msticpy/context/azure/sentinel_dynamic_summary.py @@ -4,44 +4,71 @@ # license information. # -------------------------------------------------------------------------- """Sentinel Dynamic Summary Mixin class.""" +from __future__ import annotations + import logging -from datetime import datetime from functools import singledispatchmethod -from typing import Optional +from typing import TYPE_CHECKING, Any, Callable, Iterable import httpx -import pandas as pd +from typing_extensions import Self + +from msticpy.context.azure.sentinel_utils import SentinelUtilsMixin from ..._version import VERSION from ...common.exceptions import MsticpyAzureConnectionError, MsticpyParameterError from ...common.pkg_config import get_config, get_http_timeout from ...data.core.data_providers import QueryProvider from .azure_data import get_api_headers - -# pylint: disable=unused-import -from .sentinel_dynamic_summary_types import ( # noqa: F401 +from .sentinel_dynamic_summary_types import ( DynamicSummary, DynamicSummaryItem, df_to_dynamic_summary, ) +if TYPE_CHECKING: + from datetime import datetime + + import pandas as pd + + __version__ = VERSION __author__ = "Ian Hellen" _DYN_SUM_API_VERSION = "2023-03-01-preview" -logger = logging.getLogger(__name__) +logger: logging.Logger = logging.getLogger(__name__) -class SentinelDynamicSummaryMixin: +class SentinelDynamicSummaryMixin(SentinelUtilsMixin): """Mixin class with Sentinel Dynamic Summary integrations.""" # expose these methods as members of the Sentinel class. - df_to_dynamic_summary = DynamicSummary.df_to_dynamic_summary - df_to_dynamic_summaries = DynamicSummary.df_to_dynamic_summaries + df_to_dynamic_summary: Callable[ + ..., + DynamicSummary, + ] = DynamicSummary.df_to_dynamic_summary + df_to_dynamic_summaries: Callable[ + ..., + list[DynamicSummary], + ] = DynamicSummary.df_to_dynamic_summaries @classmethod - def new_dynamic_summary(cls, **kwargs): + def new_dynamic_summary( # pylint:disable=too-many-arguments # noqa: PLR0913 + cls: type[Self], + summary_id: str | None = None, + name: str | None = None, + description: str | None = None, + tenant_id: str | None = None, + azure_tenant_id: str | None = None, + search_key: str | None = None, + tactics: str | list[str] | None = None, + techniques: str | list[str] | None = None, + source_info: dict[str, Any] | None = None, + summary_items: ( + pd.DataFrame | Iterable[DynamicSummaryItem] | list[dict[str, Any]] | None + ) = None, + ) -> DynamicSummary: """ Return a new DynamicSummary object. @@ -55,9 +82,20 @@ def new_dynamic_summary(cls, **kwargs): DynamicSummary """ - return DynamicSummary.new_dynamic_summary(**kwargs) + return DynamicSummary.new_dynamic_summary( + summary_id=summary_id, + summary_name=name, + summary_description=description, + tenant_id=tenant_id, + azure_tenant_id=azure_tenant_id, + search_key=search_key, + tactics=tactics, + techniques=techniques, + source_info=source_info, + summary_items=summary_items, + ) - def list_dynamic_summaries(self) -> pd.DataFrame: + def list_dynamic_summaries(self: Self) -> pd.DataFrame: """ Return current list of Dynamic Summaries from a Sentinel workspace. @@ -67,12 +105,16 @@ def list_dynamic_summaries(self) -> pd.DataFrame: The current Dynamic Summary objects. """ - return self._list_items( # type: ignore - item_type="dynamic_summary", api_version=_DYN_SUM_API_VERSION + return self._list_items( + item_type="dynamic_summary", + api_version=_DYN_SUM_API_VERSION, ) def get_dynamic_summary( - self, summary_id: str, summary_items=False + self: Self, + summary_id: str, + *, + summary_items: bool = False, ) -> DynamicSummary: """ Return DynamicSummary for ID. @@ -97,37 +139,41 @@ def get_dynamic_summary( """ if summary_items: - if not self.sent_data_query: # type: ignore + if not self.sent_data_query: try: - self.sent_data_query = SentinelQueryProvider( - self.default_workspace_name # type: ignore[attr-defined] + self.sent_data_query: ( + SentinelQueryProvider | None + ) = SentinelQueryProvider( + self.default_workspace_name, # type: ignore[attr-defined] ) logger.info( "Created sentinel query provider for %s", self.default_workspace_name, # type: ignore[attr-defined] ) except LookupError: - print( - "Unable to find default workspace.", - "Use 'sentinel.set_default_workspace(workspace='my_ws_name'", + logging.info( + "Unable to find default workspace." + "Use 'sentinel.set_default_workspace(workspace='my_ws_name' " "and retry.", ) if self.sent_data_query: logger.info("Query dynamic summary for %s", summary_id) return df_to_dynamic_summary( - self.sent_data_query.get_dynamic_summary(summary_id) + self.sent_data_query.get_dynamic_summary(summary_id), ) - dyn_sum_url = self.sent_urls["dynamic_summary"] + f"/{summary_id}" # type: ignore - + dyn_sum_url = self.sent_urls["dynamic_summary"] + f"/{summary_id}" params = {"api-version": _DYN_SUM_API_VERSION} + if not self._token: + err_msg = "Token not found, can't get dynamic summary." + raise ValueError(err_msg) response = httpx.get( dyn_sum_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, timeout=get_http_timeout(), ) - if response.status_code == 200: + if response.is_success: logger.info("Query API for summary id %s", summary_id) return DynamicSummary.from_json(response.json()) logger.info( @@ -137,14 +183,21 @@ def get_dynamic_summary( ) raise MsticpyAzureConnectionError(response.json()) - def create_dynamic_summary( - self, - summary: Optional[DynamicSummary] = None, - name: Optional[str] = None, - description: Optional[str] = None, - data: Optional[pd.DataFrame] = None, - **kwargs, - ) -> Optional[str]: + def create_dynamic_summary( # pylint:disable=too-many-arguments #noqa: PLR0913 + self: Self, + summary: DynamicSummary | None = None, + name: str | None = None, + description: str | None = None, + data: pd.DataFrame | None = None, + *, + summary_id: str | None = None, + tenant_id: str | None = None, + azure_tenant_id: str | None = None, + search_key: str | None = None, + tactics: str | list[str] | None = None, + techniques: str | list[str] | None = None, + source_info: dict[str, Any] | None = None, + ) -> str | None: """ Create a Dynamic Summary in the Sentinel Workspace. @@ -158,6 +211,20 @@ def create_dynamic_summary( Dynamic Summary description data : pd.DataFrame The summary data + summary_id: str | None + Id of the summary object + tenant_id: str | None + Tenant Id of the Sentinel workspace + azure_tenant_id: str | None + Tenant Id of the Sentinel workspace + search_key : str, optional + Search key for the entire summary, by default None + tactics : Union[str, List[str], None], optional + Relevant MITRE tactics, by default None + techniques : Union[str, List[str], None], optional + Relevant MITRE techniques, by default None + source_info : str, optional + Summary source info, by default None Returns ------- @@ -170,28 +237,40 @@ def create_dynamic_summary( If API returns an error. """ - if summary: + if summary is not None: if not summary.summary_name: + err_msg: str = "DynamicSummary must have unique `summary_name`." raise MsticpyParameterError( - "DynamicSummary must have unique `summary_name`.", + err_msg, parameters="summary_name", ) return self._create_dynamic_summary(summary) # pylint: disable=unexpected-keyword-arg if not name: + err_msg = "DynamicSummary must have unique name" raise MsticpyParameterError( - "DynamicSummary must have unique name", parameters="name" + err_msg, + parameters="name", ) logger.info("create_dynamic_summary %s (%s)", name, description) return self._create_dynamic_summary( - name, description=description, data=data, **kwargs + name, + description=description, + data=data, + summary_id=summary_id, + tenant_id=tenant_id, + azure_tenant_id=azure_tenant_id, + search_key=search_key, + tactics=tactics, + techniques=techniques, + source_info=source_info, ) @singledispatchmethod def _create_dynamic_summary( - self, + self: Self, summary: DynamicSummary, - ) -> Optional[str]: + ) -> str | None: """ Create a Dynamic Summary in the Sentinel Workspace. @@ -211,42 +290,54 @@ def _create_dynamic_summary( If API returns an error. """ - self.check_connected() # type: ignore - dyn_sum_url = "/".join( - [self.sent_urls["dynamic_summary"], summary.summary_id] # type: ignore - ) - - params = {"api-version": _DYN_SUM_API_VERSION} - response = httpx.put( + self.check_connected() + dyn_sum_url = "/".join([self.sent_urls["dynamic_summary"], summary.summary_id]) + + params: dict[str, str] = {"api-version": _DYN_SUM_API_VERSION} + if not self._token: + err_msg: str = "Token not found, can't create dynamic summary." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( dyn_sum_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=summary.to_json_api(), timeout=get_http_timeout(), ) logger.info( - "_create_dynamic_summary (DynamicSummary) status %d", response.status_code + "_create_dynamic_summary (DynamicSummary) status %d", + response.status_code, ) - if response.status_code in (200, 201): - print("Dynamic summary created/updated.") + if response.is_success: + logger.info("Dynamic summary created/updated.") return response.json().get("name") logger.warning( "_create_dynamic_summary (DynamicSummary) failure %s", response.content.decode("utf-8"), ) + err_msg = ( + f"Dynamic summary create/update failed with status {response.status_code}" + ) raise MsticpyAzureConnectionError( - ( - "Dynamic summary create/update failed with status", - str(response.status_code), - ), + err_msg, "Text response:", response.text, ) - @_create_dynamic_summary.register - def _( - self, name: str, description: str, data: pd.DataFrame, **kwargs - ) -> Optional[str]: + @_create_dynamic_summary.register(str) + def _( # pylint:disable=too-many-arguments # noqa: PLR0913 + self: Self, + name: str, + description: str, + data: pd.DataFrame, + summary_id: str | None = None, + tenant_id: str | None = None, + azure_tenant_id: str | None = None, + search_key: str | None = None, + tactics: str | list[str] | None = None, + techniques: str | list[str] | None = None, + source_info: dict[str, Any] | None = None, + ) -> str | None: """ Create a Dynamic Summary in the Sentinel Workspace. @@ -258,13 +349,12 @@ def _( Dynamic Summary description data : pd.DataFrame The summary data - - Other Parameters - ---------------- - relation_name : str, optional - The relation name, by default None - relation_id : str, optional - The relation ID, by default None + summary_id: str | None + Id of the summary object + tenant_id: str | None + Tenant Id of the Sentinel workspace + azure_tenant_id: str | None + Tenant Id of the Sentinel workspace search_key : str, optional Search key for the entire summary, by default None tactics : Union[str, List[str], None], optional @@ -273,9 +363,6 @@ def _( Relevant MITRE techniques, by default None source_info : str, optional Summary source info, by default None - summary_items : Union[pd, DataFrame, Iterable[DynamicSummaryItem], - List[Dict[str, Any]]], optional - Collection of summary items, by default None Returns ------- @@ -288,12 +375,18 @@ def _( If API returns an error. """ - self.check_connected() # type: ignore + self.check_connected() summary = DynamicSummary( summary_name=name, summary_description=description, summary_items=data, - **kwargs, + summary_id=summary_id, + tenant_id=tenant_id, + azure_tenant_id=azure_tenant_id, + search_key=search_key, + tactics=tactics, + techniques=techniques, + source_info=source_info, ) logger.info( "_create_dynamic_summary (DF) rows: %d", @@ -302,9 +395,9 @@ def _( return self.create_dynamic_summary(summary) def delete_dynamic_summary( - self, + self: Self, summary_id: str, - ): + ) -> None: """ Delete the Dynamic Summary for `summary_id`. @@ -319,39 +412,53 @@ def delete_dynamic_summary( If the API returns an error. """ - self.check_connected() # type: ignore + self.check_connected() - dyn_sum_url = f"{self.sent_urls['dynamic_summary']}/{summary_id}" # type: ignore + dyn_sum_url = f"{self.sent_urls['dynamic_summary']}/{summary_id}" params = {"api-version": _DYN_SUM_API_VERSION} + if not self._token: + err_msg: str = "Token not found, can't delete dynamic summary." + raise ValueError(err_msg) response = httpx.delete( dyn_sum_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, timeout=get_http_timeout(), ) logger.info( - "delete_dynamic_summary %s - status %d", summary_id, response.status_code + "delete_dynamic_summary %s - status %d", + summary_id, + response.status_code, ) - if response.status_code == 200: - print("Dynamic summary deleted.") + if response.is_success: + logger.info("Dynamic summary deleted.") return response.json().get("name") logger.warning( "delete_dynamic_summary failure %s", response.content.decode("utf-8"), ) + err_msg = f"Dynamic summary deletion failed with status {response.status_code}" raise MsticpyAzureConnectionError( - f"Dynamic summary deletion failed with status {response.status_code}", + err_msg, "Text response:", response.text, ) - def update_dynamic_summary( - self, - summary: Optional[DynamicSummary] = None, - summary_id: Optional[str] = None, - data: Optional[pd.DataFrame] = None, - **kwargs, - ): + def update_dynamic_summary( # pylint:disable=too-many-arguments # noqa:PLR0913 + self: Self, + summary: DynamicSummary | None = None, + summary_id: str | None = None, + data: pd.DataFrame | None = None, + *, + name: str | None = None, + description: str | None = None, + tenant_id: str | None = None, + azure_tenant_id: str | None = None, + search_key: str | None = None, + tactics: str | list[str] | None = None, + techniques: str | list[str] | None = None, + source_info: dict[str, Any] | None = None, + ) -> str | None: """ Update a dynamic summary in the Sentinel Workspace. @@ -363,9 +470,6 @@ def update_dynamic_summary( The ID of the summary to update. data : pd.DataFrame The summary data - - Other Parameters - ---------------- name : str The name of the dynamic summary to create description : str @@ -383,8 +487,12 @@ def update_dynamic_summary( source_info : str, optional Summary source info, by default None summary_items : Union[pd, DataFrame, Iterable[DynamicSummaryItem], - List[Dict[str, Any]]], optional - Collection of summary items, by default None + List[Dict[str, Any]]], optional + Collection of summary items, by default + tenant_id: str | None + Tenant Id of the Sentinel workspace + azure_tenant_id: str | None + Tenant Id of the Sentinel workspace Returns ------- @@ -402,8 +510,10 @@ def update_dynamic_summary( if (summary and not summary.summary_id) or ( data is not None and not summary_id ): + err_msg: str = "You must supply a summary ID to update" raise MsticpyParameterError( - "You must supply a summary ID to update", parameters="summary_id" + err_msg, + parameters="summary_id", ) logger.info( "update_dynamic_summary summary %s, df %s", @@ -411,7 +521,17 @@ def update_dynamic_summary( data is not None, ) return self.create_dynamic_summary( - summary=summary, data=data, summary_id=summary_id, **kwargs + summary=summary, + data=data, + name=name, + description=description, + summary_id=summary_id, + tenant_id=tenant_id, + azure_tenant_id=azure_tenant_id, + search_key=search_key, + tactics=tactics, + techniques=techniques, + source_info=source_info, ) @@ -424,7 +544,7 @@ class SentinelQueryProvider: | where SummaryStatus == "Active" or SummaryDataType == "SummaryItem" """ - def __init__(self, workspace: str): + def __init__(self: SentinelQueryProvider, workspace: str) -> None: """Initialize Sentinel Provider.""" workspaces = get_config("AzureSentinel.Workspaces", {}) self.workspace_config = "" @@ -439,17 +559,22 @@ def __init__(self, workspace: str): logger.info("Found workspace config %s", ws_name) break else: - raise LookupError(f"Cannot find workspace configuration for {workspace}") + err_msg: str = f"Cannot find workspace configuration for {workspace}" + raise LookupError(err_msg) self.qry_prov = QueryProvider("MSSentinel") self.qry_prov.connect(workspace=self.workspace_alias) - def get_dynamic_summary(self, summary_id) -> pd.DataFrame: + def get_dynamic_summary(self: Self, summary_id: str) -> pd.DataFrame: """Retrieve dynamic summary from MS Sentinel table.""" logger.info("Dynamic summary query for %s", summary_id) return self.qry_prov.MSSentinel.get_dynamic_summary_by_id(summary_id=summary_id) - def get_dynamic_summaries(self, start: datetime, end: datetime) -> pd.DataFrame: + def get_dynamic_summaries( + self: Self, + start: datetime, + end: datetime, + ) -> pd.DataFrame: """Return dynamic summaries for date range.""" logger.info( "Dynamic summary query for dynamic summaries from %s to %s", diff --git a/msticpy/context/azure/sentinel_dynamic_summary_types.py b/msticpy/context/azure/sentinel_dynamic_summary_types.py index c69ba6594..2d19ee65d 100644 --- a/msticpy/context/azure/sentinel_dynamic_summary_types.py +++ b/msticpy/context/azure/sentinel_dynamic_summary_types.py @@ -4,16 +4,19 @@ # license information. # -------------------------------------------------------------------------- """Sentinel Dynamic Summary classes.""" +from __future__ import annotations + import dataclasses import json import logging import uuid from datetime import datetime from functools import singledispatchmethod -from typing import Any, Callable, ClassVar, Dict, Iterable, List, Optional, Union, cast +from typing import Any, Callable, ClassVar, Hashable, Iterable import numpy as np import pandas as pd +from typing_extensions import Self from ..._version import VERSION from ...common.exceptions import MsticpyUserError @@ -21,7 +24,7 @@ __version__ = VERSION __author__ = "Ian Hellen" -_TACTICS = ( +_TACTICS: tuple[str, ...] = ( "Reconnaissance", "ResourceDevelopment", "InitialAccess", @@ -37,9 +40,9 @@ "CommandAndControl", "Impact", ) -_TACTICS_DICT = {tactic.casefold(): tactic for tactic in _TACTICS} +_TACTICS_DICT: dict[str, str] = {tactic.casefold(): tactic for tactic in _TACTICS} -_CLS_TO_API_MAP = { +_CLS_TO_API_MAP: dict[str, str] = { "summary_id": "summaryId", "summary_name": "summaryName", "azure_tenant_id": "azureTenantId", @@ -58,27 +61,28 @@ "summary_items": "rawContent", "summary_item_id": "summaryItemId", } -_API_TO_CLS_MAP = {val: key for key, val in _CLS_TO_API_MAP.items()} +_API_TO_CLS_MAP: dict[str, str] = {val: key for key, val in _CLS_TO_API_MAP.items()} -logger = logging.getLogger(__name__) +logger: logging.Logger = logging.getLogger(__name__) class FieldList: """Class to hold field names.""" - def __init__(self, fieldnames: Iterable[str]): + def __init__(self: FieldList, fieldnames: Iterable[str]) -> None: """Add fields to field mapping.""" self.__dict__.update({field.upper(): field for field in fieldnames}) - def __repr__(self): + def __repr__(self: Self) -> str: """Return list of field attributes and values.""" - field_names = "\n ".join(f"{key}='{val}'" for key, val in vars(self).items()) + field_names: str = "\n ".join( + f"{key}='{val}'" for key, val in vars(self).items() + ) return f"Fields:\n {field_names}" -# pylint: disable=too-many-instance-attributes @dataclasses.dataclass -class DynamicSummaryItem: +class DynamicSummaryItem: # pylint:disable=too-many-instance-attributes """ DynamicSummaryItem class. @@ -92,9 +96,9 @@ class DynamicSummaryItem: The ID of the summary item relation search_key: Optional[str] = None Searchable key value for summary item - tactics: Union[str, List[str], None] = None + tactics: Union[str, list[str], None] = None Relevant MITRE tactics for the summary item - techniques: Union[str, List[str], None] = None + techniques: Union[str, list[str], None] = None Relevant MITRE techniques for the summary item event_time_utc: Optional[datetime] = None Event time for the summary item @@ -107,23 +111,19 @@ class DynamicSummaryItem: """ - fields: ClassVar - summary_item_id: Optional[str] = None - relation_name: Optional[str] = None - relation_id: Optional[str] = None - search_key: Optional[str] = None - tactics: Union[str, List[str], None] = dataclasses.field( # type: ignore - default_factory=list - ) - techniques: Union[str, List[str], None] = dataclasses.field( # type: ignore - default_factory=list - ) - event_time_utc: Optional[datetime] = None - observable_type: Optional[str] = None - observable_value: Optional[str] = None - packed_content: Dict[str, Any] = dataclasses.field(default_factory=dict) - - def __post_init__(self): + fields: ClassVar[FieldList] + summary_item_id: str | None = None + relation_name: str | None = None + relation_id: str | None = None + search_key: str | None = None + tactics: list[str] | str = dataclasses.field(default_factory=list) + techniques: str | list[str] = dataclasses.field(default_factory=list) + event_time_utc: datetime | None = None + observable_type: str | None = None + observable_value: str | None = None + packed_content: dict[Hashable, Any] = dataclasses.field(default_factory=dict) + + def __post_init__(self: Self) -> None: """Initialize item ID if was not set explicitly.""" self.summary_item_id = self.summary_item_id or str(uuid.uuid4()) if isinstance(self.tactics, str): @@ -132,7 +132,7 @@ def __post_init__(self): if isinstance(self.techniques, str): self.techniques = [self.techniques] - def to_api_dict(self): + def to_api_dict(self: Self) -> dict[str, Any]: """Return attributes as a JSON-serializable dictionary.""" return { _CLS_TO_API_MAP.get(name, name): _convert_data_types(value) @@ -143,7 +143,7 @@ def to_api_dict(self): # Add helper class attribute for field names. DynamicSummaryItem.fields = FieldList( - [field.name for field in dataclasses.fields(DynamicSummaryItem)] + [field.name for field in dataclasses.fields(DynamicSummaryItem)], ) @@ -151,13 +151,36 @@ class DynamicSummary: """Dynamic Summary class.""" fields = FieldList( - ["summary_id", "summary_name", "summary_description"] - + ["tenant_id", "relation_name", "relation_id"] # noqa: W503 - + ["search_key", "tactics", "techniques", "source_info"] # noqa: W503 - + ["summary_items"] # noqa: W503 + [ + "summary_id", + "summary_name", + "summary_description", + "tenant_id", + "relation_name", + "relation_id", + "search_key", + "tactics", + "techniques", + "source_info", + "summary_items", + ], ) - def __init__(self, summary_id: Optional[str] = None, **kwargs): + def __init__( # pylint:disable=too-many-arguments #noqa:PLR0913 + self: DynamicSummary, + summary_id: str | None = None, + summary_name: str | None = None, + summary_description: str | None = None, + tenant_id: str | None = None, + azure_tenant_id: str | None = None, + search_key: str | None = None, + tactics: str | list[str] | None = None, + techniques: str | list[str] | None = None, + source_info: dict[str, Any] | None = None, + summary_items: ( + pd.DataFrame | Iterable[DynamicSummaryItem] | list[dict[str, Any]] | None + ) = None, + ) -> None: """ Initialize a DynamicSummary instance. @@ -171,58 +194,54 @@ def __init__(self, summary_id: Optional[str] = None, **kwargs): Summary description, by default None tenant_id : str, optional Azure tenant ID, by default None - relation_name : str, optional - The relation name, by default None - relation_id : str, optional - The relation ID, by default None + azure_tenant_id : str, optional + Azure tenant ID, by default None search_key : str, optional Search key column for the summarized data, by default None - tactics : Union[str, List[str], None], optional + tactics : Union[str, list[str], None], optional Relevant MITRE tactics, by default None - techniques : Union[str, List[str], None], optional + techniques : Union[str, list[str], None], optional Relevant MITRE techniques, by default None source_info : Dict[str, Any], optional Summary source info dictionary, by default None - summary_items : Union[pd, DataFrame, Iterable[DynamicSummaryItem], - List[Dict[str, Any]]], optional + summary_items : Union[pd, DataFrame, Iterable[DynamicSummaryItem] + list of summary items + list[Dict[str, Any]]], optional Collection of summary items, by default None """ self.summary_id: str = summary_id or str(uuid.uuid4()) - self.summary_name: str = kwargs.pop("summary_name", None) - self.summary_description: str = kwargs.pop("summary_description", None) - self.tenant_id: str = kwargs.pop( - "azure_tenant_id", kwargs.pop("tenant_id", None) + self.summary_name: str | None = summary_name + self.summary_description: str | None = summary_description + self.tenant_id: str | None = azure_tenant_id or tenant_id + + self.search_key: str | None = search_key + tactics = tactics or [] + self.tactics: list[str] = _match_tactics( + [tactics] if isinstance(tactics, str) else tactics, ) - - self.search_key = kwargs.pop("search_key", None) - tactics = kwargs.pop("tactics", []) - self.tactics = _match_tactics( - [tactics] if isinstance(tactics, str) else tactics + techniques = techniques or [] + self.techniques: list[str] = ( + [techniques] if isinstance(techniques, str) else techniques ) - techniques = kwargs.pop("techniques", []) - self.techniques = [techniques] if isinstance(techniques, str) else techniques - self.summary_items: List[DynamicSummaryItem] = [] - summary_items = kwargs.pop("summary_items", None) + self.summary_items: list[DynamicSummaryItem] = [] if summary_items is not None: self.add_summary_items(summary_items) - source_info = kwargs.pop("source_info", {}) - self.source_info = ( + self.source_info: dict[str, Any] = ( source_info if isinstance(source_info, dict) else {"user_source": source_info} ) self.source_info["source_pkg"] = f"MSTICPy {VERSION}" - # Add other kwargs as instance attributes - self.__dict__.update(kwargs) logger.info( - "Dynamic summary created %s", summary_id or f"auto({self.summary_id})" + "Dynamic summary created %s", + summary_id or f"auto({self.summary_id})", ) - def __repr__(self) -> str: + def __repr__(self: Self) -> str: """Return simple representation of instance.""" - attributes = { + attributes: dict[str, str | Any] = { key: f"'{val}'" if isinstance(val, str) else val for key, val in vars(self).items() if key != "summary_items" and val not in (None, pd.NaT, "", []) @@ -233,38 +252,38 @@ def __repr__(self) -> str: *(f" {key}={val}" for key, val in attributes.items()), f" summary_items={len(self.summary_items)}", ")", - ] + ], ) @classmethod - def from_json(cls, data: Union[Dict[str, Any], str]) -> "DynamicSummary": + def from_json( + cls: type[Self], + data: dict[str, Any] | str, + ) -> Self: """Create new DynamicSummary instance from json string or dict.""" if isinstance(data, str): try: data = json.loads(data) except json.JSONDecodeError as json_err: - raise MsticpyUserError( - "JSON Error decoding dynamic summary data" - ) from json_err - data = cast(Dict[str, Any], data) - if "properties" in data: - data = data["properties"] - data = cast(Dict[str, Any], data) - summary_props = { + err_msg: str = "JSON Error decoding dynamic summary data" + raise MsticpyUserError(err_msg) from json_err + return cls.from_json(data) + properties: dict[str, Any] = data.get("properties", data) + summary_props: dict[str, Any] = { _API_TO_CLS_MAP.get(name, name): value - for name, value in data.items() + for name, value in properties.items() if name != "rawContent" } summary = cls(**summary_props) - summary_items: List[DynamicSummaryItem] = [] + summary_items: list[DynamicSummaryItem] = [] try: - raw_content = json.loads(data.get("rawContent", "[]")) + raw_content_data: str = data.get("rawContent", "[]") + raw_content: list[dict[str, Any]] = json.loads(raw_content_data) except json.JSONDecodeError as json_err: - raise MsticpyUserError( - "JSON Error decoding dynamic summary item data" - ) from json_err + err_msg = "JSON Error decoding dynamic summary item data" + raise MsticpyUserError(err_msg) from json_err for raw_item in raw_content: - summary_item_props = { + summary_item_props: dict[str, Any] = { _API_TO_CLS_MAP.get(name, name): ( pd.to_datetime(value) if name == "eventTimeUTC" else value ) @@ -275,7 +294,21 @@ def from_json(cls, data: Union[Dict[str, Any], str]) -> "DynamicSummary": return summary @classmethod - def new_dynamic_summary(cls, **kwargs): + def new_dynamic_summary( # pylint:disable=too-many-arguments # noqa: PLR0913 + cls: type[Self], + summary_id: str | None = None, + summary_name: str | None = None, + summary_description: str | None = None, + tenant_id: str | None = None, + azure_tenant_id: str | None = None, + search_key: str | None = None, + tactics: str | list[str] | None = None, + techniques: str | list[str] | None = None, + source_info: dict[str, Any] | None = None, + summary_items: ( + pd.DataFrame | Iterable[DynamicSummaryItem] | list[dict[str, Any]] | None + ) = None, + ) -> Self: """ Return a new DynamicSummary object. @@ -289,10 +322,21 @@ def new_dynamic_summary(cls, **kwargs): DynamicSummary """ - return cls(**kwargs) + return cls( + summary_id=summary_id, + summary_name=summary_name, + summary_description=summary_description, + tenant_id=tenant_id, + azure_tenant_id=azure_tenant_id, + search_key=search_key, + tactics=tactics, + techniques=techniques, + source_info=source_info, + summary_items=summary_items, + ) @staticmethod - def df_to_dynamic_summaries(data: pd.DataFrame) -> List["DynamicSummary"]: + def df_to_dynamic_summaries(data: pd.DataFrame) -> list[DynamicSummary]: r""" Return a list of DynamicSummary objects from a DataFrame of summaries. @@ -303,7 +347,7 @@ def df_to_dynamic_summaries(data: pd.DataFrame) -> List["DynamicSummary"]: Returns ------- - List[DynamicSummary] + list[DynamicSummary] List of Dynamic Summary objects. Examples @@ -327,7 +371,7 @@ def df_to_dynamic_summaries(data: pd.DataFrame) -> List["DynamicSummary"]: ] @staticmethod - def df_to_dynamic_summary(data: pd.DataFrame) -> "DynamicSummary": + def df_to_dynamic_summary(data: pd.DataFrame) -> DynamicSummary: r""" Return a single DynamicSummary object from a DataFrame. @@ -361,12 +405,10 @@ def df_to_dynamic_summary(data: pd.DataFrame) -> "DynamicSummary": return df_to_dynamic_summary(data) def add_summary_items( - self, - data: Union[ - Iterable[DynamicSummaryItem], Iterable[Dict[str, Any]], pd.DataFrame - ], + self: Self, + data: Iterable[DynamicSummaryItem] | Iterable[dict[str, Any]] | pd.DataFrame, **kwargs, - ): + ) -> None: """ Add list of DynamicSummaryItems replacing existing list. @@ -394,7 +436,7 @@ def add_summary_items( self._add_summary_items(data, **kwargs) @singledispatchmethod - def _add_summary_items(self, data: list, **kwargs): + def _add_summary_items(self: Self, data: list, **kwargs) -> None: """ Add list of DynamicSummaryItems. @@ -414,12 +456,16 @@ def _add_summary_items(self, data: list, **kwargs): else: self._add_summary_items_dict(data) - @_add_summary_items.register + @_add_summary_items.register(pd.DataFrame) def _( - self, + self: Self, data: pd.DataFrame, + *, + summary_fields: dict[str, str] | None = None, + event_time_utc: str | None = None, + search_key: str | None = None, **kwargs, - ): + ) -> None: """ Add DataFrame of dynamic summary items. @@ -432,16 +478,19 @@ def _( and use as SummaryItem properties, by default None. For example: {"col_a": "tactics", "col_b": "relation_name"} See DynamicSummaryItem for a list of available properties. + event_time_utc: Optional[datetime] = None + Event time for the summary item + search_key: Optional[str] = None + Searchable key value for summary item See Also -------- DynamicSummaryItem """ - summary_fields = kwargs.pop("summary_fields", None) logger.info("_add_summary_items (df) rows %d", len(data)) for row in data.to_dict(orient="records"): - summary_params = {} + summary_params: dict[str, Any] = {} if summary_fields: # if summary fields to map to dynamic summary item properties # extract these from the row dictionary first @@ -452,25 +501,26 @@ def _( # if event time not in summary_fields, try to get from # kwargs or from data if "event_time_utc" not in summary_params: - summary_params["event_time_utc"] = kwargs.pop( - "event_time_utc", row.get("TimeGenerated") + summary_params["event_time_utc"] = event_time_utc or row.get( + "TimeGenerated", ) - search_key_value = row.get(self.search_key) if self.search_key else None - if search_key_value and "search_key" not in kwargs: - kwargs["search_key"] = search_key_value + search_key_value: str | None = ( + row.get(self.search_key) if self.search_key else None + ) + if search_key_value and not search_key: + search_key = search_key_value # Create DynamicSummaryItem instance for each row self.summary_items.append( DynamicSummaryItem( packed_content={ - key: _convert_data_types(value) # type: ignore - for key, value in row.items() # type: ignore + key: _convert_data_types(value) for key, value in row.items() }, - **summary_params, + **{**summary_params, "search_key": search_key}, **kwargs, # pass remaining kwargs as summary item properties - ) + ), ) - def _add_summary_items_dict(self, data: Iterable[Dict[str, Any]]): + def _add_summary_items_dict(self: Self, data: Iterable[dict[str, Any]]) -> None: """ Add DynamicSummary items from an iterable of dicts. @@ -482,9 +532,10 @@ def _add_summary_items_dict(self, data: Iterable[Dict[str, Any]]): """ logger.info( - "_add_summary_items (list(dict)) rows %d", len(list(data)) if data else 0 + "_add_summary_items (list(dict)) rows %d", + len(list(data)) if data else 0, ) - summary_items = [] + summary_items: list[DynamicSummaryItem] = [] for properties in data: # if search key specified, try to extract from packed_content field if ( @@ -492,8 +543,8 @@ def _add_summary_items_dict(self, data: Iterable[Dict[str, Any]]): and "search_key" not in properties and self.search_key in properties.get("packed_content", {}) ): - search_key_value = properties.get("packed_content", {}).get( - self.search_key + search_key_value: str = properties.get("packed_content", {}).get( + self.search_key, ) if search_key_value: properties["search_key"] = search_key_value @@ -501,12 +552,10 @@ def _add_summary_items_dict(self, data: Iterable[Dict[str, Any]]): self.summary_items = summary_items def append_summary_items( - self, - data: Union[ - Iterable[DynamicSummaryItem], Iterable[Dict[str, Any]], pd.DataFrame - ], + self: Self, + data: Iterable[DynamicSummaryItem] | Iterable[dict[str, Any]] | pd.DataFrame, **kwargs, - ): + ) -> None: """ Append list of DynamicSummaryItems to existing list. @@ -526,30 +575,30 @@ def append_summary_items( DynamicSummaryItem """ - current_items = self.summary_items + current_items: list[DynamicSummaryItem] = self.summary_items self.add_summary_items(data, **kwargs) - new_items = self.summary_items + new_items: list[DynamicSummaryItem] = self.summary_items self.summary_items = current_items + new_items logger.info("append_summary_items %s", type(data)) - def to_json(self): + def to_json(self: Self) -> str: """Return JSON representation of DynamicSummary.""" - summary_properties = { + summary_properties: dict[str, Any] = { _CLS_TO_API_MAP.get(prop_name, prop_name): prop_value for prop_name, prop_value in self.__dict__.items() if prop_name in _CLS_TO_API_MAP and prop_value is not None } if self.summary_items: summary_properties[_CLS_TO_API_MAP["summary_items"]] = json.dumps( - [item.to_api_dict() for item in self.summary_items] + [item.to_api_dict() for item in self.summary_items], ) return json.dumps(summary_properties) - def to_json_api(self): + def to_json_api(self: Self) -> str: """Return API-ready JSON representation of DynamicSummary.""" return f'{{"properties" : {self.to_json()} }}' - def to_df(self) -> pd.DataFrame: + def to_df(self: Self) -> pd.DataFrame: """Return summary items as DataFrame.""" data = pd.DataFrame([item.packed_content for item in self.summary_items]) if "TimeGenerated" in data.columns: @@ -561,7 +610,7 @@ def to_df(self) -> pd.DataFrame: return data -_DF_TO_CLS_MAP = { +_DF_TO_CLS_MAP: dict[str, str] = { "TenantId": "ws_tenant_id", "TimeGenerated": "time_generated", "AzureTenantId": "tenant_id", @@ -591,8 +640,8 @@ def to_df(self) -> pd.DataFrame: "SourceSystem": "source_system", "Type": "type", } -_CLS_TO_DF_MAP = {val: key for key, val in _DF_TO_CLS_MAP.items()} -_DF_SUMMARY_FIELDS = { +_CLS_TO_DF_MAP: dict[str, str] = {val: key for key, val in _DF_TO_CLS_MAP.items()} +_DF_SUMMARY_FIELDS: set[str] = { "TenantId", "TimeGenerated", "AzureTenantId", @@ -615,7 +664,7 @@ def to_df(self) -> pd.DataFrame: "QueryEndDate", "SummaryDataType", } -_DF_SUMMARY_ITEM_FIELDS = { +_DF_SUMMARY_ITEM_FIELDS: set[str] = { "TimeGenerated", "SummaryItemId", "RelationName", @@ -638,7 +687,7 @@ def to_df(self) -> pd.DataFrame: def _get_summary_record(data: pd.DataFrame) -> pd.Series: """Return active dynamic summary header record.""" - ds_summary = data[ + ds_summary: pd.DataFrame = data[ (data["SummaryDataType"] == "Summary") & (data["SummaryStatus"] == "Active") ] return ds_summary[list(_DF_SUMMARY_FIELDS)].rename(columns=_DF_TO_CLS_MAP).iloc[0] @@ -646,13 +695,13 @@ def _get_summary_record(data: pd.DataFrame) -> pd.Series: def _get_summary_items(data: pd.DataFrame) -> pd.DataFrame: """Return summary item records for dynamic summary.""" - ds_summary_items = data[data["SummaryDataType"] == "SummaryItem"] + ds_summary_items: pd.DataFrame = data[data["SummaryDataType"] == "SummaryItem"] return ds_summary_items[list(_DF_SUMMARY_ITEM_FIELDS)].rename( - columns=_DF_TO_CLS_MAP + columns=_DF_TO_CLS_MAP, ) -def df_to_dynamic_summaries(data: pd.DataFrame) -> List[DynamicSummary]: +def df_to_dynamic_summaries(data: pd.DataFrame) -> list[DynamicSummary]: r""" Return a list of DynamicSummary objects from a DataFrame of summaries. @@ -663,7 +712,7 @@ def df_to_dynamic_summaries(data: pd.DataFrame) -> List[DynamicSummary]: Returns ------- - List[DynamicSummary] + list[DynamicSummary] List of Dynamic Summary objects. Examples @@ -716,35 +765,37 @@ def df_to_dynamic_summary(data: pd.DataFrame) -> DynamicSummary: dyn_summaries = df_to_dynamic_summary(data) """ - dyn_summary = DynamicSummary() - dyn_summary.__dict__.update(_get_summary_record(data).to_dict()) # type: ignore + dyn_summary: DynamicSummary = DynamicSummary() + dyn_summary.__dict__.update(_get_summary_record(data).to_dict()) - items_list = _get_summary_items(data).to_dict(orient="records") - items = [] + items_list: list[dict[Hashable, Any]] = _get_summary_items(data).to_dict( + orient="records", + ) + items: list[DynamicSummaryItem] = [] for item in items_list: - # pylint: disable=no-value-for-parameter # "fields" attrib is a ClassVar - ds_item = DynamicSummaryItem() - ds_item.__dict__.update(item) # type: ignore + ds_item: DynamicSummaryItem = DynamicSummaryItem() + for key, value in item.items(): + setattr(ds_item, str(key), value) items.append(ds_item) dyn_summary.add_summary_items(items) return dyn_summary -def _to_datetime_utc_str(date_time): +def _to_datetime_utc_str(date_time: datetime | str) -> str: """Convert datetime to ISO date string.""" if not isinstance(date_time, datetime): return date_time - dt_str = date_time.isoformat() + dt_str: str = date_time.isoformat() return dt_str.replace("+00:00", "Z") if "+00:00" in dt_str else f"{dt_str}Z" -def _convert_dict_types(input_dict: Dict[Any, Any]) -> Dict[Any, Any]: +def _convert_dict_types(input_dict: dict[Any, Any]) -> dict[Any, Any]: """Convert data types in dictionary members.""" return {name: _convert_data_types(value) for name, value in input_dict.items()} -_TYPE_CONVERTER = { +_TYPE_CONVERTER: dict[Any, Callable] = { np.ndarray: list, datetime: _to_datetime_utc_str, pd.Timestamp: _to_datetime_utc_str, @@ -752,15 +803,18 @@ def _convert_dict_types(input_dict: Dict[Any, Any]) -> Dict[Any, Any]: } -def _convert_data_types(value: Any, type_convert: Dict[type, Callable] = None) -> Any: +def _convert_data_types( + value: str, + type_convert: dict[type, Callable] | None = None, +) -> str: """Convert a type based on dictionary of converters.""" type_convert = type_convert or {} type_convert.update(_TYPE_CONVERTER) - converter = type_convert.get(type(value)) + converter: Callable | None = type_convert.get(type(value)) return converter(value) if converter else value -def _match_tactics(tactics: Iterable[str]) -> List[str]: +def _match_tactics(tactics: Iterable[str]) -> list[str]: """Return case-insensitive matches for tactics list.""" return [ _TACTICS_DICT[tactic.casefold()] diff --git a/msticpy/context/azure/sentinel_incidents.py b/msticpy/context/azure/sentinel_incidents.py index cba00ce1c..03b6609e8 100644 --- a/msticpy/context/azure/sentinel_incidents.py +++ b/msticpy/context/azure/sentinel_incidents.py @@ -4,14 +4,19 @@ # license information. # -------------------------------------------------------------------------- """Mixin Classes for Sentinel Incident Features.""" -from datetime import datetime -from typing import Dict, List, Optional, Union +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Callable from uuid import UUID, uuid4 import httpx import pandas as pd from azure.common.exceptions import CloudError from IPython.display import display +from typing_extensions import Self + +from msticpy.context.azure.sentinel_bookmarks import SentinelBookmarksMixin from ..._version import VERSION from ...common.exceptions import MsticpyUserError @@ -22,16 +27,22 @@ get_http_timeout, ) +if TYPE_CHECKING: + from datetime import datetime + __version__ = VERSION __author__ = "Pete Bryan" +logger: logging.Logger = logging.getLogger(__name__) -class SentinelIncidentsMixin: + +class SentinelIncidentsMixin(SentinelBookmarksMixin): """Mixin class for Sentinel Incidents feature integrations.""" - def get_incident( - self, + def get_incident( # noqa:PLR0913 + self: Self, incident: str, + *, entities: bool = False, alerts: bool = False, comments: bool = False, @@ -64,13 +75,15 @@ def get_incident( If incident could not be retrieved. """ - incident_id = self._get_incident_id(incident) - incident_url = self.sent_urls["incidents"] + f"/{incident_id}" # type: ignore - response = self._get_items(incident_url) # type: ignore - if response.status_code != 200: + incident_id: str = self._get_incident_id(incident) + incident_url: str = self.sent_urls["incidents"] + f"/{incident_id}" + response: httpx.Response = super(SentinelBookmarksMixin, self)._get_items( + incident_url, + ) + if not response.is_success: raise CloudError(response=response) - incident_df = _azs_api_result_to_df(response) + incident_df: pd.DataFrame = _azs_api_result_to_df(response) if entities: incident_df["Entities"] = [self.get_entities(incident_id)] @@ -86,7 +99,7 @@ def get_incident( return incident_df - def get_entities(self, incident: str) -> list: + def get_entities(self: Self, incident: str) -> list: """ Get the entities from an incident. @@ -101,23 +114,26 @@ def get_entities(self, incident: str) -> list: A list of entities. """ - self.check_connected() # type: ignore - incident_id = self._get_incident_id(incident) - entities_url = self.sent_urls["incidents"] + f"/{incident_id}/entities" # type: ignore - ent_parameters = {"api-version": "2021-04-01"} - ents = httpx.post( + self.check_connected() + incident_id: str = self._get_incident_id(incident) + entities_url: str = self.sent_urls["incidents"] + f"/{incident_id}/entities" + ent_parameters: dict[str, str] = {"api-version": "2021-04-01"} + if not self._token: + err_msg = "Token not found, can't get entities." + raise ValueError(err_msg) + ents: httpx.Response = httpx.post( entities_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=ent_parameters, timeout=get_http_timeout(), ) return ( [(ent["kind"], ent["properties"]) for ent in ents.json()["entities"]] - if ents.status_code == 200 + if ents.is_success else [] ) - def get_incident_alerts(self, incident: str) -> list: + def get_incident_alerts(self: Self, incident: str) -> list: """ Get the alerts from an incident. @@ -132,13 +148,16 @@ def get_incident_alerts(self, incident: str) -> list: A list of alerts. """ - self.check_connected() # type: ignore - incident_id = self._get_incident_id(incident) - alerts_url = self.sent_urls["incidents"] + f"/{incident_id}/alerts" # type: ignore - alerts_parameters = {"api-version": "2021-04-01"} - alerts_resp = httpx.post( + self.check_connected() + incident_id: str = self._get_incident_id(incident) + alerts_url: str = self.sent_urls["incidents"] + f"/{incident_id}/alerts" + alerts_parameters: dict[str, str] = {"api-version": "2021-04-01"} + if not self._token: + err_msg = "Token not found, can't get incident alerts." + raise ValueError(err_msg) + alerts_resp: httpx.Response = httpx.post( alerts_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=alerts_parameters, timeout=get_http_timeout(), ) @@ -151,11 +170,11 @@ def get_incident_alerts(self, incident: str) -> list: } for alert in alerts_resp.json()["value"] ] - if alerts_resp.status_code == 200 + if alerts_resp.is_success else [] ) - def get_incident_comments(self, incident: str) -> list: + def get_incident_comments(self: Self, incident: str) -> list: """ Get the comments from an incident. @@ -170,10 +189,13 @@ def get_incident_comments(self, incident: str) -> list: A list of comments. """ - incident_id = self._get_incident_id(incident) - comments_url = self.sent_urls["incidents"] + f"/{incident_id}/comments" # type: ignore - comments_response = self._get_items(comments_url, "2021-04-01") # type: ignore - comment_details = comments_response.json() + incident_id: str = self._get_incident_id(incident) + comments_url: str = self.sent_urls["incidents"] + f"/{incident_id}/comments" + comments_response: httpx.Response = self._get_items( + comments_url, + {"api-version": "2020-04-01"}, + ) + comment_details: dict[str, Any] = comments_response.json() return ( [ { @@ -182,11 +204,11 @@ def get_incident_comments(self, incident: str) -> list: } for comment in comment_details["value"] ] - if comments_response.status_code == 200 + if comments_response.is_success else [] ) - def get_incident_bookmarks(self, incident: str) -> list: + def get_incident_bookmarks(self: Self, incident: str) -> list: """ Get the comments from an incident. @@ -201,33 +223,38 @@ def get_incident_bookmarks(self, incident: str) -> list: A list of bookmarks. """ - bookmarks_list = [] - incident_id = self._get_incident_id(incident) - relations_url = self.sent_urls["incidents"] + f"/{incident_id}/relations" # type: ignore - relations_response = self._get_items(relations_url, "2021-04-01") # type: ignore - if relations_response.status_code == 200 and relations_response.json()["value"]: + bookmarks_list: list[dict[str, Any]] = [] + incident_id: str = self._get_incident_id(incident) + relations_url: str = self.sent_urls["incidents"] + f"/{incident_id}/relations" + relations_response: httpx.Response = self._get_items( + relations_url, + {"api-version": "2020-04-01"}, + ) + if relations_response.is_success and relations_response.json()["value"]: for relationship in relations_response.json()["value"]: if ( relationship["properties"]["relatedResourceType"] == "Microsoft.SecurityInsights/Bookmarks" ): - bkmark_id = relationship["properties"]["relatedResourceName"] - bookmarks_df = self.list_bookmarks() # type: ignore - bookmark = bookmarks_df[bookmarks_df["name"] == bkmark_id].iloc[0] + bkmark_id: str = relationship["properties"]["relatedResourceName"] + bookmarks_df: pd.DataFrame = self.list_bookmarks() + bookmark: pd.Series = bookmarks_df[ + bookmarks_df["name"] == bkmark_id + ].iloc[0] bookmarks_list.append( { "Bookmark ID": bkmark_id, "Bookmark Title": bookmark["properties.displayName"], - } + }, ) return bookmarks_list def update_incident( - self, + self: Self, incident_id: str, update_items: dict, - ): + ) -> str: """ Update properties of an incident. @@ -246,40 +273,45 @@ def update_incident( If incident could not be updated. """ - self.check_connected() # type: ignore - incident_dets = self.get_incident(incident_id) - incident_url = self.sent_urls["incidents"] + f"/{incident_id}" # type: ignore - params = {"api-version": "2020-01-01"} - if "title" not in update_items.keys(): + self.check_connected() + incident_dets: pd.DataFrame = self.get_incident(incident_id) + incident_url: str = self.sent_urls["incidents"] + f"/{incident_id}" + params: dict[str, str] = {"api-version": "2020-01-01"} + if "title" not in update_items: update_items["title"] = incident_dets.iloc[0]["properties.title"] - if "status" not in update_items.keys(): + if "status" not in update_items: update_items["status"] = incident_dets.iloc[0]["properties.status"] - data = extract_sentinel_response( - update_items, props=True, etag=incident_dets.iloc[0]["etag"] + data: dict[str, Any] = extract_sentinel_response( + update_items, + props=True, + etag=incident_dets.iloc[0]["etag"], ) - response = httpx.put( + if not self._token: + err_msg = "Token not found, can't update incident." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( incident_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data), timeout=get_http_timeout(), ) if response.status_code not in (200, 201): raise CloudError(response=response) - print("Incident updated.") + logger.info("Incident updated.") return response.json().get("name") - def create_incident( # pylint: disable=too-many-arguments, too-many-locals - self, + def create_incident( # pylint: disable=too-many-arguments, too-many-locals #noqa:PLR0913 + self: Self, title: str, severity: str, status: str = "New", - description: Optional[str] = None, - first_activity_time: Optional[datetime] = None, - last_activity_time: Optional[datetime] = None, - labels: Optional[List] = None, - bookmarks: Optional[List] = None, - ) -> Optional[str]: + description: str | None = None, + first_activity_time: datetime | None = None, + last_activity_time: datetime | None = None, + labels: list[dict[str, Any]] | None = None, + bookmarks: list[str] | None = None, + ) -> str | None: """ Create a Sentinel Incident. @@ -315,11 +347,11 @@ def create_incident( # pylint: disable=too-many-arguments, too-many-locals If the API returns an error """ - self.check_connected() # type: ignore - incident_id = uuid4() - incident_url = self.sent_urls["incidents"] + f"/{incident_id}" # type: ignore - params = {"api-version": "2020-01-01"} - data_items: Dict[str, Union[str, List]] = { + self.check_connected() + incident_id: UUID = uuid4() + incident_url: str = self.sent_urls["incidents"] + f"/{incident_id}" + params: dict[str, str] = {"api-version": "2020-01-01"} + data_items: dict[str, str | list] = { "title": title, "severity": severity.capitalize(), "status": status.capitalize(), @@ -333,36 +365,42 @@ def create_incident( # pylint: disable=too-many-arguments, too-many-locals data_items["firstActivityTimeUtc"] = first_activity_time.isoformat() if last_activity_time: data_items["lastActivityTimeUtc"] = last_activity_time.isoformat() - data = extract_sentinel_response(data_items, props=True) - response = httpx.put( + data: dict[str, Any] = extract_sentinel_response(data_items, props=True) + if not self._token: + err_msg: str = "Token not found, can't create incident." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( incident_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data), timeout=get_http_timeout(), ) - if response.status_code not in (200, 201): + if not response.is_success: raise CloudError(response=response) if bookmarks: for mark in bookmarks: - relation_id = uuid4() - bookmark_id = self._get_bookmark_id(mark) # type: ignore - mark_res_id = self.sent_urls["bookmarks"] + f"/{bookmark_id}" # type: ignore - relations_url = incident_url + f"/relations/{relation_id}" - bkmark_data_items = {"relatedResourceId": mark_res_id} + relation_id: UUID = uuid4() + bookmark_id: str = self._get_bookmark_id(mark) + mark_res_id: str = self.sent_urls["bookmarks"] + f"/{bookmark_id}" + relations_url: str = incident_url + f"/relations/{relation_id}" + bkmark_data_items: dict[str, Any] = {"relatedResourceId": mark_res_id} data = extract_sentinel_response(bkmark_data_items, props=True) params = {"api-version": "2021-04-01"} + if not self._token: + err_msg = "Token not found, can't create relations." + raise ValueError(err_msg) response = httpx.put( relations_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data), timeout=get_http_timeout(), ) - print("Incident created.") + logger.info("Incident created.") return response.json().get("name") - def _get_incident_id(self, incident: str) -> str: + def _get_incident_id(self: Self, incident: str) -> str: """ Get an incident ID. @@ -384,31 +422,29 @@ def _get_incident_id(self, incident: str) -> str: """ try: UUID(incident) - return incident except ValueError as incident_name: - incidents = self.list_incidents() - filtered_incidents = incidents[ + incidents: pd.DataFrame = self.list_incidents() + filtered_incidents: pd.DataFrame = incidents[ incidents["properties.title"].str.contains(incident) ] if len(filtered_incidents) > 1: display(filtered_incidents[["name", "properties.title"]]) - raise MsticpyUserError( - "More than one incident found, please specify by GUID" - ) from incident_name + err_msg: str = "More than one incident found, please specify by GUID" + raise MsticpyUserError(err_msg) from incident_name if ( not isinstance(filtered_incidents, pd.DataFrame) or filtered_incidents.empty ): - raise MsticpyUserError( - f"Incident {incident} not found" - ) from incident_name + err_msg = f"Incident {incident} not found" + raise MsticpyUserError(err_msg) from incident_name return filtered_incidents["name"].iloc[0] + return incident def post_comment( - self, + self: Self, incident_id: str, comment: str, - ): + ) -> str: """ Write a comment for an incident. @@ -425,25 +461,28 @@ def post_comment( If message could not be posted. """ - self.check_connected() # type: ignore - comment_url = ( - self.sent_urls["incidents"] + f"/{incident_id}/comments/{uuid4()}" # type: ignore + self.check_connected() + comment_url: str = ( + self.sent_urls["incidents"] + f"/{incident_id}/comments/{uuid4()}" ) - params = {"api-version": "2020-01-01"} - data = extract_sentinel_response({"message": comment}) - response = httpx.put( + params: dict[str, str] = {"api-version": "2020-01-01"} + data: dict[str, Any] = extract_sentinel_response({"message": comment}) + if not self._token: + err_msg = "Token not found, can't post comment." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( comment_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data), timeout=get_http_timeout(), ) - if response.status_code not in (200, 201): + if not response.is_success: raise CloudError(response=response) - print("Comment posted.") + logger.info("Comment posted.") return response.json().get("name") - def add_bookmark_to_incident(self, incident: str, bookmark: str): + def add_bookmark_to_incident(self: Self, incident: str, bookmark: str) -> str: """ Add a bookmark to an incident. @@ -460,31 +499,34 @@ def add_bookmark_to_incident(self, incident: str, bookmark: str): If API returns error """ - self.check_connected() # type: ignore - incident_id = self._get_incident_id(incident) - incident_url = self.sent_urls["incidents"] + f"/{incident_id}" # type: ignore - bookmark_id = self._get_bookmark_id(bookmark) # type: ignore - mark_res_id = self.sent_urls["bookmarks"] + f"/{bookmark_id}" # type: ignore - relations_id = uuid4() - bookmark_url = incident_url + f"/relations/{relations_id}" - bkmark_data_items = { - "relatedResourceId": mark_res_id.split(self.base_url)[1] # type: ignore + self.check_connected() + incident_id: str = self._get_incident_id(incident) + incident_url: str = self.sent_urls["incidents"] + f"/{incident_id}" + bookmark_id: str = self._get_bookmark_id(bookmark) + mark_res_id: str = self.sent_urls["bookmarks"] + f"/{bookmark_id}" + relations_id: UUID = uuid4() + bookmark_url: str = incident_url + f"/relations/{relations_id}" + bkmark_data_items: dict[str, Any] = { + "relatedResourceId": mark_res_id.split(self.base_url)[1], } - data = extract_sentinel_response(bkmark_data_items, props=True) - params = {"api-version": "2021-04-01"} - response = httpx.put( + data: dict[str, Any] = extract_sentinel_response(bkmark_data_items, props=True) + params: dict[str, str] = {"api-version": "2021-04-01"} + if not self._token: + err_msg = "Token not found, can't add bookmark to incident." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( bookmark_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data), timeout=get_http_timeout(), ) - if response.status_code not in (200, 201): + if not response.is_success: raise CloudError(response=response) - print("Bookmark added to incident.") + logger.info("Bookmark added to incident.") return response.json().get("name") - def list_incidents(self, params: Optional[dict] = None) -> pd.DataFrame: + def list_incidents(self: Self, params: dict | None = None) -> pd.DataFrame: """ Get a list of incident for a Sentinel workspace. @@ -506,6 +548,6 @@ def list_incidents(self, params: Optional[dict] = None) -> pd.DataFrame: """ if params is None: params = {"$top": 50} - return self._list_items(item_type="incidents", params=params) # type: ignore + return self._list_items(item_type="incidents", params=params) - get_incidents = list_incidents + get_incidents: Callable[..., pd.DataFrame] = list_incidents diff --git a/msticpy/context/azure/sentinel_search.py b/msticpy/context/azure/sentinel_search.py index 5263cf198..08c08d793 100644 --- a/msticpy/context/azure/sentinel_search.py +++ b/msticpy/context/azure/sentinel_search.py @@ -4,31 +4,42 @@ # license information. # -------------------------------------------------------------------------- """Mixin Classes for Sentinel Search Features.""" -from datetime import datetime, timedelta +from __future__ import annotations + +import datetime as dt +import logging +from typing import TYPE_CHECKING, Any from uuid import uuid4 import httpx from azure.common.exceptions import CloudError +from typing_extensions import Self from ..._version import VERSION from .azure_data import get_api_headers -from .sentinel_utils import extract_sentinel_response +from .sentinel_utils import SentinelUtilsMixin, extract_sentinel_response +if TYPE_CHECKING: + from ...common.timespan import TimeSpan __version__ = VERSION __author__ = "Pete Bryan" +logger: logging.Logger = logging.getLogger(__name__) + -class SentinelSearchlistsMixin: +class SentinelSearchlistsMixin(SentinelUtilsMixin): """Mixin class for Sentinel Watchlist feature integrations.""" - def create_search( - self, + def create_search( # noqa: PLR0913 + self: Self, query: str, - start: datetime = None, - end: datetime = None, - search_name: str = None, - **kwargs, - ): + start: dt.datetime | None = None, + end: dt.datetime | None = None, + search_name: str | None = None, + *, + timespan: TimeSpan | None = None, + limit: int = 1000, + ) -> None: """ Create a Search job. @@ -42,6 +53,10 @@ def create_search( The end time for the query, by default now. search_name : str, optional A name to apply to the search, by default a random GUID is generated. + timespan: Timespan, optional + If defined, overwrite start and end variables. + limit: int, optional + Set the maximum number of results to return. Defaults to 1000. Raises ------ @@ -49,40 +64,39 @@ def create_search( If there is an error creating the search job. """ - limit = 1000 - if "limit" in kwargs: - limit = kwargs.pop("limit") - if "timespan" in kwargs: - start = kwargs.get("timespan").start # type: ignore - end = kwargs.get("timespan").end # type: ignore - search_end = end or datetime.now() - search_start = start or (search_end - timedelta(days=90)) - search_name = search_name or uuid4() # type: ignore - search_name = search_name.replace("_", "") # type: ignore - search_url = ( - self.sent_urls["search"] # type: ignore + if timespan: + start = timespan.start + end = timespan.end + search_end: dt.datetime = end or dt.datetime.now(tz=dt.timezone.utc) + search_start: dt.datetime = start or (search_end - dt.timedelta(days=90)) + search_name = (search_name or str(uuid4())).replace("_", "") + search_url: str = ( + self.sent_urls["search"] + f"/{search_name}_SRCH?api-version=2021-12-01-preview" ) - search_items = { + search_items: dict[str, dict[str, Any]] = { "searchResults": { "query": f"{query}", "limit": limit, "startSearchTime": f"{search_start.isoformat()}", "endSearchTime": f"{search_end.isoformat()}", - } + }, } - search_body = extract_sentinel_response(search_items) - search_create_response = httpx.put( + search_body: dict[str, Any] = extract_sentinel_response(search_items) + if not self._token: + err_msg = "Token not found, can't create search." + raise ValueError(err_msg) + search_create_response: httpx.Response = httpx.put( search_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), json=search_body, timeout=60, ) - if search_create_response.status_code != 202: + if not search_create_response.is_success: raise CloudError(response=search_create_response) - print(f"Search job created with for {search_name}_SRCH.") + logger.info("Search job created with for %s_SRCH.", search_name) - def check_search_status(self, search_name: str) -> bool: + def check_search_status(self: Self, search_name: str) -> bool: """ Check the status of a search job. @@ -103,23 +117,27 @@ def check_search_status(self, search_name: str) -> bool: """ search_name = search_name.strip("_SRCH") - search_url = ( - self.sent_urls["search"] # type: ignore + search_url: str = ( + self.sent_urls["search"] + f"/{search_name}_SRCH?api-version=2021-12-01-preview" ) - search_check_response = httpx.get( - search_url, headers=get_api_headers(self._token) # type: ignore + if not self._token: + err_msg = "Token not found, can't check search status." + raise ValueError(err_msg) + search_check_response: httpx.Response = httpx.get( + search_url, + headers=get_api_headers(self._token), ) - if search_check_response.status_code != 200: + if not search_check_response.is_success: raise CloudError(response=search_check_response) - check_result = search_check_response.json()["properties"]["provisioningState"] - print(f"{search_name}_SRCH status is '{check_result}'") - if check_result == "Succeeded": - return True - return False + check_result: str = search_check_response.json()["properties"][ + "provisioningState" + ] + logger.info("%s_SRCH status is '%s'", search_name, check_result) + return check_result == "Succeeded" - def delete_search(self, search_name: str): + def delete_search(self: Self, search_name: str) -> None: """ Delete a search result. @@ -135,13 +153,17 @@ def delete_search(self, search_name: str): """ search_name = search_name.strip("_SRCH") - search_url = ( - self.sent_urls["search"] # type: ignore + search_url: str = ( + self.sent_urls["search"] + f"/{search_name}_SRCH?api-version=2021-12-01-preview" ) - search_delete_response = httpx.delete( - search_url, headers=get_api_headers(self._token) # type: ignore + if not self._token: + err_msg = "Token not found, can't delete search." + raise ValueError(err_msg) + search_delete_response: httpx.Response = httpx.delete( + search_url, + headers=get_api_headers(self._token), ) - if search_delete_response.status_code != 202: + if not search_delete_response.is_success: raise CloudError(response=search_delete_response) - print(f"{search_name}_SRCH set for deletion.") + logger.info("%s_SRCH set for deletion.", search_name) diff --git a/msticpy/context/azure/sentinel_ti.py b/msticpy/context/azure/sentinel_ti.py index dccea872d..cb96269b4 100644 --- a/msticpy/context/azure/sentinel_ti.py +++ b/msticpy/context/azure/sentinel_ti.py @@ -4,27 +4,34 @@ # license information. # -------------------------------------------------------------------------- """Mixin Classes for Sentinel Analytics Features.""" -from datetime import datetime -from typing import Optional +from __future__ import annotations + +import datetime as dt +import logging +from typing import TYPE_CHECKING, Any import httpx -import pandas as pd from azure.common.exceptions import CloudError +from typing_extensions import Self from ..._version import VERSION from ...common.exceptions import MsticpyUserError from .azure_data import get_api_headers from .sentinel_utils import ( + SentinelUtilsMixin, _azs_api_result_to_df, extract_sentinel_response, get_http_timeout, ) +if TYPE_CHECKING: + import pandas as pd + __version__ = VERSION __author__ = "Pete Bryan" - -_INDICATOR_ITEMS = { +logger: logging.Logger = logging.getLogger(__name__) +_INDICATOR_ITEMS: dict[str, str] = { "valid_to": "validUntil", "description": "description", "threat_types": "threatTypes", @@ -34,7 +41,7 @@ "source": "source", } -_IOC_TYPE_MAPPING = { +_IOC_TYPE_MAPPING: dict[str, str] = { "dns": "domain-name", "url": "url", "ipv4": "ipv4-addr", @@ -44,14 +51,16 @@ "sha256_hash": "SHA-256", } +MAX_CONFIDENCE: int = 100 + -class SentinelTIMixin: +class SentinelTIMixin(SentinelUtilsMixin): """Mixin class for Sentinel Hunting feature integrations.""" def get_all_indicators( - self, - limit: Optional[int] = None, - orderby: Optional[str] = None, + self: Self, + limit: int | None = None, + orderby: str | None = None, ) -> pd.DataFrame: """ Return all TI indicators in a Microsoft Sentinel workspace. @@ -74,11 +83,13 @@ def get_all_indicators( appendix += f"&$top={limit}" if orderby: appendix += f"&$orderby={orderby}" - return self._list_items( # type: ignore - item_type="ti", api_version="2021-10-01", appendix=appendix - ) # type: ignore + return self._list_items( + item_type="ti", + api_version="2021-10-01", + appendix=appendix, + ) - def get_ti_metrics(self) -> pd.DataFrame: + def get_ti_metrics(self: Self) -> pd.DataFrame: """ Return metrics about TI indicators in a Microsoft Sentinel workspace. @@ -88,18 +99,27 @@ def get_ti_metrics(self) -> pd.DataFrame: A table of the custom hunting queries. """ - return self._list_items( # type: ignore - item_type="ti", api_version="2021-10-01", appendix="/metrics" - ) # type: ignore + return self._list_items( + item_type="ti", + api_version="2021-10-01", + appendix="/metrics", + ) - def create_indicator( - self, + def create_indicator( # pylint:disable=too-many-arguments, too-many-locals #noqa:PLR0913 + self: Self, indicator: str, ioc_type: str, name: str = "TI Indicator", confidence: int = 0, + *, silent: bool = False, - **kwargs, + description: str | None = None, + labels: list | None = None, + kill_chain_phases: list | None = None, + threat_types: list | None = None, + external_references: list | None = None, + valid_from: dt.datetime | None = None, + valid_to: dt.datetime | None = None, ) -> str: """ Create a new indicator within the Microsoft Sentinel workspace. @@ -145,24 +165,24 @@ def create_indicator( If API call fails """ - self.check_connected() # type: ignore - ti_url = self.sent_urls["ti"] + "/createIndicator" # type: ignore - params = {"api-version": "2021-10-01"} + self.check_connected() + ti_url: str = self.sent_urls["ti"] + "/createIndicator" + params: dict[str, str] = {"api-version": "2021-10-01"} if ioc_type not in _IOC_TYPE_MAPPING: - raise MsticpyUserError( - """ioc_type must be one of - + err_msg: str = """ioc_type must be one of - 'dns', 'url', 'ipv4', 'ipv6', 'md5_hash', 'sha1_hash', 'sha256_hash'""" - ) - normalized_ioc_type = _IOC_TYPE_MAPPING[ioc_type] - pattern_type = normalized_ioc_type - value = "value" + raise MsticpyUserError(err_msg) + normalized_ioc_type: str = _IOC_TYPE_MAPPING[ioc_type] + pattern_type: str = normalized_ioc_type + value: str = "value" if normalized_ioc_type in ["SHA-256", "SHA-1", "MD5"]: pattern_type = "file" value = f"hashes.'{normalized_ioc_type}'" - if confidence > 100 or confidence < 0: - raise MsticpyUserError("confidence must be between 0 and 100") - data_items = { + if confidence > MAX_CONFIDENCE or confidence < 0: + err_msg = "confidence must be between 0 and 100" + raise MsticpyUserError(err_msg) + data_items: dict[str, Any] = { "displayName": name, "confidence": confidence, "pattern": f"[{pattern_type}:{value} = '{indicator}']", @@ -170,12 +190,27 @@ def create_indicator( "revoked": "false", "source": "MSTICPy", } - data_items.update(_build_additional_indicator_items(**kwargs)) - data = extract_sentinel_response(data_items, props=True) + data_items.update( + _build_additional_indicator_items( + valid_from=valid_from, + valid_to=valid_to, + external_references=external_references, + kill_chain_phases=kill_chain_phases, + labels=labels, + name=name, + threat_types=threat_types, + confidence=confidence, + description=description, + ), + ) + data: dict[str, Any] = extract_sentinel_response(data_items, props=True) data["kind"] = "indicator" - response = httpx.post( + if not self._token: + err_msg = "Token not found, can't create indicator." + raise ValueError(err_msg) + response: httpx.Response = httpx.post( ti_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data), timeout=get_http_timeout(), @@ -183,17 +218,18 @@ def create_indicator( if response.status_code not in (200, 201): raise CloudError(response=response) if not silent: - print("Indicator created.") + logger.info("Indicator created.") return response.json().get("name") def bulk_create_indicators( - self, + self: Self, data: pd.DataFrame, indicator_column: str = "Observable", indicator_type_column: str = "IoCType", - **kwargs, - ): + *, + confidence_column: str | None = None, + ) -> None: """ Bulk create indicators from a DataFrame. @@ -210,11 +246,7 @@ def bulk_create_indicators( """ for row in data.iterrows(): - confidence = ( - row[1][kwargs["confidence_column"]] - if "confidence_column" in kwargs - else 0 - ) + confidence: int = row[1][confidence_column] if confidence_column else 0 try: self.create_indicator( indicator=row[1][indicator_column], @@ -223,10 +255,13 @@ def bulk_create_indicators( silent=True, ) except CloudError: - print(f"Error creating indicator {row[1][indicator_column]}") - print(f"{len(data.index)} indicators created.") + logger.exception( + "Error creating indicator %s", + row[1][indicator_column], + ) + logger.info("%s indicators created.", len(data.index)) - def get_indicator(self, indicator_id: str) -> dict: + def get_indicator(self: Self, indicator_id: str) -> dict: """ Get a specific indicator by its ID. @@ -246,20 +281,36 @@ def get_indicator(self, indicator_id: str) -> dict: If API call fails. """ - self.check_connected() # type: ignore - ti_url = self.sent_urls["ti"] + f"/indicators/{indicator_id}" # type: ignore - params = {"api-version": "2021-10-01"} - response = httpx.get( + self.check_connected() + ti_url: str = self.sent_urls["ti"] + f"/indicators/{indicator_id}" + params: dict[str, str] = {"api-version": "2021-10-01"} + if not self._token: + err_msg = "Token not found, can't get indicator." + raise ValueError(err_msg) + response: httpx.Response = httpx.get( ti_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, timeout=get_http_timeout(), ) - if response.status_code != 200: + if not response.is_success: raise CloudError(response=response) return response.json() - def update_indicator(self, indicator_id: str, **kwargs): + def update_indicator( # pylint:disable=too-many-arguments,too-many-locals #noqa:PLR0913 + self: Self, + indicator_id: str, + *, + name: str | None = None, + confidence: int = 0, + description: str | None = None, + labels: list[str] | None = None, + kill_chain_phases: list | None = None, + threat_types: list | None = None, + external_references: list | None = None, + valid_from: dt.datetime | None = None, + valid_to: dt.datetime | None = None, + ) -> None: """ Update an existing indicator within the Microsoft Sentinel workspace. @@ -294,28 +345,44 @@ def update_indicator(self, indicator_id: str, **kwargs): If API call fails """ - self.check_connected() # type: ignore - ti_url = self.sent_urls["ti"] + f"/indicators/{indicator_id}" # type: ignore - indicator_details = self.get_indicator(indicator_id) - data_items = _build_additional_indicator_items(**kwargs) + self.check_connected() + ti_url: str = self.sent_urls["ti"] + f"/indicators/{indicator_id}" + indicator_details: dict[str, Any] = self.get_indicator(indicator_id) + data_items: dict[str, Any] = _build_additional_indicator_items( + valid_from=valid_from, + valid_to=valid_to, + external_references=external_references, + kill_chain_phases=kill_chain_phases, + labels=labels, + name=name, + threat_types=threat_types, + confidence=confidence, + description=description, + ) data_items.pop("validFrom") - full_data_items = _add_missing_items(data_items, indicator_details) - data = extract_sentinel_response(full_data_items, props=True) + full_data_items: dict[str, Any] = _add_missing_items( + data_items, + indicator_details, + ) + data: dict[str, Any] = extract_sentinel_response(full_data_items, props=True) data["etag"] = indicator_details["etag"] data["kind"] = "indicator" - params = {"api-version": "2021-10-01"} - response = httpx.put( + params: dict[str, str] = {"api-version": "2021-10-01"} + if not self._token: + err_msg = "Token not found, can't update indicator." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( ti_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data), timeout=get_http_timeout(), ) if response.status_code not in (200, 201): raise CloudError(response=response) - print("Indicator updated.") + logger.info("Indicator updated.") - def add_tag(self, indicator_id: str, tag: str): + def add_tag(self: Self, indicator_id: str, tag: str) -> None: """ Add a tag to an existing indicator. @@ -327,14 +394,14 @@ def add_tag(self, indicator_id: str, tag: str): The tag to add. """ - self.check_connected() # type: ignore - indicator_details = self.get_indicator(indicator_id) - tags = [tag] + self.check_connected() + indicator_details: dict[str, Any] = self.get_indicator(indicator_id) + tags: list[str] = [tag] if "threatIntelligenceTags" in indicator_details["properties"]: tags += indicator_details["properties"]["threatIntelligenceTags"] self.update_indicator(indicator_id=indicator_id, labels=tags) - def delete_indicator(self, indicator_id: str): + def delete_indicator(self: Self, indicator_id: str) -> None: """ Delete a specific TI indicator. @@ -349,48 +416,65 @@ def delete_indicator(self, indicator_id: str): If API call fails """ - self.check_connected() # type: ignore - ti_url = self.sent_urls["ti"] + f"/indicators/{indicator_id}" # type: ignore - params = {"api-version": "2021-10-01"} - response = httpx.delete( + self.check_connected() + ti_url: str = self.sent_urls["ti"] + f"/indicators/{indicator_id}" + params: dict[str, str] = {"api-version": "2021-10-01"} + if not self._token: + err_msg = "Token not found, can't delete indicator." + raise ValueError(err_msg) + response: httpx.Response = httpx.delete( ti_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, timeout=get_http_timeout(), ) if response.status_code not in (200, 204): raise CloudError(response=response) - print("Indicator deleted.") - - def query_indicators(self, **kwargs) -> pd.DataFrame: + logger.info("Indicator deleted.") + + def query_indicators( # pylint:disable=too-many-arguments, too-many-locals #noqa:PLR0913 + self: Self, + *, + include_disabled: bool = False, + keywords: str | None = None, + min_confidence: int = 0, + max_confidence: int = 100, + max_valid_until: str | None = None, + min_valid_until: str | None = None, + page_size: int | None = None, + pattern_types: list[str] | None = None, + sort_by: list[str] | None = None, + sources: list[str] | None = None, + threat_types: list[str] | None = None, + ) -> pd.DataFrame: """ Query for indicators in a Sentinel workspace. Parameters ---------- - includeDisabled : bool, optional + include_disabled : bool, optional Parameter to include/exclude disabled indicators. keywords : str, optional Keyword for searching threat intelligence indicators Use this to search for specific indicator values. - maxConfidence : int, optional + max_confidence : int, optional Maximum confidence. - maxValidUntil : str, optional + max_valid_until : str, optional End time for ValidUntil filter. - minConfidence : int, optional + min_confidence : int, optional Minimum confidence. - minValidUntil : str, optional + min_valid_until : str, optional Start time for ValidUntil filter. - pageSize : int, optional + page_size : int, optional Maximum number of results to return in one page. - patternTypes : list, optional + pattern_types : list, optional A list of IoC types to include. - sortBy : List, optional + sort_by : List, optional Columns to sort by and sorting order as: [{"itemKey": COLUMN_NAME, "sortOrder": ascending/descending}] sources: list, optional A list of indicator sources to include - threatTypes: list, optional + threat_types: list, optional A list of Threat types to include Returns @@ -404,63 +488,109 @@ def query_indicators(self, **kwargs) -> pd.DataFrame: If API call fails """ - self.check_connected() # type: ignore - ti_url = self.sent_urls["ti"] + "/queryIndicators" # type: ignore - data_items = dict(kwargs) - params = {"api-version": "2021-10-01"} - response = httpx.post( + self.check_connected() + ti_url: str = self.sent_urls["ti"] + "/queryIndicators" + data_items: dict[str, Any] = { + "includeDisabled": include_disabled, + "maxConfidence": max_confidence, + "minConfidence": min_confidence, + } + if keywords: + data_items["keywords"] = keywords + if max_valid_until: + data_items["maxValidUntil"] = max_valid_until + if min_valid_until: + data_items["minValidUntil"] = min_valid_until + if page_size: + data_items["pageSize"] = page_size + if pattern_types: + data_items["patternTypes"] = pattern_types + if sort_by: + data_items["sortBy"] = sort_by + if sources: + data_items["sources"] = sources + if threat_types: + data_items["threatTypes"] = threat_types + params: dict[str, str] = {"api-version": "2021-10-01"} + if not self._token: + err_msg = "Token not found, can't query indicators." + raise ValueError(err_msg) + response: httpx.Response = httpx.post( ti_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(data_items), timeout=get_http_timeout(), ) - if response.status_code != 200: + if not response.is_success: raise CloudError(response=response) return _azs_api_result_to_df(response) -def _build_additional_indicator_items(**kwargs) -> dict: +def _build_additional_indicator_items( # pylint:disable=too-many-arguments #noqa: PLR0913 + *, + valid_from: dt.datetime | None = None, + valid_to: dt.datetime | None = None, + external_references: list[str] | None = None, + kill_chain_phases: list[str] | None = None, + labels: list[str] | None = None, + name: str | None = None, + confidence: int = 0, + description: str | None = None, + threat_types: list | None = None, + revoked: bool | None = None, + source: str | None = None, +) -> dict: """Add in additional data items for indicators.""" - data_items = { + data_items: dict[str, Any] = { "validFrom": ( - kwargs["valid_from"].isoformat() - if "valid_from" in kwargs - else datetime.now().isoformat() - ) + valid_from.isoformat() + if valid_from + else dt.datetime.now(tz=dt.timezone.utc).isoformat() + ), + "confidence": confidence, } - for item, value in kwargs.items(): - if item in _INDICATOR_ITEMS: - data_items[_INDICATOR_ITEMS[item]] = value - if "valid_to" in kwargs: - data_items["validUntil"] = kwargs["valid_to"].isoformat() + if name: + data_items["displayName"] = name + if description: + data_items["description"] = description + if threat_types: + data_items["threatTypes"] = threat_types + if revoked: + data_items["revoked"] = revoked + if source: + data_items["source"] = source + if valid_to: + data_items["validUntil"] = valid_to.isoformat() else: - data_items["validUntil"] = datetime.now().isoformat() - if "external_references" in kwargs: - ext_refs = [ - {"sourceName": "MSTICPy", "url": ref} - for ref in kwargs["external_references"] + data_items["validUntil"] = dt.datetime.now(tz=dt.timezone.utc).isoformat() + if external_references: + ext_refs: list[dict[str, Any]] = [ + {"sourceName": "MSTICPy", "url": ref} for ref in external_references ] data_items["externalReferences"] = ext_refs - if "kill_chain_phases" in kwargs: - kill_chain = [ + if kill_chain_phases: + kill_chain: list[dict[str, Any]] = [ { "killChainName": "Lockheed Martin - Intrusion Kill Chain", "phaseName": phase, } - for phase in kwargs["kill_chain_phases"] + for phase in kill_chain_phases ] data_items["killChainPhases"] = kill_chain - if "labels" in kwargs: - data_items["labels"] = kwargs["labels"] - data_items["threatIntelligenceTags"] = kwargs["labels"] + if labels: + data_items["labels"] = labels + data_items["threatIntelligenceTags"] = labels return data_items -_REQUIRED_ITEMS = ["pattern", "patternType", "source"] +_REQUIRED_ITEMS: list[str] = ["pattern", "patternType", "source"] -def _add_missing_items(data_items, indicator) -> dict: +def _add_missing_items( + data_items: dict[str, Any], + indicator: dict[str, Any], +) -> dict[str, Any]: """Add missing required items to requests based on existing values.""" for req_item in _REQUIRED_ITEMS: if req_item not in data_items: diff --git a/msticpy/context/azure/sentinel_utils.py b/msticpy/context/azure/sentinel_utils.py index 2011567a0..cc1c6530f 100644 --- a/msticpy/context/azure/sentinel_utils.py +++ b/msticpy/context/azure/sentinel_utils.py @@ -3,29 +3,32 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -"""Mixin Classes for Sentinel Utilities.""" +"""Mixin Classes for Sentinel Utilties.""" +from __future__ import annotations + import logging from collections import Counter from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any import httpx import pandas as pd from azure.common.exceptions import CloudError from azure.mgmt.core import tools as az_tools +from typing_extensions import Self from ..._version import VERSION from ...auth.azure_auth_core import AzureCloudConfig from ...common.exceptions import MsticpyAzureConfigError, MsticpyAzureConnectionError from ...common.pkg_config import get_http_timeout -from .azure_data import get_api_headers +from .azure_data import AzureData, get_api_headers __version__ = VERSION __author__ = "Pete Bryan" -logger = logging.getLogger(__name__) +logger: logging.Logger = logging.getLogger(__name__) -_PATH_MAPPING = { +_PATH_MAPPING: dict[str, str] = { "ops_path": "/providers/Microsoft.SecurityInsights/operations", "alert_rules": "/providers/Microsoft.SecurityInsights/alertRules", "ss_path": "/savedSearches", @@ -49,41 +52,47 @@ class SentinelInstanceDetails: workspace_name: str @property - def resource_id(self) -> Optional[str]: + def resource_id(self) -> str: """Return the resource ID for the workspace.""" return build_sentinel_resource_id( - self.subscription_id, self.resource_group, self.workspace_name # type: ignore + self.subscription_id, + self.resource_group, + self.workspace_name, ) @classmethod - def from_resource_id(cls, resource_id: str) -> "SentinelInstanceDetails": + def from_resource_id(cls: type[Self], resource_id: str) -> Self: """Return SentinelInstanceDetails from a resource ID.""" return cls(**parse_resource_id(resource_id)) -class SentinelUtilsMixin: +class SentinelUtilsMixin(AzureData): """Mixin class for Sentinel core feature integrations.""" - def _get_items(self, url: str, params: Optional[dict] = None) -> httpx.Response: + def _get_items(self: Self, url: str, params: dict | None = None) -> httpx.Response: """Get items from the API.""" - self.check_connected() # type: ignore + self.check_connected() if params is None: params = {"api-version": "2020-01-01"} logger.debug("_get_items request to %s.", url) + if not self._token: + err_msg = "Token not found, can't get items." + raise ValueError(err_msg) return httpx.get( url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, timeout=get_http_timeout(), ) - def _list_items( - self, + def _list_items( # noqa:PLR0913 + self: Self, item_type: str, api_version: str = "2020-01-01", - appendix: Optional[str] = None, + appendix: str | None = None, + *, next_follow: bool = False, - params: Optional[Dict[str, Any]] = None, + params: dict[str, Any] | None = None, ) -> pd.DataFrame: """ Return lists of core resources from APIs. @@ -100,6 +109,7 @@ def _list_items( If True, follow the nextLink to get all results, by default False params: Dict, optional Any additional parameters to pass to the API call, by default None + Returns ------- pd.DataFrame @@ -111,38 +121,40 @@ def _list_items( If a valid result is not returned. """ - item_url = self.url + _PATH_MAPPING[item_type] # type: ignore + item_url: str = (self.url or "") + _PATH_MAPPING[item_type] if appendix: item_url = item_url + appendix if params is None: params = {} params["api-version"] = api_version - response = self._get_items(item_url, params) - if response.status_code == 200: - results_df = _azs_api_result_to_df(response) + response: httpx.Response = self._get_items(item_url, params) + if response.is_success: + results_df: pd.DataFrame = _azs_api_result_to_df(response) else: raise CloudError(response=response) - j_resp = response.json() - results = [results_df] + j_resp: dict[str, Any] = response.json() + results: list[pd.DataFrame] = [results_df] # If nextLink in response, go get that data as well if next_follow: i = 0 # Limit to 5 nextLinks to prevent infinite loop while "nextLink" in j_resp and i < 5: - next_url = j_resp["nextLink"] - next_response = self._get_items(next_url, params) - next_results_df = _azs_api_result_to_df(next_response) + next_url: str = j_resp["nextLink"] + next_response: httpx.Response = self._get_items(next_url, params) + next_results_df: pd.DataFrame = _azs_api_result_to_df(next_response) results.append(next_results_df) j_resp = next_response.json() i += 1 results_df = pd.concat(results) logger.info( - "list_items request to %s returned %d rows", item_url, len(results_df) + "list_items request to %s returned %d rows", + item_url, + len(results_df), ) return results_df def _build_sent_res_id( - self, + self: Self, subscription_id: str, resource_group: str, workspace_name: str, @@ -166,19 +178,23 @@ def _build_sent_res_id( """ return build_sentinel_resource_id( - subscription_id, resource_group, workspace_name + subscription_id, + resource_group, + workspace_name, ) def _build_sentinel_api_root( - self, sentinel_instance: SentinelInstanceDetails, base_url: Optional[str] = None + self: Self, + sentinel_instance: SentinelInstanceDetails, + base_url: str | None = None, ) -> str: """ Build an API URL from an Azure resource ID. Parameters ---------- - res_id : str - An Azure resource ID. + sentinel_instance : SentinelInstanceDetails + Details of a Sentinel workspace base_url : str, optional The base URL of the Azure cloud to connect to. Defaults to resource manager for configured cloud. @@ -192,25 +208,23 @@ def _build_sentinel_api_root( """ if not base_url: - base_url = AzureCloudConfig(self.cloud).resource_manager # type: ignore - resource_id = sentinel_instance.resource_id + base_url = AzureCloudConfig(self.cloud).resource_manager + resource_id: str | None = sentinel_instance.resource_id if base_url.endswith("/"): base_url = base_url[:-1] - sentinel_api_url = "".join( - [ - f"{base_url}{resource_id}", - ] - ) + sentinel_api_url: str = f"{base_url}{resource_id}" logger.info("Sentinel API URL built: %s", sentinel_api_url) return sentinel_api_url - def check_connected(self): + def check_connected(self: Self) -> None: """Check that Sentinel workspace is connected.""" - if not self.connected: # type: ignore - raise MsticpyAzureConnectionError( - "Not connected to Sentinel, ensure you run `.connect` before calling functions." + if not self.connected: + err_msg: str = ( + "Not connected to Sentinel, ensure you run `.connect`" + "before calling functions." ) + raise MsticpyAzureConnectionError(err_msg) def _azs_api_result_to_df(response: httpx.Response) -> pd.DataFrame: @@ -233,9 +247,10 @@ def _azs_api_result_to_df(response: httpx.Response) -> pd.DataFrame: If the response is not valid JSON. """ - j_resp = response.json() - if response.status_code != 200 or not j_resp: - raise ValueError("No valid JSON result in response") + j_resp: dict[str, Any] = response.json() + if not response.is_success or not j_resp: + err_msg: str = "No valid JSON result in response" + raise ValueError(err_msg) if "value" in j_resp: j_resp = j_resp["value"] return pd.json_normalize(j_resp) @@ -264,17 +279,20 @@ def build_sentinel_resource_id( The formatted resource ID. """ - resource_id = "".join( - [ - f"/subscriptions/{subscription_id}/resourcegroups/{resource_group}", - f"/providers/Microsoft.OperationalInsights/workspaces/{workspace_name}", - ] + resource_id: str = ( + f"/subscriptions/{subscription_id}/resourcegroups/{resource_group}" + f"/providers/Microsoft.OperationalInsights/workspaces/{workspace_name}" ) logger.info("Resource ID built: %s", resource_id) return resource_id -def extract_sentinel_response(items: dict, props: bool = False, **kwargs) -> dict: +def extract_sentinel_response( + items: dict, + *, + props: bool = False, + etag: dict[str, str] | None = None, +) -> dict: """ Build request data body from items. @@ -284,6 +302,8 @@ def extract_sentinel_response(items: dict, props: bool = False, **kwargs) -> dic A set pf items to be formated in the request body. props: bool, optional Whether all items are to be built as properities. Default is false. + etag: dict[str, str], Optional + If defined, set the etag body value Returns ------- @@ -291,35 +311,36 @@ def extract_sentinel_response(items: dict, props: bool = False, **kwargs) -> dic The request body formatted for the API. """ - data_body = {"properties": {}} # type: Dict[str, Dict[str, str]] + data_body: dict[str, dict[str, str]] = {"properties": {}} for key in items: if key in ["severity", "status", "title", "message", "searchResults"] or props: - data_body["properties"].update({key: items[key]}) # type:ignore + data_body["properties"].update({key: items[key]}) else: data_body[key] = items[key] - if "etag" in kwargs: - data_body["etag"] = kwargs.get("etag") # type:ignore + if etag: + data_body["etag"] = etag return data_body -def validate_resource_id(res_id): +def validate_resource_id(res_id: str) -> str: """Validate a Resource ID String and fix if needed.""" - valid = _validator(res_id) + valid: bool = _validator(res_id) if not valid: res_id = _fix_res_id(res_id) valid = _validator(res_id) if not valid: - raise MsticpyAzureConfigError("The Resource ID provided is not valid.") + err_msg: str = "The Resource ID provided is not valid." + raise MsticpyAzureConfigError(err_msg) return res_id -def parse_resource_id(res_id: str) -> Dict[str, Any]: +def parse_resource_id(res_id: str) -> dict[str, Any]: """Extract components from workspace resource ID.""" if not res_id.startswith("/"): res_id = f"/{res_id}" - res_id_parts = az_tools.parse_resource_id(res_id) - workspace_name = None + res_id_parts: dict[str, Any] = az_tools.parse_resource_id(res_id) + workspace_name: str | None = None if ( res_id_parts.get("namespace") == "Microsoft.OperationalInsights" and res_id_parts.get("type") == "workspaces" @@ -332,12 +353,12 @@ def parse_resource_id(res_id: str) -> Dict[str, Any]: } -def _validator(res_id): +def _validator(res_id: str) -> bool: """Check Resource ID string matches pattern expected.""" return az_tools.is_valid_resource_id(res_id) -def _fix_res_id(res_id): +def _fix_res_id(res_id: str) -> str: """Try to fix common issues with Resource ID string.""" if res_id.startswith("https:"): res_id = "/".join(res_id.split("/")[5:]) diff --git a/msticpy/context/azure/sentinel_watchlists.py b/msticpy/context/azure/sentinel_watchlists.py index 89bd9fbe9..df8af7e80 100644 --- a/msticpy/context/azure/sentinel_watchlists.py +++ b/msticpy/context/azure/sentinel_watchlists.py @@ -4,26 +4,36 @@ # license information. # -------------------------------------------------------------------------- """Mixin Classes for Sentinel Watchlist Features.""" -from typing import Dict, Optional, Union +from __future__ import annotations + +import logging +from typing import Any from uuid import uuid4 import httpx import pandas as pd from azure.common.exceptions import CloudError +from typing_extensions import Self from ..._version import VERSION from ...common.exceptions import MsticpyUserError from .azure_data import get_api_headers -from .sentinel_utils import extract_sentinel_response, get_http_timeout +from .sentinel_utils import ( + SentinelUtilsMixin, + extract_sentinel_response, + get_http_timeout, +) __version__ = VERSION __author__ = "Pete Bryan" +logger: logging.Logger = logging.getLogger(__name__) + -class SentinelWatchlistsMixin: +class SentinelWatchlistsMixin(SentinelUtilsMixin): """Mixin class for Sentinel Watchlist feature integrations.""" - def list_watchlists(self) -> pd.DataFrame: + def list_watchlists(self: Self) -> pd.DataFrame: """ List Deployed Watchlists. @@ -38,20 +48,20 @@ def list_watchlists(self) -> pd.DataFrame: If a valid result is not returned. """ - return self._list_items( # type: ignore + return self._list_items( item_type="watchlists", api_version="2021-04-01", ) - def create_watchlist( - self, + def create_watchlist( # noqa: PLR0913 + self: Self, watchlist_name: str, description: str, search_key: str, provider: str = "MSTICPy", source: str = "Notebook", - data: pd.DataFrame = None, - ) -> Optional[str]: + data: pd.DataFrame | None = None, + ) -> str | None: """ Create a new watchlist. @@ -87,38 +97,41 @@ def create_watchlist( If there is an issue creating the watchlist. """ - self.check_connected() # type: ignore + self.check_connected() if self._check_watchlist_exists(watchlist_name): - raise MsticpyUserError(f"Watchlist {watchlist_name} already exist.") - watchlist_url = self.sent_urls["watchlists"] + f"/{watchlist_name}" # type: ignore - params = {"api-version": "2021-04-01"} - data_items = { + err_msg: str = f"Watchlist {watchlist_name} already exist." + raise MsticpyUserError(err_msg) + watchlist_url: str = self.sent_urls["watchlists"] + f"/{watchlist_name}" + params: dict[str, str] = {"api-version": "2021-04-01"} + data_items: dict[str, str] = { "displayName": watchlist_name, "source": source, "provider": provider, "description": description, "itemsSearchKey": search_key, "contentType": "text/csv", - } # type: Dict[str, str] + } if isinstance(data, pd.DataFrame) and not data.empty: - data_csv = data.to_csv(index=False) - data_items["rawContent"] = str(data_csv) - request_data = extract_sentinel_response(data_items, props=True) - response = httpx.put( + data_items["rawContent"] = str(data.to_csv(index=False)) + request_data: dict[str, Any] = extract_sentinel_response(data_items, props=True) + if not self._token: + err_msg = "Token not found, can't create watchlist." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( watchlist_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, content=str(request_data), timeout=get_http_timeout(), ) - if response.status_code != 200: + if not response.is_success: raise CloudError(response=response) - print("Watchlist created.") + logger.info("Watchlist created.") return response.json().get("name") def list_watchlist_items( - self, + self: Self, watchlist_name: str, ) -> pd.DataFrame: """ @@ -140,19 +153,20 @@ def list_watchlist_items( If a valid result is not returned. """ - watchlist_name_str = f"/{watchlist_name}/watchlistItems" - return self._list_items( # type: ignore + watchlist_name_str: str = f"/{watchlist_name}/watchlistItems" + return self._list_items( item_type="watchlists", api_version="2021-04-01", appendix=watchlist_name_str, ) def add_watchlist_item( - self, + self: Self, watchlist_name: str, - item: Union[Dict, pd.Series, pd.DataFrame], + item: dict | pd.Series | pd.DataFrame, + *, overwrite: bool = False, - ): + ) -> None: """ Add or update an item in a Watchlist. @@ -176,66 +190,74 @@ def add_watchlist_item( If the API returns an error. """ - self.check_connected() # type: ignore + self.check_connected() # Check requested watchlist actually exists if not self._check_watchlist_exists(watchlist_name): - raise MsticpyUserError(f"Watchlist {watchlist_name} does not exist.") + err_msg: str = f"Watchlist {watchlist_name} does not exist." + raise MsticpyUserError(err_msg) - new_items = [] + new_items: list[dict] = [] # Convert items to add to dictionary format if isinstance(item, pd.Series): new_items = [dict(item)] - elif isinstance(item, Dict): + elif isinstance(item, dict): new_items = [item] elif isinstance(item, pd.DataFrame): for _, line_item in item.iterrows(): new_items.append(dict(line_item)) - current_items = self.list_watchlist_items(watchlist_name) - current_items_values = current_items.filter( - regex="^properties.itemsKeyValue.", axis=1 + current_items: pd.DataFrame = self.list_watchlist_items(watchlist_name) + current_items_values: pd.DataFrame = current_items.filter( + regex="^properties.itemsKeyValue.", + axis=1, ) current_items_values.columns = current_items_values.columns.str.replace( - "properties.itemsKeyValue.", "", regex=False + "properties.itemsKeyValue.", + "", + regex=False, ) for new_item in new_items: # See if item already exists, if it does get the item ID current_df, item_series = current_items_values.align( - pd.Series(new_item), axis=1, copy=False # type: ignore + pd.Series(new_item), + axis=1, + copy=False, ) - if (current_df == item_series).all(axis=1).any() and overwrite: # type: ignore - watchlist_id = current_items[ + if (current_df == item_series).all(axis=1).any() and overwrite: + watchlist_id: str = current_items[ current_items.isin(list(new_item.values())).any(axis=1) ]["properties.watchlistItemId"].iloc[0] # If not in watchlist already generate new ID - elif not (current_df == item_series).all(axis=1).any(): # type: ignore + elif not (current_df == item_series).all(axis=1).any(): watchlist_id = str(uuid4()) else: - raise MsticpyUserError( - "Item already exists in the watchlist. Set overwrite = True to replace." - ) + err_msg = "Item already exists in the watchlist. Set overwrite = True to replace." + raise MsticpyUserError(err_msg) - watchlist_url = ( - self.sent_urls["watchlists"] # type: ignore - + f"/{watchlist_name}/watchlistItems/{watchlist_id}" + watchlist_url: str = ( + f"{self.sent_urls['watchlists']}/{watchlist_name}" + f"/watchlistItems/{watchlist_id}" ) - response = httpx.put( + if not self._token: + err_msg = "Token not found, can't add watchlist item." + raise ValueError(err_msg) + response: httpx.Response = httpx.put( watchlist_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params={"api-version": "2021-04-01"}, content=str({"properties": {"itemsKeyValue": item}}), timeout=get_http_timeout(), ) - if response.status_code != 200: + if not response.is_success: raise CloudError(response=response) - print(f"Items added to {watchlist_name}") + logger.info("Items added to %s", watchlist_name) def delete_watchlist( - self, + self: Self, watchlist_name: str, - ): + ) -> None: """ Delete a selected Watchlist. @@ -252,23 +274,31 @@ def delete_watchlist( If the API returns an error. """ - self.check_connected() # type: ignore + self.check_connected() # Check requested watchlist actually exists if not self._check_watchlist_exists(watchlist_name): - raise MsticpyUserError(f"Watchlist {watchlist_name} does not exist.") - watchlist_url = self.sent_urls["watchlists"] + f"/{watchlist_name}" # type: ignore - params = {"api-version": "2021-04-01"} - response = httpx.delete( + err_msg: str = f"Watchlist {watchlist_name} does not exist." + raise MsticpyUserError(err_msg) + watchlist_url: str = self.sent_urls["watchlists"] + f"/{watchlist_name}" + params: dict[str, str] = {"api-version": "2021-04-01"} + if not self._token: + err_msg = "Token not found, can't delete watchlist." + raise ValueError(err_msg) + response: httpx.Response = httpx.delete( watchlist_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params=params, timeout=get_http_timeout(), ) - if response.status_code != 200: + if not response.is_success: raise CloudError(response=response) - print(f"Watchlist {watchlist_name} deleted") + logger.info("Watchlist %s deleted", watchlist_name) - def delete_watchlist_item(self, watchlist_name: str, watchlist_item_id: str): + def delete_watchlist_item( + self: Self, + watchlist_name: str, + watchlist_item_id: str, + ) -> None: """ Delete a Watchlist item. @@ -287,30 +317,34 @@ def delete_watchlist_item(self, watchlist_name: str, watchlist_item_id: str): If the API returns an error. """ - self.check_connected() # type: ignore + self.check_connected() # Check requested watchlist actually exists if not self._check_watchlist_exists(watchlist_name): - raise MsticpyUserError(f"Watchlist {watchlist_name} does not exist.") + err_msg: str = f"Watchlist {watchlist_name} does not exist." + raise MsticpyUserError(err_msg) - watchlist_url = ( - self.sent_urls["watchlists"] # type: ignore + watchlist_url: str = ( + self.sent_urls["watchlists"] + f"/{watchlist_name}/watchlistItems/{watchlist_item_id}" ) - response = httpx.delete( + if not self._token: + err_msg = "Token not found, can't delete watchlist item." + raise ValueError(err_msg) + response: httpx.Response = httpx.delete( watchlist_url, - headers=get_api_headers(self._token), # type: ignore + headers=get_api_headers(self._token), params={"api-version": "2023-02-01"}, timeout=get_http_timeout(), ) - if response.status_code != 200: + if not response.is_success: raise CloudError(response=response) - print(f"Item deleted from {watchlist_name}") + logger.info("Item deleted from %s", watchlist_name) def _check_watchlist_exists( - self, + self: Self, watchlist_name: str, - ): + ) -> bool: """ Check whether a Watchlist exists or not. @@ -328,7 +362,7 @@ def _check_watchlist_exists( """ # Check requested watchlist actually exists - existing_watchlists = self.list_watchlists() + existing_watchlists: pd.DataFrame = self.list_watchlists() if existing_watchlists.empty: return False - return watchlist_name in existing_watchlists["name"].values + return watchlist_name in existing_watchlists["name"].to_numpy() diff --git a/msticpy/context/azure/sentinel_workspaces.py b/msticpy/context/azure/sentinel_workspaces.py index a942ab612..d5d74f584 100644 --- a/msticpy/context/azure/sentinel_workspaces.py +++ b/msticpy/context/azure/sentinel_workspaces.py @@ -4,14 +4,19 @@ # license information. # -------------------------------------------------------------------------- """Mixin Class for Sentinel Workspaces.""" +from __future__ import annotations + +import logging import re -import urllib -from collections import namedtuple -from typing import Dict, Optional +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, ClassVar +from urllib import parse import httpx -import pandas as pd -from azure.mgmt.core import tools as az_tools +from msrestazure import tools as az_tools +from typing_extensions import Self + +from msticpy.context.azure.sentinel_utils import SentinelUtilsMixin from ..._version import VERSION from ...auth.azure_auth_core import AzureCloudConfig @@ -20,30 +25,49 @@ from ...common.utility import mp_ua_header from ...data.core.data_providers import QueryProvider +if TYPE_CHECKING: + import pandas as pd + +logger: logging.Logger = logging.getLogger(__name__) + __version__ = VERSION __author__ = "Ian Hellen" -ParsedUrlComponents = namedtuple( - "ParsedUrlComponents", - "domain, resource_id, tenant_name, res_components, raw_res_id", -) +@dataclass +class ParsedUrlComponents: + """Class to defined components for Parsed URLs.""" -class SentinelWorkspacesMixin: + domain: str | None + resource_id: str + tenant_name: str | None + res_components: dict[str, str] + raw_res_id: str + + +class SentinelWorkspacesMixin(SentinelUtilsMixin): """Mixin class for Sentinel workspaces.""" - _TENANT_URI = "{cloud_endpoint}/{tenant_name}/.well-known/openid-configuration" - _RES_GRAPH_PROV: Optional[QueryProvider] = None + _TENANT_URI: ClassVar[str] = ( + "{cloud_endpoint}/{tenant_name}/.well-known/openid-configuration" + ) + _RES_GRAPH_PROV: ClassVar[QueryProvider | None] = None @classmethod - def get_resource_id_from_url(cls, portal_url: str) -> str: + def get_resource_id_from_url( + cls: type[Self], + portal_url: str, + ) -> str | None: """Return resource ID components from Sentinel portal URL.""" - return cls._extract_resource_id(portal_url).resource_id + if (resource := cls._extract_resource_id(portal_url)) is not None: + return resource.resource_id + return None @classmethod def get_workspace_details_from_url( - cls, portal_url: str - ) -> Dict[str, Dict[str, str]]: + cls: type[Self], + portal_url: str, + ) -> dict[str, dict[str, str]]: """ Return workspace settings from portal URL. @@ -54,15 +78,20 @@ def get_workspace_details_from_url( Returns ------- - Dict[str, Dict[str, str]] + dict[str, dict[str, str]] """ - resource_comps = cls._extract_resource_id(portal_url) - tenant_id: Optional[str] = None + resource_comps: ParsedUrlComponents | None = cls._extract_resource_id( + portal_url, + ) + if not resource_comps: + err_msg: str = f"Cannot retrieve workspace details from {portal_url}" + raise ValueError(err_msg) + tenant_id: str | None = None if resource_comps.tenant_name: tenant_id = cls._get_tenantid_from_logon_domain(resource_comps.tenant_name) - workspace_df = cls._lookup_workspace_by_res_id( - resource_id=resource_comps.resource_id + workspace_df: pd.DataFrame = cls._lookup_workspace_by_res_id( + resource_id=resource_comps.resource_id, ) if df_has_data(workspace_df): return cls._get_settings_for_workspace( @@ -73,24 +102,24 @@ def get_workspace_details_from_url( resource_group=workspace_df.iloc[0].resourceGroup, workspace_tenant_id=workspace_df.iloc[0].tenantId, ) - print( - "Failed to find Azure resource for workspace", "Returning partial results." + logger.warning( + "Failed to find Azure resource for workspace. Returning partial results.", ) return cls._get_settings_for_workspace( - workspace_name=resource_comps.res_components.get("name"), + workspace_name=resource_comps.res_components["name"], workspace_id="unknown", tenant_id=tenant_id or "unknown", - subscription_id=resource_comps.res_components.get("subscription"), - resource_group=resource_comps.res_components.get("resource_group"), + subscription_id=resource_comps.res_components["subscription"], + resource_group=resource_comps.res_components["resource_group"], workspace_tenant_id="unknown", ) @classmethod def get_workspace_name( - cls, - workspace_id: Optional[str] = None, - resource_id: Optional[str] = None, - ) -> Optional[str]: + cls: type[Self], + workspace_id: str | None = None, + resource_id: str | None = None, + ) -> str | None: """ Return resolved name from workspace ID or resource ID. @@ -112,19 +141,20 @@ def get_workspace_name( If neither workspace_id or resource_id parameters are supplied. """ - settings = cls.get_workspace_settings( - workspace_id=workspace_id, resource_id=resource_id + settings: dict[str, Any] = cls.get_workspace_settings( + workspace_id=workspace_id, + resource_id=resource_id, ) return next(iter(settings.values())).get("WorkspaceName") if settings else None @classmethod def get_workspace_id( - cls, + cls: type[Self], workspace_name: str, subscription_id: str = "", resource_group: str = "", - ) -> Optional[str]: + ) -> str | None: """ Return the workspace ID given workspace name. @@ -143,17 +173,19 @@ def get_workspace_id( The ID of the workspace if found, else None """ - settings = cls.get_workspace_settings_by_name( - workspace_name, subscription_id, resource_group + settings: dict[str, Any] = cls.get_workspace_settings_by_name( + workspace_name, + subscription_id, + resource_group, ) return next(iter(settings.values())).get("WorkspaceId") if settings else None @classmethod def get_workspace_settings( - cls, - workspace_id: Optional[str] = None, - resource_id: Optional[str] = None, - ): + cls: type[Self], + workspace_id: str | None = None, + resource_id: str | None = None, + ) -> dict[str, Any]: """ Return resolved workspace settings from workspace ID or resource ID. @@ -166,7 +198,7 @@ def get_workspace_settings( Returns ------- - Dict[str, str] + dict[str, str] The workspace name, if found, else None Raises @@ -175,12 +207,13 @@ def get_workspace_settings( If neither workspace_id or resource_id parameters are supplied. """ - if not (workspace_id or resource_id): - raise ValueError("Either workspace_id or resource_id must be supplied.") if workspace_id: - results_df = cls._lookup_workspace_by_ws_id(workspace_id) + results_df: pd.DataFrame = cls._lookup_workspace_by_ws_id(workspace_id) else: - results_df = cls._lookup_workspace_by_res_id(resource_id) # type: ignore + if not resource_id: + err_msg: str = "Either workspace_id or resource_id must be supplied." + raise ValueError(err_msg) + results_df = cls._lookup_workspace_by_res_id(resource_id) if df_has_data(results_df): return cls._get_settings_for_workspace( workspace_name=results_df.iloc[0].workspaceName, @@ -194,11 +227,11 @@ def get_workspace_settings( @classmethod def get_workspace_settings_by_name( - cls, + cls: type[Self], workspace_name: str, subscription_id: str = "", resource_group: str = "", - ): + ) -> dict[str, Any]: """ Return the workspace ID given workspace name. @@ -217,14 +250,16 @@ def get_workspace_settings_by_name( The ID of the workspace if found, else None """ - results_df = cls._lookup_workspace_by_name( - workspace_name, subscription_id, resource_group + results_df: pd.DataFrame = cls._lookup_workspace_by_name( + workspace_name, + subscription_id, + resource_group, ) if df_has_data(results_df): if len(results_df) > 1: - print( - "Warning: query returned multiple results.", - "Specify subscription_id and/or resource_group", + logger.warning( + "Warning: query returned multiple results. " + "Specify subscription_id and/or resource_group " "for more accurate results.", ) return cls._get_settings_for_workspace( @@ -238,7 +273,9 @@ def get_workspace_settings_by_name( return {} @classmethod - def _get_resource_graph_provider(cls) -> QueryProvider: + def _get_resource_graph_provider( + cls: type[Self], + ) -> QueryProvider: if not cls._RES_GRAPH_PROV: cls._RES_GRAPH_PROV = QueryProvider("ResourceGraph") if not cls._RES_GRAPH_PROV.connected: @@ -247,12 +284,12 @@ def _get_resource_graph_provider(cls) -> QueryProvider: @classmethod def _lookup_workspace_by_name( - cls, + cls: type[Self], workspace_name: str, subscription_id: str = "", resource_group: str = "", ) -> pd.DataFrame: - res_graph_prov = cls._get_resource_graph_provider() + res_graph_prov: QueryProvider = cls._get_resource_graph_provider() return res_graph_prov.Sentinel.list_sentinel_workspaces_for_name( workspace_name=workspace_name, subscription_id=subscription_id, @@ -260,39 +297,48 @@ def _lookup_workspace_by_name( ) @classmethod - def _lookup_workspace_by_ws_id(cls, workspace_id: str) -> pd.DataFrame: - res_graph_prov = cls._get_resource_graph_provider() + def _lookup_workspace_by_ws_id( + cls: type[Self], + workspace_id: str, + ) -> pd.DataFrame: + res_graph_prov: QueryProvider = cls._get_resource_graph_provider() return res_graph_prov.Sentinel.get_sentinel_workspace_for_workspace_id( - workspace_id=workspace_id + workspace_id=workspace_id, ) @classmethod - def _lookup_workspace_by_res_id(cls, resource_id: str): - res_graph_prov = cls._get_resource_graph_provider() + def _lookup_workspace_by_res_id( + cls: type[Self], + resource_id: str | None, + ) -> pd.DataFrame: + res_graph_prov: QueryProvider = cls._get_resource_graph_provider() return res_graph_prov.Sentinel.get_sentinel_workspace_for_resource_id( - resource_id=resource_id + resource_id=resource_id, ) @classmethod - def _extract_resource_id(cls, url: str) -> ParsedUrlComponents: + def _extract_resource_id( + cls: type[Self], + url: str, + ) -> ParsedUrlComponents | None: """Extract and return resource ID components from URL.""" resid_pattern = ( r"https://(?P[^/]+)/#?(@(?P[^/]+))?" ".*(?P(%2F|/)subscriptions(%2F|/).*)" ) - uri_match = re.search(resid_pattern, url) + uri_match: re.Match[str] | None = re.search(resid_pattern, url) if not uri_match: - return ParsedUrlComponents(None, None, None, None, None) + return None - raw_res_id = uri_match.groupdict()["res_id"] - raw_res_id = urllib.parse.unquote(raw_res_id) - res_components = az_tools.parse_resource_id(raw_res_id) + raw_res_id: str = uri_match.groupdict()["res_id"] + raw_res_id = parse.unquote(raw_res_id) + res_components: dict[str, Any] = az_tools.parse_resource_id(raw_res_id) try: - resource_id = cls._normalize_resource_id(res_components) + resource_id: str = cls._normalize_resource_id(res_components) except KeyError: - print("Invalid Sentinel resource id") - return ParsedUrlComponents(None, None, None, None, None) + logger.exception("Invalid Sentinel resource id") + return None return ParsedUrlComponents( domain=uri_match.groupdict().get("domain"), resource_id=resource_id, @@ -302,7 +348,7 @@ def _extract_resource_id(cls, url: str) -> ParsedUrlComponents: ) @staticmethod - def _normalize_resource_id(res_components: Dict[str, str]) -> str: + def _normalize_resource_id(res_components: dict[str, str]) -> str: return ( f"/subscriptions/{res_components['subscription']}" f"/resourcegroups/{res_components['resource_group']}" @@ -312,34 +358,39 @@ def _normalize_resource_id(res_components: Dict[str, str]) -> str: @classmethod def _get_tenantid_from_logon_domain( - cls, domain, cloud: str = "global" - ) -> Optional[str]: + cls: type[Self], + domain: str, + cloud: str = "global", + ) -> str | None: """Get the tenant ID from login domain.""" az_cloud_config = AzureCloudConfig(cloud) - login_endpoint = az_cloud_config.authority_uri - t_resp = httpx.get( + login_endpoint: str = az_cloud_config.authority_uri + t_resp: httpx.Response = httpx.get( cls._TENANT_URI.format(cloud_endpoint=login_endpoint, tenant_name=domain), timeout=get_http_timeout(), headers=mp_ua_header(), ) - tenant_details = t_resp.json() + tenant_details: dict[str, Any] = t_resp.json() if not tenant_details: return None tenant_ep_rgx = r"(?Phttps://[^/]+)/(?P[^/]+).*" - match = re.search(tenant_ep_rgx, tenant_details.get("token_endpoint", "")) + match: re.Match[str] | None = re.search( + tenant_ep_rgx, + tenant_details.get("token_endpoint", ""), + ) return match.groupdict()["tenant_id"] if match else None @classmethod - def _get_settings_for_workspace( - cls, + def _get_settings_for_workspace( # pylint:disable=too-many-arguments # noqa:PLR0913 + cls: type[Self], workspace_name: str, workspace_id: str, tenant_id: str, subscription_id: str, resource_group: str, workspace_tenant_id: str, - ) -> Dict[str, Dict[str, str]]: + ) -> dict[str, dict[str, str]]: """Return settings dictionary for workspace settings.""" return { workspace_name: { @@ -349,5 +400,5 @@ def _get_settings_for_workspace( "ResourceGroup": resource_group, "WorkspaceName": workspace_name, "WorkspaceTenantId": workspace_tenant_id, - } + }, } diff --git a/msticpy/context/contextlookup.py b/msticpy/context/contextlookup.py index d5096cfba..f197fbbfa 100644 --- a/msticpy/context/contextlookup.py +++ b/msticpy/context/contextlookup.py @@ -14,9 +14,9 @@ """ from __future__ import annotations -from typing import ClassVar, Iterable, Mapping +from typing import TYPE_CHECKING, ClassVar, Iterable, Mapping -import pandas as pd +from typing_extensions import Self from .._version import VERSION from ..common.utility import export @@ -26,6 +26,8 @@ from .lookup import Lookup from .provider_base import Provider, _make_sync +if TYPE_CHECKING: + import pandas as pd __version__ = VERSION __author__ = "Ian Hellen" @@ -51,15 +53,15 @@ class ContextLookup(Lookup): PROVIDERS: ClassVar[dict[str, tuple[str, str]]] = CONTEXT_PROVIDERS CUSTOM_PROVIDERS: ClassVar[dict[str, type[Provider]]] = {} - # pylint: disable=too-many-arguments - def lookup_observable( - self, + def lookup_observable( # pylint:disable=too-many-arguments # noqa:PLR0913 + self: Self, observable: str, observable_type: str | None = None, query_type: str | None = None, providers: list[str] | None = None, default_providers: list[str] | None = None, prov_scope: str = "primary", + *, show_not_supported: bool = False, ) -> pd.DataFrame: """ @@ -81,6 +83,8 @@ def lookup_observable( `providers` is specified, it will override this parameter. prov_scope : str, optional Use "primary", "secondary" or "all" providers, by default "primary" + show_not_supported: bool, optional + Include the not supported observables in the result DF. Defaults to False. Returns ------- @@ -100,8 +104,8 @@ def lookup_observable( show_not_supported=show_not_supported, ) - def lookup_observables( # pylint:disable=too-many-arguments - self, + def lookup_observables( # pylint:disable=too-many-arguments # noqa:PLR0913 + self: Self, data: pd.DataFrame | Mapping[str, str] | Iterable[str], obs_col: str | None = None, obs_type_col: str | None = None, @@ -155,8 +159,8 @@ def lookup_observables( # pylint:disable=too-many-arguments ) # pylint: disable=too-many-locals - async def _lookup_observables_async( # pylint:disable=too-many-arguments - self, + async def _lookup_observables_async( # pylint:disable=too-many-arguments # noqa:PLR0913 + self: Self, data: pd.DataFrame | Mapping[str, str] | Iterable[str], obs_col: str | None = None, obs_type_col: str | None = None, @@ -176,8 +180,8 @@ async def _lookup_observables_async( # pylint:disable=too-many-arguments prov_scope=prov_scope, ) - def lookup_observables_sync( # pylint:disable=too-many-arguments - self, + def lookup_observables_sync( # pylint:disable=too-many-arguments # noqa:PLR0913 + self: Self, data: pd.DataFrame | Mapping[str, str] | Iterable[str], obs_col: str | None = None, obs_type_col: str | None = None, @@ -229,7 +233,7 @@ def lookup_observables_sync( # pylint:disable=too-many-arguments ) def _load_providers( - self, + self: Self, *, providers: str = "ContextProviders", ) -> None: diff --git a/msticpy/context/contextproviders/context_provider_base.py b/msticpy/context/contextproviders/context_provider_base.py index bd5ba6c0c..4b6f9b1bf 100644 --- a/msticpy/context/contextproviders/context_provider_base.py +++ b/msticpy/context/contextproviders/context_provider_base.py @@ -298,7 +298,7 @@ def lookup_observables( async def lookup_observables_async( self: Self, - data: pd.DataFrame | dict[str, str] | Iterable[str], + data: pd.DataFrame | dict[str, str] | list[str], obs_col: str | None = None, obs_type_col: str | None = None, query_type: str | None = None, @@ -313,7 +313,7 @@ async def lookup_observables_async( async def _lookup_observables_async_wrapper( self: Self, - data: pd.DataFrame | dict[str, str] | Iterable[str], + data: pd.DataFrame | dict[str, str] | list[str], obs_col: str | None = None, obs_type_col: str | None = None, query_type: str | None = None, diff --git a/msticpy/context/contextproviders/http_context_provider.py b/msticpy/context/contextproviders/http_context_provider.py index 90d09b9f9..7efb9f86c 100644 --- a/msticpy/context/contextproviders/http_context_provider.py +++ b/msticpy/context/contextproviders/http_context_provider.py @@ -140,7 +140,7 @@ def _run_context_lookup_query( return result @lru_cache(maxsize=256) - def lookup_observable( + def lookup_observable( # noqa:PLR0913 self: Self, observable: str, observable_type: str | None = None, diff --git a/msticpy/context/contextproviders/servicenow.py b/msticpy/context/contextproviders/servicenow.py index 99b82d386..9d9becb57 100644 --- a/msticpy/context/contextproviders/servicenow.py +++ b/msticpy/context/contextproviders/servicenow.py @@ -15,9 +15,9 @@ from __future__ import annotations import datetime as dt +from dataclasses import dataclass from typing import Any, ClassVar -import attr from typing_extensions import Self from ..._version import VERSION @@ -38,7 +38,7 @@ # pylint: disable=too-few-public-methods -@attr.s +@dataclass class _ServiceNowParams(APILookupParams): # override LookupParams to set common defaults def __attrs_post_init__(self: Self) -> None: diff --git a/msticpy/context/domain_utils.py b/msticpy/context/domain_utils.py index eaab9a8ea..c4b90bccb 100644 --- a/msticpy/context/domain_utils.py +++ b/msticpy/context/domain_utils.py @@ -12,25 +12,28 @@ """ from __future__ import annotations +import datetime as dt import json +import logging import ssl import time from dataclasses import asdict -from datetime import datetime from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable from urllib.error import HTTPError, URLError import httpx import pandas as pd import tldextract from cryptography import x509 -from cryptography.hazmat.primitives.hashes import SHA1 -from cryptography.x509 import Certificate + +# CodeQL [SM02167] Compatibility requirement for SSL abuse list +from cryptography.hazmat.primitives.hashes import SHA1 # CodeQL [SM02167] Compatibility from dns.exception import DNSException from dns.resolver import Resolver from IPython import display from ipywidgets import IntProgress +from typing_extensions import Self from urllib3.exceptions import LocationParseError from urllib3.util import parse_url @@ -40,13 +43,22 @@ from ..common.utility import export, mp_ua_header if TYPE_CHECKING: + from cryptography.x509 import Certificate from dns.resolver import Answer + from tldextract.tldextract import ExtractResult __version__ = VERSION __author__ = "Pete Bryan" +logger: logging.Logger = logging.getLogger(__name__) @export -def screenshot(url: str, api_key: str | None = None) -> httpx.Response: +def screenshot( # pylint: disable=too-many-locals + url: str, + api_key: str | None = None, + *, + sleep: float = 0.05, + max_progress: int = 100, +) -> httpx.Response: """ Get a screenshot of a url with Browshot. @@ -56,6 +68,10 @@ def screenshot(url: str, api_key: str | None = None) -> httpx.Response: The url a screenshot is wanted for. api_key : str (optional) Browshot API key. If not set msticpyconfig checked for this. + sleep: int (optional) + Time to sleep between calls. Defaults to 0.05 seconds + max_progress: int (optional) + Set the maximum value for the progress bar. Defaults to 100. Returns ------- @@ -65,65 +81,76 @@ def screenshot(url: str, api_key: str | None = None) -> httpx.Response: """ # Get Browshot API key from kwargs or config if api_key is not None: - bs_api_key: Optional[str] = api_key + bs_api_key: str | None = api_key else: bs_conf: dict[str, Any] = get_config( - "DataProviders.Browshot", {} - ) or get_config("Browshot", {}) + "DataProviders.Browshot", + {}, + ) or get_config( + "Browshot", + {}, + ) bs_api_key = None if bs_conf is not None: - bs_api_key = bs_conf.get("Args", {}).get("AuthKey") # type: ignore + bs_api_key = bs_conf.get("Args", {}).get("AuthKey") if bs_api_key is None: + err_msg: str = ( + "No configuration found for Browshot\n" + "Please add a section to msticpyconfig.yaml:\n" + "DataProviders:\n" + " Browshot:\n" + " Args:\n" + " AuthKey: {your_auth_key}" + ) raise MsticpyUserConfigError( - "No configuration found for Browshot", - "Please add a section to msticpyconfig.yaml:", - "DataProviders:", - " Browshot:", - " Args:", - " AuthKey: {your_auth_key}", + err_msg, title="Browshot configuration not found", browshot_uri=("Get an API key for Browshot", "https://api.browshot.com/"), ) # Request screenshot from Browshot and get request ID - id_string = ( + id_string: str = ( f"https://api.browshot.com/api/v1/screenshot/create?url={url}/" f"&instance_id=26&size=screen&cache=0&key={bs_api_key}" ) - id_data = httpx.get(id_string, timeout=get_http_timeout(), headers=mp_ua_header()) - bs_id = json.loads(id_data.content)["id"] - status_string = ( + id_data: httpx.Response = httpx.get( + id_string, + timeout=get_http_timeout(), + headers=mp_ua_header(), + ) + bs_id: str = json.loads(id_data.content)["id"] + status_string: str = ( f"https://api.browshot.com/api/v1/screenshot/info?id={bs_id}&key={bs_api_key}" ) - image_string = ( + image_string: str = ( f"https://api.browshot.com/api/v1/screenshot/thumbnail?id={bs_id}" f"&zoom=50&key={bs_api_key}" ) # Wait until the screenshot is ready and keep user updated with progress - print("Getting screenshot") - progress = IntProgress(min=0, max=100) + logger.info("Getting screenshot") + progress = IntProgress(min=0, max=max_progress) display.display(progress) ready = False - while not ready and progress.value < 100: + while not ready and progress.value < max_progress: progress.value += 1 - status_data = httpx.get( + status_data: httpx.Response = httpx.get( status_string, timeout=get_http_timeout(), headers=mp_ua_header(), ) - status = json.loads(status_data.content)["status"] + status: str = json.loads(status_data.content)["status"] if status == "finished": ready = True else: - time.sleep(0.05) - progress.value = 100 + time.sleep(sleep) + progress.value = max_progress # Once ready or timed out get the screenshot - image_data = httpx.get(image_string, timeout=get_http_timeout()) + image_data: httpx.Response = httpx.get(image_string, timeout=get_http_timeout()) - if image_data.status_code != 200: - print( + if not image_data.is_success: + logger.warning( "There was a problem with the request, please check the status code for details", ) @@ -147,13 +174,13 @@ class DomainValidator: _ssl_abuse_list: pd.DataFrame = pd.DataFrame() @classmethod - def _check_and_load_abuselist(cls): + def _check_and_load_abuselist(cls: type[Self]) -> None: """Pull IANA TLD list and save to internal attribute.""" if cls._ssl_abuse_list is None or cls._ssl_abuse_list.empty: cls._ssl_abuse_list = cls._get_ssl_abuselist() @property - def ssl_abuse_list(self) -> pd.DataFrame: + def ssl_abuse_list(self: Self) -> pd.DataFrame: """ Return the class SSL Blacklist. @@ -182,7 +209,7 @@ def validate_tld(url_domain: str) -> bool: True if valid public TLD, False if not. """ - extract_result = tldextract.extract(url_domain.lower()) + extract_result: ExtractResult = tldextract.extract(url_domain.lower()) return bool(extract_result.suffix) @staticmethod @@ -203,11 +230,11 @@ def is_resolvable(url_domain: str) -> bool: """ try: _dns_resolve(url_domain, "A") - return True except DNSException: return False + return True - def in_abuse_list(self, url_domain: str) -> Tuple[bool, Optional[Certificate]]: + def in_abuse_list(self: Self, url_domain: str) -> tuple[bool, Certificate | None]: """ Validate if a domain or URL's SSL cert the abuse.ch SSL Abuse List. @@ -228,7 +255,9 @@ def in_abuse_list(self, url_domain: str) -> Tuple[bool, Optional[Certificate]]: x509_cert: Certificate = x509.load_pem_x509_certificate( cert.encode("ascii"), ) - cert_sha1: bytes = x509_cert.fingerprint(SHA1()) + cert_sha1: bytes = x509_cert.fingerprint( + SHA1() + ) # noqa: S303 # CodeQL [SM02167] Compatibility requirement for SSL abuse list result = bool( self.ssl_abuse_list["SHA1"].str.contains(cert_sha1.hex()).any(), ) @@ -237,10 +266,10 @@ def in_abuse_list(self, url_domain: str) -> Tuple[bool, Optional[Certificate]]: return result, x509_cert @classmethod - def _get_ssl_abuselist(cls) -> pd.DataFrame: + def _get_ssl_abuselist(cls: type[Self]) -> pd.DataFrame: """Download and load abuse.ch SSL Abuse List.""" try: - ssl_ab_list = pd.read_csv( + ssl_ab_list: pd.DataFrame = pd.read_csv( "https://sslbl.abuse.ch/blacklist/sslblacklist.csv", skiprows=8, ) @@ -265,13 +294,13 @@ def dns_components(domain: str) -> dict: Returns subdomain and TLD components from a domain. """ - result = tldextract.extract(domain.lower()) - if isinstance(result, tuple): - return result._asdict() # type: ignore + result: ExtractResult = tldextract.extract(domain.lower()) + if isinstance(result, tuple) and hasattr(result, "_asdict"): + return result._asdict() return asdict(result) -def url_components(url: str) -> Dict[str, str]: +def url_components(url: str) -> dict[str, str]: """Return parsed Url components as dict.""" try: return parse_url(url)._asdict() @@ -280,7 +309,7 @@ def url_components(url: str) -> Dict[str, str]: @export -def dns_resolve(url_domain: str, rec_type: str = "A") -> Dict[str, Any]: +def dns_resolve(url_domain: str, rec_type: str = "A") -> dict[str, Any]: """ Validate if a domain or URL be be resolved to an IP address. @@ -337,7 +366,7 @@ def dns_resolve_df(url_domain: str, rec_type: str = "A") -> pd.DataFrame: @export -def ip_rev_resolve(ip_address: str) -> Dict[str, Any]: +def ip_rev_resolve(ip_address: str) -> dict[str, Any]: """ Reverse lookup for IP Address. @@ -386,20 +415,20 @@ def ip_rev_resolve_df(ip_address: str) -> pd.DataFrame: @export -def _resolve_resp_to_dict(resolver_resp): +def _resolve_resp_to_dict(resolver_resp: Answer) -> dict[str, Any]: """Return Dns Python resolver response to dict.""" - rdtype = ( + rdtype: str = ( resolver_resp.rdtype.name if isinstance(resolver_resp.rdtype, Enum) else str(resolver_resp.rdtype) ) - rdclass = ( + rdclass: str = ( resolver_resp.rdclass.name if isinstance(resolver_resp.rdclass, Enum) else str(resolver_resp.rdclass) ) - return { + result: dict[str, Any] = { "qname": str(resolver_resp.qname), "rdtype": rdtype, "rdclass": rdclass, @@ -407,6 +436,11 @@ def _resolve_resp_to_dict(resolver_resp): "nameserver": getattr(resolver_resp, "nameserver", None), "port": getattr(resolver_resp, "port", None), "canonical_name": str(resolver_resp.canonical_name), - "rrset": [str(res) for res in resolver_resp.rrset], - "expiration": datetime.utcfromtimestamp(resolver_resp.expiration), + "expiration": dt.datetime.fromtimestamp( + resolver_resp.expiration, + tz=dt.timezone.utc, + ), } + if resolver_resp.rrset: + result["rrset"] = [str(res) for res in resolver_resp.rrset] + return result diff --git a/msticpy/context/geoip.py b/msticpy/context/geoip.py index b4420184e..b7bd37277 100644 --- a/msticpy/context/geoip.py +++ b/msticpy/context/geoip.py @@ -19,9 +19,12 @@ an online lookup (API key required). """ +from __future__ import annotations + import contextlib +import logging import math -import random +import secrets import tarfile import warnings from abc import ABCMeta, abstractmethod @@ -30,7 +33,7 @@ from json import JSONDecodeError from pathlib import Path from time import sleep -from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple +from typing import Any, ClassVar, Iterable, Mapping import geoip2.database import httpx @@ -38,6 +41,7 @@ from geoip2.errors import AddressNotFoundError from IPython.core.display import HTML from IPython.display import display +from typing_extensions import Self from .._version import VERSION from ..common.exceptions import MsticpyUserConfigError @@ -50,6 +54,10 @@ __version__ = VERSION __author__ = "Ian Hellen" +logger: logging.Logger = logging.getLogger(__name__) + +# pylint:disable=too-many-lines + class GeoIPDatabaseError(Exception): """Exception when GeoIP database cannot be found.""" @@ -66,16 +74,16 @@ class GeoIpLookup(metaclass=ABCMeta): """ - _LICENSE_TXT: Optional[str] = None - _LICENSE_HTML: Optional[str] = None + _LICENSE_TXT: ClassVar[str] + _LICENSE_HTML: ClassVar[str] @abstractmethod def lookup_ip( - self, - ip_address: str = None, - ip_addr_list: Iterable = None, - ip_entity: IpAddress = None, - ) -> Tuple[List[Any], List[IpAddress]]: + self: Self, + ip_address: str | None = None, + ip_addr_list: Iterable | None = None, + ip_entity: IpAddress | None = None, + ) -> tuple[list[Any], list[IpAddress]]: """ Lookup IP location abstract method. @@ -91,13 +99,17 @@ def lookup_ip( Returns ------- - Tuple[List[Any], List[IpAddress]]: + tuple[list[Any], list[IpAddress]]: raw geolocation results and same results as IpAddress entities with populated Location property. """ - def df_lookup_ip(self, data: pd.DataFrame, column: str) -> pd.DataFrame: + def df_lookup_ip( + self: Self, + data: pd.DataFrame, + column: str, + ) -> pd.DataFrame: """ Lookup Geolocation data from a pandas Dataframe. @@ -122,7 +134,11 @@ def df_lookup_ip(self, data: pd.DataFrame, column: str) -> pd.DataFrame: right_on="IpAddress", ) - def lookup_ips(self, data: pd.DataFrame, column: str) -> pd.DataFrame: + def lookup_ips( + self: Self, + data: pd.DataFrame, + column: str, + ) -> pd.DataFrame: """ Lookup Geolocation data from a pandas Dataframe. @@ -142,7 +158,7 @@ def lookup_ips(self, data: pd.DataFrame, column: str) -> pd.DataFrame: ip_list = list(data[column].values) _, entities = self.lookup_ip(ip_addr_list=ip_list) - ip_dicts = [ + ip_dicts: list[dict] = [ {**ent.Location.properties, "IpAddress": ent.Address} for ent in entities if ent.Location is not None @@ -150,28 +166,33 @@ def lookup_ips(self, data: pd.DataFrame, column: str) -> pd.DataFrame: return pd.DataFrame(data=ip_dicts) @staticmethod - def _ip_params_to_list(ip_address, ip_addr_list, ip_entity) -> List[str]: + def _ip_params_to_list( + ip_address: str | Iterable | IpAddress | None = None, + ip_addr_list: list[str] | Iterable | None = None, + ip_entity: IpAddress | None = None, + ) -> list[str]: """Try to convert different parameter formats to list.""" if ip_address is not None: # check if ip_address just used as positional arg. if isinstance(ip_address, str): return [ip_address.strip()] if isinstance(ip_address, abc.Iterable): - return [str(ip).strip() for ip in ip_addr_list] + return [str(ip).strip() for ip in ip_address] if isinstance(ip_address, IpAddress): - return [ip_entity.Address] + return [ip_address.Address] if ip_addr_list is not None and isinstance(ip_addr_list, abc.Iterable): return [str(ip).strip() for ip in ip_addr_list] if ip_entity: return [ip_entity.Address] - raise ValueError("No valid ip addresses were passed as arguments.") + err_msg: str = "No valid ip addresses were passed as arguments." + raise ValueError(err_msg) - def print_license(self): + def print_license(self: Self) -> None: """Print license information for providers.""" if self._LICENSE_HTML and is_ipython(notebook=True): display(HTML(self._LICENSE_HTML)) elif self._LICENSE_TXT: - print(self._LICENSE_TXT) + logger.info(self._LICENSE_TXT) @export @@ -186,14 +207,20 @@ class IPStackLookup(GeoIpLookup): """ - _LICENSE_HTML = """ + _LICENSE_HTML: ClassVar[ + str + ] = """ This library uses services provided by ipstack. https://ipstack.com""" - _LICENSE_TXT = """ + _LICENSE_TXT: ClassVar[ + str + ] = """ This library uses services provided by ipstack (https://ipstack.com)""" - _IPSTACK_API = "http://api.ipstack.com/{iplist}?access_key={access_key}&output=json" + _IPSTACK_API: ClassVar[str] = ( + "http://api.ipstack.com/{iplist}?access_key={access_key}&output=json" + ) _NO_API_KEY_MSSG = """ No API Key was found to access the IPStack service. @@ -208,7 +235,12 @@ class IPStackLookup(GeoIpLookup): >>> iplookup = IPStackLookup(api_key="your_api_key") """ - def __init__(self, api_key: Optional[str] = None, bulk_lookup: bool = False): + def __init__( + self: IPStackLookup, + api_key: str | None = None, + *, + bulk_lookup: bool = False, + ) -> None: """ Create a new instance of IPStackLookup. @@ -224,11 +256,11 @@ def __init__(self, api_key: Optional[str] = None, bulk_lookup: bool = False): per address) """ - self.settings: Optional[ProviderSettings] = None - self._api_key: Optional[str] = api_key - self.bulk_lookup = bulk_lookup + self.settings: ProviderSettings | None = None + self._api_key: str | None = api_key + self.bulk_lookup: bool = bulk_lookup - def _check_initialized(self): + def _check_initialized(self: Self) -> bool: """Return True if valid API key available.""" if self._api_key: return True @@ -241,18 +273,18 @@ def _check_initialized(self): self._NO_API_KEY_MSSG, help_uri=( "https://msticpy.readthedocs.io/en/latest/data_acquisition/" - + "GeoIPLookups.html#ipstack-geo-lookup-class" + "GeoIPLookups.html#ipstack-geo-lookup-class" ), service_uri="https://ipstack.com/product", title="IPStack API key not found", ) def lookup_ip( - self, - ip_address: str = None, - ip_addr_list: Iterable = None, + self: Self, + ip_address: str | None = None, + ip_addr_list: Iterable | None = None, ip_entity: IpAddress = None, - ) -> Tuple[List[Any], List[IpAddress]]: + ) -> tuple[list[Any], list[IpAddress]]: """ Lookup IP location from IPStack web service. @@ -268,7 +300,7 @@ def lookup_ip( Returns ------- - Tuple[List[Any], List[IpAddress]]: + tuple[list[Any], list[IpAddress]]: raw geolocation results and same results as IpAddress entities with populated Location property. @@ -282,23 +314,27 @@ def lookup_ip( """ self._check_initialized() - ip_list = self._ip_params_to_list(ip_address, ip_addr_list, ip_entity) + ip_list: list[str] = self._ip_params_to_list( + ip_address, + ip_addr_list, + ip_entity, + ) - results = self._submit_request(ip_list) - output_raw = [] - output_entities = [] + results: list[tuple[dict[str, str] | None, int]] = self._submit_request(ip_list) + output_raw: list[tuple[dict[str, Any] | None, int]] = [] + output_entities: list[IpAddress] = [] for ip_loc, status in results: - if status == 200 and "error" not in ip_loc: + if status == httpx.codes.OK and ip_loc and "error" not in ip_loc: output_entities.append(self._create_ip_entity(ip_loc, ip_entity)) output_raw.append((ip_loc, status)) return output_raw, output_entities @staticmethod - def _create_ip_entity(ip_loc: dict, ip_entity) -> IpAddress: + def _create_ip_entity(ip_loc: dict, ip_entity: IpAddress | None) -> IpAddress: if not ip_entity: ip_entity = IpAddress() ip_entity.Address = ip_loc["ip"] - geo_entity = GeoLocation() + geo_entity: GeoLocation = GeoLocation() geo_entity.CountryCode = ip_loc["country_code"] geo_entity.CountryOrRegionName = ip_loc["country_name"] @@ -311,62 +347,69 @@ def _create_ip_entity(ip_loc: dict, ip_entity) -> IpAddress: ip_entity.Location = geo_entity return ip_entity - def _submit_request(self, ip_list: List[str]) -> List[Tuple[Dict[str, str], int]]: + def _submit_request( + self: Self, + ip_list: list[str], + ) -> list[tuple[dict[str, str] | None, int]]: """ Submit the request to IPStack. Parameters ---------- - ip_list : List[str] + ip_list : list[str] String list of IPs to look up Returns ------- - List[Tuple[str, int]] + list[tuple[str, int]] List of response, status code pairs """ if not self.bulk_lookup: return self._lookup_ip_list(ip_list) - submit_url = self._IPSTACK_API.format( + submit_url: str = self._IPSTACK_API.format( iplist=",".join(ip_list), access_key=self._api_key, ) - response = httpx.get( + response: httpx.Response = httpx.get( submit_url, timeout=get_http_timeout(), headers=mp_ua_header(), ) - if response.status_code == 200: - results = response.json() + if response.is_success: + results: dict[str, Any] = response.json() # {"success":false,"error":{"code":303,"type":"batch_not_supported_on_plan", # "info":"Bulk requests are not supported on your plan. # Please upgrade your subscription."}} if "success" in results and not results["success"]: - raise PermissionError( - f"Service unable to complete request. Error: {results['error']}", + err_msg: str = ( + f"Service unable to complete request. Error: {results['error']}" ) - return [(item, response.status_code) for item in results] + raise PermissionError(err_msg) + return [(item, response.status_code) for item in results.values()] if response: with contextlib.suppress(JSONDecodeError): return [(response.json(), response.status_code)] return [({}, response.status_code)] - def _lookup_ip_list(self, ip_list: List[str]): + def _lookup_ip_list( + self: Self, + ip_list: list[str], + ) -> list[tuple[dict[str, str] | None, int]]: """Lookup IP Addresses one-by-one.""" - ip_loc_results = [] + ip_loc_results: list[tuple[dict | None, int]] = [] with httpx.Client(timeout=get_http_timeout(), headers=mp_ua_header()) as client: for ip_addr in ip_list: - submit_url = self._IPSTACK_API.format( + submit_url: str = self._IPSTACK_API.format( iplist=ip_addr, access_key=self._api_key, ) - response = client.get(submit_url) - if response.status_code == 200: + response: httpx.Response = client.get(submit_url) + if response.is_success: ip_loc_results.append((response.json(), response.status_code)) elif response: try: @@ -375,7 +418,7 @@ def _lookup_ip_list(self, ip_list: List[str]): except JSONDecodeError: ip_loc_results.append((None, response.status_code)) else: - print("Unknown response from IPStack request.") + logger.warning("Unknown response from IPStack request.") ip_loc_results.append((None, -1)) return ip_loc_results @@ -393,26 +436,34 @@ class GeoLiteLookup(GeoIpLookup): """ - _MAXMIND_DOWNLOAD = ( + _MAXMIND_DOWNLOAD: ClassVar[str] = ( "https://download.maxmind.com/app/geoip_download?" - + "edition_id=GeoLite2-City&license_key={license_key}&suffix=tar.gz" + "edition_id=GeoLite2-City&license_key={license_key}&suffix=tar.gz" ) - _DB_HOME = str(Path.joinpath(Path("~").expanduser(), ".msticpy", "GeoLite2")) - _DB_ARCHIVE = "GeoLite2-City.mmdb.{rand}.tar.gz" - _DB_FILE = "GeoLite2-City.mmdb" + _DB_HOME: ClassVar[str] = str( + Path.joinpath(Path("~").expanduser(), ".msticpy", "GeoLite2"), + ) + _DB_ARCHIVE: ClassVar[str] = "GeoLite2-City.mmdb.{rand}.tar.gz" + _DB_FILE: ClassVar[str] = "GeoLite2-City.mmdb" - _LICENSE_HTML = """ + _LICENSE_HTML: ClassVar[ + str + ] = """ This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com. """ - _LICENSE_TXT = """ + _LICENSE_TXT: ClassVar[ + str + ] = """ This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com. """ - _NO_API_KEY_MSSG = """ + _NO_API_KEY_MSSG: ClassVar[ + str + ] = """ No API Key was found to download the Maxmind GeoIPLite database. If you do not have an account, go here to create one and obtain and API key. https://www.maxmind.com/en/geolite2/signup @@ -422,16 +473,17 @@ class GeoLiteLookup(GeoIpLookup): Alternatively, you can pass this to the GeoLiteLookup class when creating it: >>> iplookup = GeoLiteLookup(api_key="your_api_key") """ - _UNSET_PATH = "~~UNSET~~" + _UNSET_PATH: ClassVar[str] = "~~UNSET~~" - def __init__( - self, - api_key: Optional[str] = None, - db_folder: Optional[str] = None, + def __init__( # noqa: PLR0913 + self: GeoLiteLookup, + api_key: str | None = None, + db_folder: str | None = None, + *, force_update: bool = False, auto_update: bool = True, debug: bool = False, - ): + ) -> None: r""" Return new instance of GeoLiteLookup class. @@ -462,30 +514,35 @@ def __init__( """ self._debug = debug if self._debug: - self._debug_init_state(api_key, db_folder, force_update, auto_update) - self.settings: Optional[ProviderSettings] = None - self._api_key: Optional[str] = api_key or None + self._debug_init_state( + api_key, + db_folder, + force_update=force_update, + auto_update=auto_update, + ) + self.settings: ProviderSettings | None = None + self._api_key: str | None = api_key or None self._db_folder: str = db_folder or self._UNSET_PATH self._force_update = force_update self._auto_update = auto_update - self._db_path: Optional[str] = None - self._reader: Any = None + self._db_path: str | None = None + self._reader: geoip2.database.Reader | None = None - def close(self): + def close(self: Self) -> None: """Close an open GeoIP DB.""" if self._reader: try: self._reader.close() - except Exception as err: # pylint: disable=broad-except - print(f"Exception when trying to close GeoIP DB {err}") + except Exception: # pylint: disable=broad-except + logger.exception("Exception when trying to close GeoIP DB") def lookup_ip( - self, - ip_address: str = None, - ip_addr_list: Iterable = None, + self: Self, + ip_address: str | None = None, + ip_addr_list: Iterable | None = None, ip_entity: IpAddress = None, - ) -> Tuple[List[Any], List[IpAddress]]: + ) -> tuple[list[dict[str, Any]], list[IpAddress]]: """ Lookup IP location from GeoLite2 data created by MaxMind. @@ -501,39 +558,43 @@ def lookup_ip( Returns ------- - Tuple[List[Any], List[IpAddress]] + tuple[list[Any], list[IpAddress]] raw geolocation results and same results as IpAddress entities with populated Location property. """ self._check_initialized() - ip_list = self._ip_params_to_list(ip_address, ip_addr_list, ip_entity) + ip_list: list[str] = self._ip_params_to_list( + ip_address, + ip_addr_list, + ip_entity, + ) - output_raw = [] - output_entities = [] + output_raw: list[dict[str, Any]] = [] + output_entities: list[IpAddress] = [] for ip_input in ip_list: - geo_match = None + geo_match: dict[str, Any] | None = None try: - ip_type = get_ip_type(ip_input) + ip_type: str = get_ip_type(ip_input) except ValueError: ip_type = "Invalid IP Address" if ip_type != "Public": geo_match = self._get_geomatch_non_public(ip_type) - else: + elif self._reader: try: geo_match = self._reader.city(ip_input).raw except (AddressNotFoundError, AttributeError, ValueError): continue - if geo_match: - output_raw.append(geo_match) - output_entities.append( - self._create_ip_entity(ip_input, geo_match, ip_entity), - ) + if geo_match: + output_raw.append(geo_match) + output_entities.append( + self._create_ip_entity(ip_input, geo_match, ip_entity), + ) return output_raw, output_entities @staticmethod - def _get_geomatch_non_public(ip_type): + def _get_geomatch_non_public(ip_type: str) -> dict[str, Any]: """Return placeholder record for non-public IP Types.""" return { "country": { @@ -547,17 +608,17 @@ def _get_geomatch_non_public(ip_type): def _create_ip_entity( ip_address: str, geo_match: Mapping[str, Any], - ip_entity: IpAddress = None, + ip_entity: IpAddress | None = None, ) -> IpAddress: if not ip_entity: ip_entity = IpAddress() ip_entity.Address = ip_address - geo_entity = GeoLocation() + geo_entity: GeoLocation = GeoLocation() geo_entity.CountryCode = geo_match.get("country", {}).get("iso_code", None) geo_entity.CountryOrRegionName = ( geo_match.get("country", {}).get("names", {}).get("en", None) ) - subdivs = geo_match.get("subdivisions", []) + subdivs: list[dict[str, Any]] = geo_match.get("subdivisions", []) if subdivs: geo_entity.State = subdivs[0].get("names", {}).get("en", None) geo_entity.City = geo_match.get("city", {}).get("names", {}).get("en", None) @@ -566,7 +627,7 @@ def _create_ip_entity( ip_entity.Location = geo_entity return ip_entity - def _check_initialized(self): + def _check_initialized(self: Self) -> None: """Check if DB reader open with a valid database.""" if self._reader and self.settings: return @@ -574,22 +635,23 @@ def _check_initialized(self): self.settings = _get_geoip_provider_settings("GeoIPLite") self._api_key = self._api_key or self.settings.args.get("AuthKey") - self._db_folder: str = ( + self._db_folder = ( self._db_folder if self._db_folder != self._UNSET_PATH - else self.settings.args.get("DBFolder", self._DB_HOME) # type: ignore + else self.settings.args.get("DBFolder", self._DB_HOME) ) - self._db_folder = str(Path(self._db_folder).expanduser()) # type: ignore + self._db_folder = str(Path(self._db_folder).expanduser()) self._check_and_update_db() self._db_path = self._get_geoip_db_path() if self._debug: self._debug_open_state() - if not self._db_path: + if self._db_path is None: self._raise_no_db_error() - self._reader = geoip2.database.Reader(self._db_path) + else: + self._reader = geoip2.database.Reader(self._db_path) - def _check_and_update_db(self): + def _check_and_update_db(self: Self) -> None: """ Check the age of geo ip database file and download if it older than 30 days. @@ -597,13 +659,14 @@ def _check_and_update_db(self): override auto-download behavior. """ - geoip_db_path = self._get_geoip_db_path() + geoip_db_path: str | None = self._get_geoip_db_path() self._pr_debug(f"Checking geoip DB {geoip_db_path}") self._pr_debug(f"Download URL is {self._MAXMIND_DOWNLOAD}") if geoip_db_path is None: - print( - "No local Maxmind City Database found. ", - f"Attempting to downloading new database to {self._db_folder}", + logger.info( + "No local Maxmind City Database found. " + "Attempting to downloading new database to %s", + self._db_folder, ) self._download_and_extract_archive() else: @@ -616,20 +679,22 @@ def _check_and_update_db(self): ) # Check for out of date DB file according to db_age - db_age = datetime.now(timezone.utc) - last_mod_time + db_age: timedelta = datetime.now(timezone.utc) - last_mod_time db_updated = True if db_age > timedelta(30) and self._auto_update: - print( - "Latest local Maxmind City Database present is older than 30 days.", - f"Attempting to download new database to {self._db_folder}", + logger.info( + "Latest local Maxmind City Database present is older than 30 days." + "Attempting to download new database to %s", + self._db_folder, ) if not self._download_and_extract_archive(): self._geolite_warn("DB download failed") db_updated = False elif self._force_update: - print( - "force_update is set to True.", - f"Attempting to download new database to {self._db_folder}", + logger.info( + "force_update is set to True. " + "Attempting to download new database to %s", + self._db_folder, ) if not self._download_and_extract_archive(): self._geolite_warn("DB download failed") @@ -639,7 +704,7 @@ def _check_and_update_db(self): "Continuing with cached database. Results may inaccurate.", ) - def _download_and_extract_archive(self) -> bool: + def _download_and_extract_archive(self: Self) -> bool: """ Download file from the given URL and extract if it is archive. @@ -651,14 +716,14 @@ def _download_and_extract_archive(self) -> bool: """ if not self._api_key: return False - url = self._MAXMIND_DOWNLOAD.format(license_key=self._api_key) + url: str = self._MAXMIND_DOWNLOAD.format(license_key=self._api_key) if not Path(self._db_folder).exists(): # using makedirs to create intermediate-level dirs to contain self._dbfolder Path(self._db_folder).mkdir(exist_ok=True, parents=True) # build a temp file name for the archive download - rand_int = random.randint(10000, 99999) # nosec - db_archive_path = Path(self._db_folder).joinpath( + rand_int: int = secrets.choice(range(10000, 99999)) + db_archive_path: Path = Path(self._db_folder).joinpath( self._DB_ARCHIVE.format(rand=rand_int), ) self._pr_debug(f"Downloading GeoLite DB: {db_archive_path}") @@ -676,8 +741,10 @@ def _download_and_extract_archive(self) -> bool: timeout=get_http_timeout(), headers=mp_ua_header(), ) as response: - print("Downloading and extracting GeoLite DB archive from MaxMind....") - with open(db_archive_path, "wb") as file_hdl: + logger.info( + "Downloading and extracting GeoLite DB archive from MaxMind....", + ) + with db_archive_path.open(mode="wb", encoding="utf-8") as file_hdl: for chunk in response.iter_bytes(chunk_size=10000): file_hdl.write(chunk) file_hdl.flush() @@ -696,16 +763,11 @@ def _download_and_extract_archive(self) -> bool: # no exceptions so extract the archive contents try: self._extract_to_folder(db_archive_path) - print( - "Extraction complete. Local Maxmind city DB:", - f"{db_archive_path}", - ) - return True except PermissionError as err: self._geolite_warn( f"Cannot overwrite GeoIP DB file: {db_archive_path}." - + " The file may be in use or you do not have" - + f" permission to overwrite.\n - {err}", + " The file may be in use or you do not have" + f" permission to overwrite.\n - {err}", ) except Exception as err: # pylint: disable=broad-except # There are several exception types that might come from @@ -713,15 +775,21 @@ def _download_and_extract_archive(self) -> bool: self._geolite_warn( f"Error writing GeoIP DB file: {db_archive_path} - {err}", ) + else: + logger.info( + "Extraction complete. Local Maxmind city DB: %s", + db_archive_path, + ) + return True finally: if db_archive_path.is_file(): self._pr_debug(f"Removing temp file {db_archive_path}") db_archive_path.unlink() return False - def _extract_to_folder(self, db_archive_path: Path): + def _extract_to_folder(self: Self, db_archive_path: Path) -> None: self._pr_debug(f"Extracting tarfile {db_archive_path}") - temp_folder: Optional[Path] = None + temp_folder: Path | None = None with tarfile.open(db_archive_path) as tar_archive: for member in tar_archive.getmembers(): if not member.isreg(): @@ -731,7 +799,7 @@ def _extract_to_folder(self, db_archive_path: Path): tar_archive.extract(member, self._db_folder) # The files are extracted to a subfolder (with a date in the name) # We want to move these into the main folder above this. - targetname = Path(member.name).name + targetname: str = Path(member.name).name if targetname != member.name: # if target name is not already in self._dbfolder # move it to there @@ -748,7 +816,7 @@ def _extract_to_folder(self, db_archive_path: Path): self._pr_debug(f"Removing temp path {temp_folder}") temp_folder.rmdir() - def _get_geoip_db_path(self) -> Optional[str]: + def _get_geoip_db_path(self: Self) -> str | None: """ Get the correct path containing GeoLite City Database. @@ -759,48 +827,46 @@ def _get_geoip_db_path(self) -> Optional[str]: database after control flow logic. """ - latest_db_path = Path(self._db_folder or ".").joinpath(self._DB_FILE) + latest_db_path: Path = Path(self._db_folder or ".").joinpath(self._DB_FILE) return str(latest_db_path) if latest_db_path.is_file() else None - def _pr_debug(self, *args): + def _pr_debug(self: Self, *args: str) -> None: """Print out debug info.""" if self._debug: - print(*args) + logger.debug(*args) - def _geolite_warn(self, mssg: str): - self._pr_debug(mssg) + def _geolite_warn(self: Self, msg: str) -> None: + self._pr_debug(msg) warnings.warn( - f"GeoIpLookup: {mssg}", + f"GeoIpLookup: {msg}", UserWarning, + stacklevel=1, ) - def _raise_no_db_error(self): + def _raise_no_db_error(self: Self) -> None: + err_msg: str = ( + "No usable GeoIP Database could be found.\n" + "Check that you have correctly configured the Maxmind API key in " + "msticpyconfig.yaml.\n" + "If you are using a custom DBFolder setting in your config, " + f"check that this is a valid path: {self._db_folder}.\n" + "If you edit your msticpyconfig to change this setting run the " + "following commands to reload your settings and retry:" + " import msticpy" + " msticpy.settings.refresh_config()" + ) raise MsticpyUserConfigError( - "No usable GeoIP Database could be found.", - ( - "Check that you have correctly configured the Maxmind API key in " - "msticpyconfig.yaml." - ), - ( - "If you are using a custom DBFolder setting in your config, " - + f"check that this is a valid path: {self._db_folder}." - ), - ( - "If you edit your msticpyconfig to change this setting run the " - "following commands to reload your settings and retry:" - " import msticpy" - " msticpy.settings.refresh_config()" - ), + err_msg, help_uri=( "https://msticpy.readthedocs.io/en/latest/data_acquisition/" - + "GeoIPLookups.html#maxmind-geo-ip-lite-lookup-class" + "GeoIPLookups.html#maxmind-geo-ip-lite-lookup-class" ), service_uri="https://www.maxmind.com/en/geolite2/signup", title="Maxmind GeoIP database not found", ) - def _debug_open_state(self): - dbg_api_key = ( + def _debug_open_state(self: Self) -> None: + dbg_api_key: str = ( "None" if self._api_key is None else self._api_key[:4] + "*" * (len(self._api_key) - 4) @@ -812,8 +878,15 @@ def _debug_open_state(self): self._pr_debug(f" dbpath={self._db_path}") self._pr_debug(f"Using config file: {current_config_path()}") - def _debug_init_state(self, api_key, db_folder, force_update, auto_update): - dbg_api_key = ( + def _debug_init_state( + self: Self, + api_key: str | None, + db_folder: str | None, + *, + force_update: bool, + auto_update: bool, + ) -> None: + dbg_api_key: str = ( "None" if api_key is None else api_key[:4] + "*" * (len(api_key) - 4) ) self._pr_debug(f"__init__ params: api_key={dbg_api_key}") @@ -837,10 +910,12 @@ def _get_geoip_provider_settings(provider_name: str) -> ProviderSettings: Settings for the provider. """ - settings = get_provider_settings(config_section="OtherProviders") + settings: dict[str, ProviderSettings] = get_provider_settings( + config_section="OtherProviders", + ) if provider_name in settings: return settings[provider_name] - return ProviderSettings( # type: ignore[call-arg] + return ProviderSettings( name=provider_name, description="Not found.", ) @@ -870,9 +945,10 @@ def entity_distance(ip_src: IpAddress, ip_dest: IpAddress) -> float: """ if not ip_src.Location or not ip_dest.Location: - raise AttributeError( - "Source and destination entities must have defined Location properties.", + err_msg: str = ( + "Source and destination entities must have defined Location properties." ) + raise AttributeError(err_msg) return geo_distance( origin=(ip_src.Location.Latitude, ip_src.Location.Longitude), @@ -885,17 +961,17 @@ def entity_distance(ip_src: IpAddress, ip_dest: IpAddress) -> float: @export def geo_distance( - origin: Tuple[float, float], - destination: Tuple[float, float], + origin: tuple[float, float], + destination: tuple[float, float], ) -> float: """ Calculate the Haversine distance. Parameters ---------- - origin : Tuple[float, float] + origin : tuple[float, float] Latitude, Longitude of origin of distance measurement. - destination : Tuple[float, float] + destination : tuple[float, float] Latitude, Longitude of origin of distance measurement. Returns diff --git a/msticpy/context/http_provider.py b/msticpy/context/http_provider.py index 0cbc62546..81e5663de 100644 --- a/msticpy/context/http_provider.py +++ b/msticpy/context/http_provider.py @@ -16,11 +16,11 @@ import traceback from abc import abstractmethod +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar -import attr import httpx -from attr import Factory +from typing_extensions import Self from .._version import VERSION from ..common.exceptions import MsticpyConfigError @@ -36,20 +36,19 @@ __author__ = "Ian Hellen" -# pylint: disable=too-few-public-methods -@attr.s(auto_attribs=True) +@dataclass class APILookupParams: """HTTP Lookup Params definition.""" - path: str = "" - verb: str = "GET" - full_url: bool = False - headers: dict[str, str] = Factory(dict) - params: dict[str, str | float] = Factory(dict) - data: dict[str, str] = Factory(dict) - auth_type: str = "" - auth_str: list[str] = Factory(list) - sub_type: str = "" + path: str = field(default="") + verb: str = field(default="GET") + full_url: bool = field(default=False) + headers: dict[str, str] = field(default_factory=dict) + params: dict[str, str | float] = field(default_factory=dict) + data: dict[str, str] = field(default_factory=dict) + auth_type: str = field(default="") + auth_str: list[str] = field(default_factory=list) + sub_type: str = field(default="") class HttpProvider(Provider): @@ -129,7 +128,7 @@ class HttpProvider(Provider): _REQUIRED_PARAMS: ClassVar[list[str]] = [] def __init__( - self, + self: HttpProvider, *, timeout: int | None = None, ApiID: str | None = None, # noqa: N803 @@ -169,7 +168,7 @@ def __init__( @abstractmethod def lookup_item( - self, + self: Self, item: str, item_type: str | None = None, query_type: str | None = None, @@ -213,7 +212,7 @@ def lookup_item( # pylint: enable=duplicate-code def _substitute_parms( - self, + self: Self, value: str, value_type: str, query_type: str | None = None, diff --git a/msticpy/context/ip_utils.py b/msticpy/context/ip_utils.py index dd8d636cf..cc821d9b6 100644 --- a/msticpy/context/ip_utils.py +++ b/msticpy/context/ip_utils.py @@ -12,29 +12,36 @@ Designed to support any data source containing IP address entity. """ +from __future__ import annotations + import ipaddress +import logging import re import socket import warnings +from dataclasses import dataclass, field from functools import lru_cache from time import sleep -from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Union +from typing import Any, Callable import httpx import pandas as pd from bs4 import BeautifulSoup from deprecated.sphinx import deprecated +from typing_extensions import Self from .._version import VERSION from ..common.exceptions import MsticpyConnectionError, MsticpyException from ..common.utility import arg_to_list, export from ..datamodel.entities import GeoLocation, IpAddress +logger: logging.Logger = logging.getLogger(__name__) + __version__ = VERSION __author__ = "Ashwin Patil" -_REGISTRIES = { +_REGISTRIES: dict[str, dict[str, str]] = { "arin": { "url": "http://rdap.arin.net/registry/ip/", }, @@ -54,22 +61,23 @@ _POTAROO_ASNS_URL = "https://bgp.potaroo.net/cidr/autnums.html" +RATE_LIMIT_THRESHOLD: int = 50 + # Closure to cache ASN dictionary from Potaroo -def _fetch_asns() -> Callable[[], Dict[str, str]]: +def _fetch_asns() -> Callable[[], dict[str, str]]: """Create closure for ASN fetching.""" - asns_dict: Dict[str, str] = {} + asns_dict: dict[str, str] = {} - def _get_asns_dict() -> Dict[str, str]: + def _get_asns_dict() -> dict[str, str]: """Return or fetch and return ASN Soup.""" nonlocal asns_dict if not asns_dict: try: - asns_resp = httpx.get(_POTAROO_ASNS_URL) + asns_resp: httpx.Response = httpx.get(_POTAROO_ASNS_URL) except httpx.ConnectError as err: - raise MsticpyConnectionError( - "Unable to get ASN details from potaroo.net", - ) from err + err_msg: str = "Unable to get ASN details from potaroo.net" + raise MsticpyConnectionError(err_msg) from err asns_soup = BeautifulSoup(asns_resp.content, features="lxml") asns_dict = { str(asn.next_element) @@ -83,16 +91,17 @@ def _get_asns_dict() -> Dict[str, str]: # Create the dictionary accessor from the fetch_asns wrapper -_ASNS_DICT = _fetch_asns() +_ASNS_DICT: Callable[[], dict[str, str]] = _fetch_asns() @export def convert_to_ip_entities( - ip_str: Optional[str] = None, - data: Optional[pd.DataFrame] = None, - ip_col: Optional[str] = None, + ip_str: str | None = None, + data: pd.DataFrame | None = None, + ip_col: str | None = None, + *, geo_lookup: bool = True, -) -> List[IpAddress]: +) -> list[IpAddress]: """ Take in an IP Address string and converts it to an IP Entity. @@ -124,21 +133,22 @@ def convert_to_ip_entities( # pylint: disable=import-outside-toplevel, cyclic-import from .geoip import GeoLiteLookup - geo_lite_lookup = GeoLiteLookup() + geo_lite_lookup: GeoLiteLookup = GeoLiteLookup() - ip_entities: List[IpAddress] = [] - all_ips: Set[str] = set() + ip_entities: list[IpAddress] = [] + all_ips: set[str] = set() if ip_str: - addrs = arg_to_list(ip_str) + addrs: list[str] = arg_to_list(ip_str) elif data is not None and ip_col: - addrs = data[ip_col].values + addrs = data[ip_col].to_numpy().tolist() else: - raise ValueError("Must specify either ip_str or data + ip_col parameters.") + err_msg: str = "Must specify either ip_str or data + ip_col parameters." + raise ValueError(err_msg) for addr in addrs: if isinstance(addr, list): - ip_list = set(addr) + ip_list: set[str] = set(addr) elif isinstance(addr, str) and "," in addr: ip_list = {ip.strip() for ip in addr.split(",")} else: @@ -156,7 +166,7 @@ def convert_to_ip_entities( @export def create_ip_record( heartbeat_df: pd.DataFrame, - az_net_df: pd.DataFrame = None, + az_net_df: pd.DataFrame | None = None, ) -> IpAddress: """ Generate ip_entity record for provided IP value. @@ -174,35 +184,33 @@ def create_ip_record( Details of the IP data collected """ - ip_entity = IpAddress() + ip_entity: IpAddress = IpAddress() # Produce ip_entity record using available dataframes - ip_hb = heartbeat_df.iloc[0] + ip_hb: pd.Series[str] = heartbeat_df.iloc[0] ip_entity.Address = ip_hb["ComputerIP"] - ip_entity.hostname = ip_hb["Computer"] # type: ignore - ip_entity.SourceComputerId = ip_hb["SourceComputerId"] # type: ignore - ip_entity.OSType = ip_hb["OSType"] # type: ignore - ip_entity.OSName = ip_hb["OSName"] # type: ignore - ip_entity.OSVMajorVersion = ip_hb["OSMajorVersion"] # type: ignore - ip_entity.OSVMinorVersion = ip_hb["OSMinorVersion"] # type: ignore - ip_entity.ComputerEnvironment = ip_hb["ComputerEnvironment"] # type: ignore - ip_entity.OmsSolutions = [ # type: ignore - sol.strip() for sol in ip_hb["Solutions"].split(",") - ] - ip_entity.VMUUID = ip_hb["VMUUID"] # type: ignore - ip_entity.SubscriptionId = ip_hb["SubscriptionId"] # type: ignore - geoloc_entity = GeoLocation() # type: ignore - geoloc_entity.CountryOrRegionName = ip_hb["RemoteIPCountry"] # type: ignore - geoloc_entity.Longitude = ip_hb["RemoteIPLongitude"] # type: ignore - geoloc_entity.Latitude = ip_hb["RemoteIPLatitude"] # type: ignore - ip_entity.Location = geoloc_entity # type: ignore + ip_entity.hostname = ip_hb["Computer"] + ip_entity.SourceComputerId = ip_hb["SourceComputerId"] + ip_entity.OSType = ip_hb["OSType"] + ip_entity.OSName = ip_hb["OSName"] + ip_entity.OSVMajorVersion = ip_hb["OSMajorVersion"] + ip_entity.OSVMinorVersion = ip_hb["OSMinorVersion"] + ip_entity.ComputerEnvironment = ip_hb["ComputerEnvironment"] + ip_entity.OmsSolutions = [sol.strip() for sol in ip_hb["Solutions"].split(",")] + ip_entity.VMUUID = ip_hb["VMUUID"] + ip_entity.SubscriptionId = ip_hb["SubscriptionId"] + geoloc_entity: GeoLocation = GeoLocation() + geoloc_entity.CountryOrRegionName = ip_hb["RemoteIPCountry"] + geoloc_entity.Longitude = ip_hb["RemoteIPLongitude"] + geoloc_entity.Latitude = ip_hb["RemoteIPLatitude"] + ip_entity.Location = geoloc_entity # If Azure network data present add this to host record if az_net_df is not None and not az_net_df.empty: if len(az_net_df) == 1: - priv_addr_str = az_net_df["PrivateIPAddresses"].loc[0] + priv_addr_str: str = az_net_df["PrivateIPAddresses"].loc[0] ip_entity["private_ips"] = convert_to_ip_entities(priv_addr_str) - pub_addr_str = az_net_df["PublicIPAddresses"].loc[0] + pub_addr_str: str = az_net_df["PublicIPAddresses"].loc[0] ip_entity["public_ips"] = convert_to_ip_entities(pub_addr_str) else: if "private_ips" not in ip_entity: @@ -214,8 +222,7 @@ def create_ip_record( @export -# pylint: disable=too-many-return-statements, invalid-name -def get_ip_type(ip: str = None, ip_str: str = None) -> str: +def get_ip_type(ip: str | None = None, ip_str: str | None = None) -> str: """ Validate value is an IP address and determine IPType category. @@ -236,42 +243,41 @@ def get_ip_type(ip: str = None, ip_str: str = None) -> str: """ ip_str = ip or ip_str if not ip_str: - raise ValueError("'ip' or 'ip_str' value must be specified") + err_msg: str = "'ip' or 'ip_str' value must be specified" + raise ValueError(err_msg) try: - ipaddress.ip_address(ip_str) + ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address = ipaddress.ip_address( + ip_str, + ) except ValueError: - print(f"{ip_str} does not appear to be an IPv4 or IPv6 address") + logger.exception("%s does not appear to be an IPv4 or IPv6 address", ip_str) else: - if ipaddress.ip_address(ip_str).is_multicast: - return "Multicast" - if ipaddress.ip_address(ip_str).is_global: - return "Public" - if ipaddress.ip_address(ip_str).is_loopback: - return "Loopback" - if ipaddress.ip_address(ip_str).is_link_local: - return "Link Local" - if ipaddress.ip_address(ip_str).is_unspecified: - return "Unspecified" - if ipaddress.ip_address(ip_str).is_private: - return "Private" - if ipaddress.ip_address(ip_str).is_reserved: - return "Reserved" + return_values: dict[str, str] = { + "is_multicast": "Multicast", + "is_global": "Public", + "is_loopback": "Loopback", + "is_link_local": "Link Local", + "is_unspecified": "Unspecified", + "is_private": "Private", + "is_reserved": "Reserved", + } + for func, msg in return_values.items(): + if getattr(ip_obj, func): + return msg + return "Unspecified" return "Unspecified" -# pylint: enable=too-many-return-statements - - -# pylint: disable=invalid-name @deprecated("Replaced with ip_whois function", version="2.1.0") @export @lru_cache(maxsize=1024) def get_whois_info( - ip: str = None, + ip: str | None = None, + *, show_progress: bool = False, - **kwargs, -) -> Tuple[str, dict]: + ip_str: str | None = None, +) -> pd.DataFrame | _IpWhoIsResult: """ Retrieve whois ASN information for given IP address using IPWhois python package. @@ -296,32 +302,37 @@ def get_whois_info( IP addresses. """ - ip_str = ip or kwargs.get("ip_str") + ip_str = ip or ip_str if not ip_str: - raise ValueError("'ip' or 'ip_str' value must be specified") - ip_type = get_ip_type(ip_str) + err_msg: str = "'ip' or 'ip_str' value must be specified" + raise ValueError(err_msg) + ip_type: str = get_ip_type(ip_str) if ip_type == "Public": + logger.info(ip_str) try: - print(ip_str) - whois_result = ip_whois(ip_str) - if show_progress: - print(".", end="") - return whois_result # type: ignore + whois_result: pd.DataFrame | _IpWhoIsResult = ip_whois(ip_str) except MsticpyException as err: - return f"Error during lookup of {ip_str} {type(err)}", {} - return f"No ASN Information for IP type: {ip_type}", {} - - -# pylint: enable=invalid-name + return _IpWhoIsResult( + name=f"Error during lookup of {ip_str} {type(err)}", + properties={}, + ) + if show_progress: + logger.info(".") + return whois_result + return _IpWhoIsResult( + name=f"No ASN Information for IP type: {ip_type}", + properties={}, + ) @export -def get_whois_df( +def get_whois_df( # noqa: PLR0913 data: pd.DataFrame, ip_column: str, + *, all_columns: bool = True, asn_col: str = "AsnDescription", - whois_col: Optional[str] = "WhoIsData", + whois_col: str = "WhoIsData", show_progress: bool = False, ) -> pd.DataFrame: """ @@ -353,14 +364,16 @@ def get_whois_df( """ del show_progress - whois_data = ip_whois(data[ip_column].drop_duplicates()) + whois_data: pd.DataFrame | _IpWhoIsResult = ip_whois( + data[ip_column].drop_duplicates(), + ) if ( isinstance(whois_data, pd.DataFrame) and not whois_data.empty and "query" in whois_data.columns ): data = data.merge( - whois_data, # type: ignore + whois_data, how="left", left_on=ip_column, right_on="query", @@ -379,11 +392,18 @@ def get_whois_df( class IpWhoisAccessor: """Pandas api extension for IP Whois lookup.""" - def __init__(self, pandas_obj): + def __init__(self: IpWhoisAccessor, pandas_obj: pd.DataFrame) -> None: """Instantiate pandas extension class.""" - self._df = pandas_obj - - def lookup(self, ip_column, **kwargs): + self._df: pd.DataFrame = pandas_obj + + def lookup( + self: Self, + ip_column: str, + *, + asn_col: str = "ASNDescription", + whois_col: str = "WhoIsData", + show_progress: bool = False, + ) -> pd.DataFrame: """ Extract IoCs from either a pandas DataFrame. @@ -391,9 +411,6 @@ def lookup(self, ip_column, **kwargs): ---------- ip_column : str Column name of IP Address to look up. - - Other Parameters - ---------------- asn_col : str, optional Name of the output column for ASN description, by default "ASNDescription" @@ -414,19 +431,28 @@ def lookup(self, ip_column, **kwargs): "Please use IpAddress.util.whois() pivot function." "This will be removed in MSTICPy v2.2.0" ) - warnings.warn(warn_message, category=DeprecationWarning) - return get_whois_df(data=self._df, ip_column=ip_column, **kwargs) + warnings.warn( + warn_message, + category=DeprecationWarning, + stacklevel=1, + ) + return get_whois_df( + data=self._df, + ip_column=ip_column, + asn_col=asn_col, + whois_col=whois_col, + show_progress=show_progress, + ) -# pylint: disable=inconsistent-return-statements, invalid-name def ip_whois( - ip: Union[IpAddress, str, List, pd.Series, None] = None, - ip_address: Union[IpAddress, str, List, pd.Series, None] = None, - raw=False, + ip: IpAddress | str | list | pd.Series | None = None, + ip_address: IpAddress | str | list[str] | pd.Series | None = None, + *, + raw: bool = False, query_rate: float = 0.5, retry_count: int = 5, -) -> Union[pd.DataFrame, Tuple]: - # sourcery skip: assign-if-exp, reintroduce-else +) -> pd.DataFrame | _IpWhoIsResult: """ Lookup IP Whois information. @@ -457,16 +483,17 @@ def ip_whois( """ ip = ip if ip is not None else ip_address if ip is None: - raise ValueError("One of ip or ip_address parameters must be supplied.") + err_msg: str = "One of ip or ip_address parameters must be supplied." + raise ValueError(err_msg) if isinstance(ip, (list, pd.Series)): - rate_limit = len(ip) > 50 + rate_limit: bool = len(ip) > RATE_LIMIT_THRESHOLD if rate_limit: - print("Large number of lookups, this may take some time.") - whois_results: Dict[str, Any] = {} + logger.info("Large number of lookups, this may take some time.") + whois_results: dict[str, Any] = {} for ip_addr in ip: if rate_limit: sleep(query_rate) - whois_results[ip_addr] = _whois_lookup( # type: ignore + whois_results[ip_addr] = _whois_lookup( ip_addr, raw=raw, retry_count=retry_count, @@ -477,7 +504,7 @@ def ip_whois( return pd.DataFrame() -def get_asn_details(asns: Union[str, List]) -> Union[pd.DataFrame, Dict]: +def get_asn_details(asns: str | list[str]) -> pd.DataFrame | dict[str, Any]: """ Get details about an ASN(s) from its number. @@ -494,12 +521,14 @@ def get_asn_details(asns: Union[str, List]) -> Union[pd.DataFrame, Dict]: """ if isinstance(asns, list): - asn_detail_results = [_asn_results(str(asn)) for asn in asns] + asn_detail_results: list[dict[str, Any]] = [ + _asn_results(str(asn)) for asn in asns + ] return pd.DataFrame(asn_detail_results) return _asn_results(str(asns)) -def get_asn_from_name(name: str) -> Dict: +def get_asn_from_name(name: str) -> dict[str, Any]: """ Get a list of ASNs that match a name. @@ -522,22 +551,23 @@ def get_asn_from_name(name: str) -> Dict: """ name = name.casefold() - asns_dict = _ASNS_DICT() - matches = { + asns_dict: dict[str, str] = _ASNS_DICT() + matches: dict[str, str] = { key: value for key, value in asns_dict.items() if name in value.casefold() } if len(matches.keys()) == 1: - return next(iter(matches)) # type: ignore + return next(iter(matches)) # type:ignore[arg-type] if len(matches.keys()) > 1: return matches - raise MsticpyException(f"No ASNs found matching {name}") + err_msg: str = f"No ASNs found matching {name}" + raise MsticpyException(err_msg) def get_asn_from_ip( - ip: Union[str, IpAddress, None] = None, - ip_address: Union[str, IpAddress, None] = None, -) -> Dict: + ip: str | IpAddress | None = None, + ip_address: str | IpAddress | None = None, +) -> dict[str, Any]: """ Get the ASN that an IP belongs to. @@ -554,39 +584,41 @@ def get_asn_from_ip( Details of the ASN that the IP belongs to. """ - ip = ip or ip_address - if not ip: + ip_param: str | IpAddress | None = ip or ip_address + if not ip_param: return {} - if isinstance(ip, IpAddress): - ip = ip.Address - ip = ip.strip() - query = f" -v {ip}\r\n" - ip_response = _cymru_query(query) - keys = ip_response.split("\n", maxsplit=1)[0].split("|") - values = ip_response.split("\n")[1].split("|") + if isinstance(ip_param, IpAddress): + ip_param = ip_param.Address + ip_str: str = ip_param.strip() + query: str = f" -v {ip_str}\r\n" + ip_response: str = _cymru_query(query) + keys: list[str] = ip_response.split("\n", maxsplit=1)[0].split("|") + values: list[str] = ip_response.split("\n")[1].split("|") return {key.strip(): value.strip() for key, value in zip(keys, values)} -class _IpWhoIsResult(NamedTuple): +@dataclass +class _IpWhoIsResult: """Named tuple for IPWhoIs Result.""" - name: Optional[str] - properties: Dict[str, Any] = {} + name: str | None = None + properties: dict[str, Any] = field(default_factory=dict) @lru_cache(maxsize=1024) def _whois_lookup( - ip_addr: Union[str, IpAddress], + ip_addr: str | IpAddress, + *, raw: bool = False, - retry_count: int = 5, # type: ignore + retry_count: int = 5, ) -> _IpWhoIsResult: """Conduct lookup of IP Whois information.""" - if isinstance(ip_addr, IpAddress): # type: ignore + if isinstance(ip_addr, IpAddress): ip_addr = ip_addr.Address - asn_items = get_asn_from_ip(ip_addr.strip()) - registry_url: Optional[str] = None + asn_items: dict[str, Any] = get_asn_from_ip(ip_addr.strip()) + registry_url: str | None = None if asn_items and "Error: no ASN or IP match on line 1." not in asn_items: - ipwhois_result = _IpWhoIsResult(asn_items["AS Name"], {}) # type: ignore + ipwhois_result: _IpWhoIsResult = _IpWhoIsResult(asn_items["AS Name"], {}) ipwhois_result.properties["asn"] = asn_items["AS"] ipwhois_result.properties["query"] = asn_items["IP"] ipwhois_result.properties["asn_cidr"] = asn_items["BGP Prefix"] @@ -598,7 +630,7 @@ def _whois_lookup( if not asn_items or not registry_url: return _IpWhoIsResult(None) return _add_rdap_data( - ipwhois_result=ipwhois_result, # type: ignore + ipwhois_result=ipwhois_result, rdap_reg_url=f"{registry_url}{ip_addr}", retry_count=retry_count, raw=raw, @@ -609,92 +641,107 @@ def _add_rdap_data( ipwhois_result: _IpWhoIsResult, rdap_reg_url: str, retry_count: int, + *, raw: bool, ) -> _IpWhoIsResult: """Add RDAP data to WhoIs result.""" retries = 0 while retries < retry_count: - rdap_data = _rdap_lookup(url=rdap_reg_url, retry_count=retry_count) - if rdap_data.status_code == 200: - rdap_data_content = rdap_data.json() - net = _create_net(rdap_data_content) + rdap_data: httpx.Response = _rdap_lookup( + url=rdap_reg_url, + retry_count=retry_count, + ) + if rdap_data.is_success: + rdap_data_content: dict[str, Any] = rdap_data.json() + net: dict[str, Any] = _create_net(rdap_data_content) ipwhois_result.properties["nets"] = [net] for link in rdap_data_content["links"]: if link["rel"] == "up": - up_data_link = link["href"] - up_rdap_data = httpx.get(up_data_link) - up_rdap_data_content = up_rdap_data.json() - up_net = _create_net(up_rdap_data_content) + up_data_link: str = link["href"] + up_rdap_data: httpx.Response = httpx.get(up_data_link) + up_rdap_data_content: dict[str, Any] = up_rdap_data.json() + up_net: dict[str, Any] = _create_net(up_rdap_data_content) ipwhois_result.properties["nets"].append(up_net) if raw: ipwhois_result.properties["raw"] = rdap_data break - if rdap_data.status_code == 429: + if rdap_data.status_code == httpx.codes.TOO_MANY_REQUESTS: sleep(3) retries += 1 continue - raise MsticpyConnectionError(f"Error code: {rdap_data.status_code}") + err_msg: str = f"Error code: {rdap_data.status_code}" + raise MsticpyConnectionError(err_msg) return ipwhois_result def _rdap_lookup(url: str, retry_count: int = 5) -> httpx.Response: """Perform RDAP lookup with retries.""" - rdap_data = None + rdap_data: httpx.Response | None = None while retry_count > 0 and not rdap_data: - try: - rdap_data = httpx.get(url) - except (httpx.WriteError, httpx.ReadError): - retry_count -= 1 + rdap_data = _run_rdap_query(url) + retry_count -= 1 if not rdap_data: - raise MsticpyException( - "Rate limit exceeded - try adjusting query_rate parameter to slow down requests", + err_msg: str = ( + "Rate limit exceeded - try adjusting query_rate parameter " + "to slow down requests" ) + raise MsticpyException(err_msg) return rdap_data -def _whois_result_to_pandas(results: Union[str, List, Dict]) -> pd.DataFrame: +def _run_rdap_query(url: str) -> httpx.Response | None: + """Execute rdap query call and handle errors.""" + try: + return httpx.get(url) + except (httpx.WriteError, httpx.ReadError): + return None + + +def _whois_result_to_pandas(results: str | list[str] | dict[str, Any]) -> pd.DataFrame: """Transform whois results to a Pandas DataFrame.""" if isinstance(results, dict): return pd.DataFrame( [result or {"query": idx} for idx, result in results.items()], ) - raise NotImplementedError("Only dict type current supported for `results`.") + err_msg: str = "Only dict type current supported for `results`." + raise NotImplementedError(err_msg) def _find_address( entity: dict, -) -> Union[str, None]: # pylint: disable=inconsistent-return-statements +) -> str | None: """Find an orgs address from an RDAP entity.""" if "vcardArray" not in entity: return None for vcard in [vcard for vcard in entity["vcardArray"] if isinstance(vcard, list)]: for vcard_sub in vcard: - if len(vcard) >= 2 and vcard_sub[0] == "adr" and "label" in vcard_sub[1]: + if vcard_sub[0] == "adr" and "label" in vcard_sub[1]: return vcard_sub[1]["label"] return None -def _create_net(data: Dict) -> Dict: +def _create_net(data: dict[str, Any]) -> dict[str, Any]: """Create a network object from RDAP data.""" - net_data = data.get("cidr0_cidrs", [None])[0] or {} - net_prefixes = net_data.keys() & {"v4prefix", "v6prefix"} + net_data: dict[str, Any] = data.get("cidr0_cidrs", [None])[0] or {} + net_prefixes: set[str] = net_data.keys() & {"v4prefix", "v6prefix"} if not net_data or not net_prefixes: - net_cidr = "No network data retrieved." + net_cidr: str = "No network data retrieved." else: net_cidr = " ".join( f"{net_data[net_prefix]}/{net_data.get('length', '')}" for net_prefix in net_prefixes ) - address = "" - created = updated = None + address: str | None = None + created: str | None = None + updated: str | None = None for item in data["events"]: created = item["eventDate"] if item["eventAction"] == "last changed" else None updated = item["eventDate"] if item["eventAction"] == "registration" else None for entity in data["entities"]: - address = _find_address(entity) # type: ignore + address = _find_address(entity) regex = r"[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*" - emails = re.findall(regex, str(data)) + emails: list[str] = re.findall(regex, str(data)) return { "cidr": net_cidr, "handle": data["handle"], @@ -708,47 +755,59 @@ def _create_net(data: Dict) -> Dict: } -def _asn_whois_query(query, server, port=43, retry_count=5) -> str: +def _asn_whois_query( + query: str, + server: str, + port: int = 43, + retry_count: int = 5, +) -> str: """Connect to whois server and send query.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as conn: conn.connect((server, port)) conn.send(query.encode()) - response = [] - response_data = None + response: list[str] = [] + response_data: str | None = None while retry_count > 0 and not response_data: - try: - response_data = conn.recv(4096).decode() - if "error" in response_data: - raise MsticpyConnectionError( - "An error occurred during lookup, please try again.", - ) - if "rate limit exceeded" in response_data: - raise MsticpyConnectionError( - "Rate limit exceeded please wait and try again.", - ) - response.append(response_data) - except (UnicodeDecodeError, ConnectionResetError): - retry_count -= 1 - response_data = None + response_data = _run_asn_query(conn, response) + retry_count -= 1 return "".join(response) -def _cymru_query(query): +def _run_asn_query( + conn: socket.socket, + response: list[str], +) -> str | None: + """Execute asn query call and handle errors.""" + try: + response_data: str = conn.recv(4096).decode() + except (UnicodeDecodeError, ConnectionResetError): + return None + if "error" in response_data: + err_msg: str = "An error occurred during lookup, please try again." + raise MsticpyConnectionError(err_msg) + if "rate limit exceeded" in response_data: + err_msg = "Rate limit exceeded please wait and try again." + raise MsticpyConnectionError(err_msg) + response.append(response_data) + return response_data + + +def _cymru_query(query: str) -> str: """Query Cymru for ASN information.""" return _asn_whois_query(query, "whois.cymru.com") -def _radb_query(query): +def _radb_query(query: str) -> str: """Query RADB for ASN information.""" return _asn_whois_query(query, "whois.radb.net") -def _parse_asn_response(response) -> dict: +def _parse_asn_response(response: str) -> dict[str, Any]: """Parse ASN response into a dictionary.""" - response_output = {} + response_output: dict[str, Any] = {} for item in response.split("\n"): try: - key = item.split(":")[0].strip() + key: str = item.split(":")[0].strip() if key and key not in response_output: try: response_output[key] = item.split(":")[1].strip() @@ -763,24 +822,24 @@ def _parse_asn_response(response) -> dict: return response_output -def _asn_results(asn: str) -> dict: +def _asn_results(asn: str) -> dict[str, Any]: """Get ASN details from ASN number.""" if not asn.startswith("AS"): asn = f"AS{asn}" - query1 = f" {asn}\r\n" - asn_response = _radb_query(query1) - asn_details = _parse_asn_details(asn_response) - query2 = f" -i origin {asn}\r\n" - asn_ranges_response = _radb_query(query2) + query1: str = f" {asn}\r\n" + asn_response: str = _radb_query(query1) + asn_details: dict[str, Any] = _parse_asn_details(asn_response) + query2: str = f" -i origin {asn}\r\n" + asn_ranges_response: str = _radb_query(query2) asn_details["ranges"] = _parse_asn_ranges(asn_ranges_response) return asn_details -def _parse_asn_details(response): +def _parse_asn_details(response: str) -> dict[str, Any]: """Parse ASN details response into a dictionary.""" - asn_keys = ["aut-num", "as-name", "descr", "notify", "changed"] - asn_output = _parse_asn_response(response) - asn_output_filtered = { + asn_keys: list[str] = ["aut-num", "as-name", "descr", "notify", "changed"] + asn_output: dict[str, Any] = _parse_asn_response(response) + asn_output_filtered: dict[str, Any] = { key: value for key, value in asn_output.items() if key in asn_keys } asn_output_filtered["Autonomous Number"] = asn_output_filtered.pop("aut-num", None) @@ -791,7 +850,7 @@ def _parse_asn_details(response): return asn_output_filtered -def _parse_asn_ranges(response): +def _parse_asn_ranges(response: str) -> list[str]: """Parse ASN ranges response into a list.""" return [ item.split(": ")[1].strip() diff --git a/msticpy/context/lookup.py b/msticpy/context/lookup.py index 1e25ab4f1..ba21f2260 100644 --- a/msticpy/context/lookup.py +++ b/msticpy/context/lookup.py @@ -15,16 +15,24 @@ from __future__ import annotations import asyncio -import datetime as dt import importlib +import logging import warnings from collections import ChainMap -from types import ModuleType -from typing import Any, Callable, ClassVar, Iterable, Mapping, Sized +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Iterable, + Mapping, + Sized, +) import nest_asyncio import pandas as pd from tqdm.auto import tqdm +from typing_extensions import Self from .._version import VERSION from ..common.exceptions import MsticpyConfigError, MsticpyUserConfigError @@ -34,34 +42,41 @@ reload_settings, ) from ..common.utility import export, is_ipython -from ..nbwidgets.select_item import SelectItem from ..vis.ti_browser import browse_results from .lookup_result import LookupStatus # used in dynamic instantiation of providers from .provider_base import Provider, _make_sync +if TYPE_CHECKING: + import datetime as dt + from types import ModuleType + + from ..nbwidgets.select_item import SelectItem + __version__ = VERSION __author__ = "Florian Bracq" +logger: logging.Logger = logging.getLogger(__name__) + class ProgressCounter: """Progress counter for async tasks.""" - def __init__(self, total: int) -> None: + def __init__(self: ProgressCounter, total: int) -> None: """Initialize the class.""" self.total: int = total self._lock: asyncio.Condition = asyncio.Condition() self._remaining: int = total - async def decrement(self, increment: int = 1) -> None: + async def decrement(self: Self, increment: int = 1) -> None: """Decrement the counter.""" if self._remaining == 0: return async with self._lock: self._remaining -= increment - async def get_remaining(self) -> int: + async def get_remaining(self: Self) -> int: """Get the current remaining count.""" async with self._lock: return self._remaining @@ -88,7 +103,7 @@ class Lookup: PACKAGE: ClassVar[str] = "" def __init__( - self, + self: Lookup, providers: list[str] | None = None, *, primary_providers: list[Provider] | None = None, @@ -122,6 +137,7 @@ def __init__( warnings.warn( "'secondary_providers' is a deprecated parameter", DeprecationWarning, + stacklevel=1, ) for prov in secondary_providers: self.add_provider(prov, primary=False) @@ -133,7 +149,7 @@ def __init__( nest_asyncio.apply() @property - def loaded_providers(self) -> dict[str, Provider]: + def loaded_providers(self: Self) -> dict[str, Provider]: """ Return dictionary of loaded providers. @@ -146,7 +162,7 @@ def loaded_providers(self) -> dict[str, Provider]: return dict(self._all_providers) @property - def provider_status(self) -> Iterable[str]: + def provider_status(self: Self) -> Iterable[str]: """ Return loaded provider status. @@ -167,7 +183,7 @@ def provider_status(self) -> Iterable[str]: return prim + sec @property - def configured_providers(self) -> list[str]: + def configured_providers(self: Self) -> list[str]: """ Return a list of available providers that have configuration details present. @@ -182,7 +198,7 @@ def configured_providers(self) -> list[str]: return prim_conf + sec_conf - def enable_provider(self, providers: str | Iterable[str]) -> None: + def enable_provider(self: Self, providers: str | Iterable[str]) -> None: """ Set the provider(s) as primary (used by default). @@ -206,12 +222,21 @@ def enable_provider(self, providers: str | Iterable[str]) -> None: self._providers[provider] = self._secondary_providers[provider] del self._secondary_providers[provider] elif provider not in self._providers: - raise ValueError( - f"Unknown provider '{provider}'. Available providers:", - ", ".join(self.list_available_providers(as_list=True)), # type: ignore + available_providers: list[str] | None = self.list_available_providers( + as_list=True, ) - - def disable_provider(self, providers: str | Iterable[str]) -> None: + if not available_providers: + err_msg: str = ( + f"Unknown provider '{provider}'. No available providers." + ) + else: + err_msg = ( + f"Unknown provider '{provider}'. Available providers:" + ", ".join(available_providers) + ) + raise ValueError(err_msg) + + def disable_provider(self: Self, providers: str | Iterable[str]) -> None: """ Set the provider as secondary (not used by default). @@ -235,12 +260,21 @@ def disable_provider(self, providers: str | Iterable[str]) -> None: self._secondary_providers[provider] = self._providers[provider] del self._providers[provider] elif provider not in self._secondary_providers: - raise ValueError( - f"Unknown provider '{provider}'. Available providers:", - ", ".join(self.list_available_providers(as_list=True)), # type: ignore + available_providers: list[str] | None = self.list_available_providers( + as_list=True, ) - - def set_provider_state(self, prov_dict: dict[str, bool]) -> None: + if not available_providers: + err_msg: str = ( + f"Unknown provider '{provider}'. No available providers." + ) + else: + err_msg = ( + f"Unknown provider '{provider}'. Available providers:" + ", ".join(available_providers) + ) + raise ValueError(err_msg) + + def set_provider_state(self: Self, prov_dict: dict[str, bool]) -> None: """ Set a dict of providers to primary/secondary. @@ -259,7 +293,7 @@ def set_provider_state(self, prov_dict: dict[str, bool]) -> None: @classmethod def browse_results( - cls, + cls: type[Self], data: pd.DataFrame, severities: list[str] | None = None, *, @@ -287,19 +321,19 @@ def browse_results( """ if not isinstance(data, pd.DataFrame): - print("Input data is in an unexpected format.") + logger.info("Input data is in an unexpected format.") return None return browse_results(data=data, severities=severities, height=height) browse: Callable[..., SelectItem | None] = browse_results - def provider_usage(self) -> None: + def provider_usage(self: Self) -> None: """Print usage of loaded providers.""" print("Primary providers") print("-----------------") if self._providers: for prov_name, prov in self._providers.items(): - print(f"\nProvider class: {prov_name}") + print("\nProvider class: %s", prov_name) prov.usage() else: print("none") @@ -307,29 +341,29 @@ def provider_usage(self) -> None: print("-------------------") if self._secondary_providers: for prov_name, prov in self._secondary_providers.items(): - print(f"\nProvider class: {prov_name}") + print("\nProvider class: %s", prov_name) prov.usage() else: print("none") @classmethod - def reload_provider_settings(cls) -> None: + def reload_provider_settings(cls: type[Self]) -> None: """Reload provider settings from config.""" reload_settings() - print( - "Settings reloaded. Use reload_providers to update settings", - "for loaded providers.", + logger.info( + "Settings reloaded. Use reload_providers to update settings for loaded providers.", ) - def reload_providers(self) -> None: + def reload_providers(self: Self) -> None: """Reload settings and provider classes.""" reload_settings() self._load_providers() def add_provider( - self, + self: Self, provider: Provider, name: str | None = None, + *, primary: bool = True, ) -> None: """ @@ -353,8 +387,8 @@ def add_provider( else: self._secondary_providers[name] = provider - def lookup_item( # pylint: disable=too-many-locals, too-many-arguments - self, + def lookup_item( # pylint: disable=too-many-locals, too-many-arguments #noqa: PLR0913 + self: Self, item: str, item_type: str | None = None, query_type: str | None = None, @@ -385,6 +419,12 @@ def lookup_item( # pylint: disable=too-many-locals, too-many-arguments `providers` is specified, it will override this parameter. prov_scope : str, optional Use "primary", "secondary" or "all" providers, by default "primary" + show_not_supported: bool + If True, display unsupported items. Defaults to False + start: dt.datetime + If supported by the provider, start time for the item's validity + end: dt.datetime + If supported by the provider, end time for the item's validity Returns ------- @@ -403,8 +443,8 @@ def lookup_item( # pylint: disable=too-many-locals, too-many-arguments end=end, ) - def lookup_items( # pylint: disable=too-many-arguments - self, + def lookup_items( # pylint: disable=too-many-arguments #noqa: PLR0913 + self: Self, data: pd.DataFrame | Mapping[str, str] | Sized, item_col: str | None = None, item_type_col: str | None = None, @@ -442,6 +482,12 @@ def lookup_items( # pylint: disable=too-many-arguments `providers` is specified, it will override this parameter. prov_scope : str, optional Use "primary", "secondary" or "all" providers, by default "primary" + show_not_supported: bool + If True, display unsupported items. Defaults to False + start: dt.datetime + If supported by the provider, start time for the item's validity + end: dt.datetime + If supported by the provider, end time for the item's validity Other Parameters ---------------- @@ -488,11 +534,11 @@ def result_to_df(item_lookup: pd.DataFrame) -> pd.DataFrame: """ if not isinstance(item_lookup, pd.DataFrame): err_msg: str = f"DataFrame was expected, but {type(item_lookup)} received." - raise ValueError(err_msg) + raise TypeError(err_msg) return item_lookup - async def _lookup_items_async( # pylint: disable=too-many-locals, too-many-arguments - self, + async def _lookup_items_async( # pylint: disable=too-many-locals, too-many-arguments #noqa: PLR0913 + self: Self, data: pd.DataFrame | Mapping[str, str] | Sized, item_col: str | None = None, item_type_col: str | None = None, @@ -560,8 +606,8 @@ async def _lookup_items_async( # pylint: disable=too-many-locals, too-many-argu show_bad_item=show_bad_item, ) - def lookup_items_sync( # pylint: disable=too-many-arguments, too-many-locals - self, + def lookup_items_sync( # pylint: disable=too-many-arguments, too-many-locals #noqa: PLR0913 + self: Self, data: pd.DataFrame | Mapping[str, str] | Iterable[str], item_col: str | None = None, item_type_col: str | None = None, @@ -600,6 +646,16 @@ def lookup_items_sync( # pylint: disable=too-many-arguments, too-many-locals `providers` is specified, it will override this parameter. prov_scope : str, optional Use "primary", "secondary" or "all" providers, by default "primary" + col: str, Optional + Name of the column holding the data + column: str, Optional + Name of the column holding the data + show_not_supported: bool, Optional + Set to True to include unsupported items in the result DF. + Defaults to False + show_bad_item: bool, Optional + Set to True to include invalid items in the result DF. + Defaults to False Returns ------- @@ -640,7 +696,7 @@ def lookup_items_sync( # pylint: disable=too-many-arguments, too-many-locals ) @staticmethod - async def _track_completion(prog_counter) -> None: + async def _track_completion(prog_counter: ProgressCounter) -> None: total: float = await prog_counter.get_remaining() with tqdm(total=total, unit="obs", desc="Observables processed") as prog_bar: try: @@ -659,7 +715,7 @@ async def _track_completion(prog_counter) -> None: prog_bar.update(total - final_remaining) @property - def available_providers(self) -> list[str]: + def available_providers(self: Self) -> list[str]: """ Return a list of builtin and plugin providers. @@ -673,8 +729,9 @@ def available_providers(self) -> list[str]: @classmethod def list_available_providers( - cls, - show_query_types=False, + cls: type[Self], + *, + show_query_types: bool = False, as_list: bool = False, ) -> list[str] | None: """ @@ -699,7 +756,7 @@ def list_available_providers( for provider_name in cls.PROVIDERS: provider_class: type[Provider] = cls.import_provider(provider_name) if not as_list: - print(provider_name) + logger.info(provider_name) providers.append(provider_name) if show_query_types and provider_class: provider_class.usage() @@ -707,18 +764,18 @@ def list_available_providers( return providers if as_list else None @classmethod - def import_provider(cls, provider: str) -> type[Provider]: + def import_provider(cls: type[Self], provider: str) -> type[Provider]: """Import provider class.""" mod_name, cls_name = cls.PROVIDERS.get(provider, (None, None)) if not (mod_name and cls_name): if hasattr(cls, "CUSTOM_PROVIDERS") and provider in cls.CUSTOM_PROVIDERS: return cls.CUSTOM_PROVIDERS[provider] - raise LookupError( - f"No provider named '{provider}'.", - "Possible values are:", - ", ".join(list(cls.PROVIDERS) + list(cls.CUSTOM_PROVIDERS)), + err_msg: str = ( + f"No provider named '{provider}'. Possible values are: " + ", ".join(list(cls.PROVIDERS) + list(cls.CUSTOM_PROVIDERS)) ) + raise LookupError(err_msg) imp_module: ModuleType = importlib.import_module( f"msticpy.context.{cls.PACKAGE}.{mod_name}", @@ -727,7 +784,7 @@ def import_provider(cls, provider: str) -> type[Provider]: return getattr(imp_module, cls_name) def _load_providers( - self, + self: Self, *, providers: str = "Providers", ) -> None: @@ -781,7 +838,7 @@ def _load_providers( ) def _select_providers( - self, + self: Self, providers: list[str] | None = None, prov_scope: str = "primary", ) -> dict[str, Provider]: @@ -836,5 +893,5 @@ def _combine_results( result_list.append(result) if not result_list: - print("No Item matches") + logger.info("No Item matches") return pd.concat(result_list, sort=False) if result_list else pd.DataFrame() diff --git a/msticpy/context/preprocess_observable.py b/msticpy/context/preprocess_observable.py index 2ed2d0b08..7f297933c 100644 --- a/msticpy/context/preprocess_observable.py +++ b/msticpy/context/preprocess_observable.py @@ -23,6 +23,7 @@ from typing import Callable, ClassVar from urllib.parse import quote_plus +from typing_extensions import Self from urllib3.exceptions import LocationParseError from urllib3.util import parse_url @@ -34,7 +35,9 @@ __version__ = VERSION __author__ = "Ian Hellen" -_IOC_EXTRACT = IoCExtract() +_IOC_EXTRACT: IoCExtract = IoCExtract() + +MINIMAL_ENTROPY: float = 3.0 # slightly stricter than normal URL regex to exclude '() from host string @@ -48,7 +51,8 @@ (\#(?P([a-z0-9-._~!$&'()*+,;=:/?@]|%[0-9A-F]{2})*))?\b""" _HTTP_STRICT_RGXC: re.Pattern[str] = re.compile( - _HTTP_STRICT_REGEX, re.IGNORECASE | re.VERBOSE | re.MULTILINE + _HTTP_STRICT_REGEX, + re.IGNORECASE | re.VERBOSE | re.MULTILINE, ) @@ -212,7 +216,7 @@ def _preprocess_dns(domain: str) -> SanitizedObservable: def _preprocess_hash(hash_str: str) -> SanitizedObservable: """Ensure Hash has minimum entropy (rather than a string of 'x').""" str_entropy: float = _entropy(hash_str) - if str_entropy < 3.0: + if str_entropy < MINIMAL_ENTROPY: return SanitizedObservable(None, "String has too low an entropy to be a hash") return SanitizedObservable(hash_str, "ok") @@ -260,12 +264,12 @@ def __init__(self: PreProcessor) -> None: } @property - def processors(self) -> dict[str, list[str | CheckerType]]: + def processors(self: Self) -> dict[str, list[str | CheckerType]]: """Return _processors value.""" return self._processors def check( - self, + self: Self, value: str, value_type: str, *, @@ -280,6 +284,9 @@ def check( The value to be checked. value_type : str The type of value to be checked. + require_url_encoding: bool, Optional + If true, apply URL encoding. Only applicable for URL observables.* + Defaults to False. Returns ------- @@ -306,7 +313,8 @@ def check( proc_name = processor.__name__ if proc_name == "_preprocess_url": result = processor( - proc_value, require_url_encoding=require_url_encoding + proc_value, + require_url_encoding=require_url_encoding, ) else: result = processor(proc_value) @@ -315,7 +323,7 @@ def check( break return result - def add_check(self, value_type: str, checker: CheckerType) -> None: + def add_check(self: Self, value_type: str, checker: CheckerType) -> None: """Add a new checker to the processors.""" if value_type not in self._processors: self._processors[value_type] = [checker] diff --git a/msticpy/context/provider_base.py b/msticpy/context/provider_base.py index 0b5c2eb1b..2ff1ef036 100644 --- a/msticpy/context/provider_base.py +++ b/msticpy/context/provider_base.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +import logging from abc import ABC, abstractmethod from asyncio import get_event_loop from collections.abc import Iterable as C_Iterable @@ -40,6 +41,7 @@ __author__ = "Ian Hellen" _ITEM_EXTRACT: ItemExtract = ItemExtract() +logger: logging.Logger = logging.getLogger(__name__) @export @@ -50,7 +52,7 @@ class Provider(ABC): @abstractmethod def lookup_item( - self, + self: Self, item: str, item_type: str | None = None, query_type: str | None = None, @@ -89,7 +91,7 @@ def lookup_item( """ def _check_item_type( - self, + self: Self, item: str, item_type: str | None = None, query_subtype: str | None = None, @@ -147,7 +149,7 @@ def _check_item_type( return result # pylint: disable=unused-argument - def __init__(self) -> None: + def __init__(self: Provider) -> None: """Initialize the provider.""" self.description: str | None = None self._supported_types: set[IoCType] = set() @@ -161,12 +163,12 @@ def __init__(self) -> None: self._preprocessors = PreProcessor() @property - def name(self) -> str: + def name(self: Self) -> str: """Return the name of the provider.""" return self.__class__.__name__ def lookup_items( - self, + self: Self, data: pd.DataFrame | dict[str, str] | Iterable[str], item_col: str | None = None, item_type_col: str | None = None, @@ -211,8 +213,8 @@ def lookup_items( return pd.concat(results) - async def lookup_items_async( - self, + async def lookup_items_async( # noqa:PLR0913 + self: Self, data: pd.DataFrame | dict[str, str] | Iterable[str], item_col: str | None = None, item_type_col: str | None = None, @@ -283,7 +285,7 @@ def item_query_defs(self: Self) -> dict[str, Any]: return self._QUERIES @classmethod - def is_known_type(cls, item_type: str) -> bool: + def is_known_type(cls: type[Self], item_type: str) -> bool: """ Return True if this a known IoC Type. @@ -301,7 +303,7 @@ def is_known_type(cls, item_type: str) -> bool: return item_type in IoCType.__members__ and item_type != "unknown" @property - def supported_types(self) -> list[str]: + def supported_types(self: Self) -> list[str]: """ Return list of supported types for this provider. @@ -314,7 +316,7 @@ def supported_types(self) -> list[str]: return [item.name for item in self._supported_types] @classmethod - def usage(cls) -> None: + def usage(cls: type[Self]) -> None: """Print usage of provider.""" print(f"{cls.__doc__} Supported query types:") for key in sorted(cls._QUERIES): @@ -324,7 +326,7 @@ def usage(cls) -> None: if len(elements) > 1: print(f"\titem_type={elements[0]}, query_type={elements[1]}") - def is_supported_type(self, item_type: str | IoCType) -> bool: + def is_supported_type(self: Self, item_type: str | IoCType) -> bool: """ Return True if the passed type is supported. @@ -362,8 +364,8 @@ def resolve_item_type(item: str) -> str: """ return _ITEM_EXTRACT.get_ioc_type(item) - async def _lookup_items_async_wrapper( - self, + async def _lookup_items_async_wrapper( # pylint: disable=too-many-arguments # noqa: PLR0913 + self: Self, data: pd.DataFrame | dict[str, str] | list[str], item_col: str | None = None, item_type_col: str | None = None, @@ -440,7 +442,7 @@ def generate_items( data: pd.DataFrame | dict | C_Iterable, item_col: str | None = None, item_type_col: str | None = None, -) -> Generator[tuple[str | None, str | None]]: +) -> Generator[tuple[str | None, str | None], Any, None]: """ Generate item pairs from different input types. diff --git a/msticpy/context/tilookup.py b/msticpy/context/tilookup.py index 8d4808cbf..4a85374d2 100644 --- a/msticpy/context/tilookup.py +++ b/msticpy/context/tilookup.py @@ -16,6 +16,8 @@ from typing import TYPE_CHECKING, ClassVar, Iterable, Mapping +from typing_extensions import Self + from .._version import VERSION from ..common.utility import export from .lookup import Lookup @@ -52,9 +54,8 @@ class TILookup(Lookup): PACKAGE: ClassVar[str] = "tiproviders" CUSTOM_PROVIDERS: ClassVar[dict[str, type[Provider]]] = {} - # pylint: disable=too-many-arguments - def lookup_ioc( - self, + def lookup_ioc( # pylint: disable=too-many-arguments #noqa: PLR0913 + self: Self, ioc: str | None = None, ioc_type: str | None = None, ioc_query_type: str | None = None, @@ -132,8 +133,8 @@ def lookup_ioc( end=end, ) - def lookup_iocs( - self, + def lookup_iocs( # pylint: disable=too-many-arguments #noqa: PLR0913 + self: Self, data: pd.DataFrame | Mapping[str, str] | Iterable[str], ioc_col: str | None = None, ioc_type_col: str | None = None, @@ -210,9 +211,8 @@ def lookup_iocs( ), ) - # pylint: disable=too-many-locals - async def _lookup_iocs_async( - self, + async def _lookup_iocs_async( # pylint: disable=too-many-arguments #noqa:PLR0913 + self: Self, data: pd.DataFrame | Mapping[str, str] | Iterable[str], ioc_col: str | None = None, ioc_type_col: str | None = None, @@ -237,8 +237,8 @@ async def _lookup_iocs_async( end=end, ) - def lookup_iocs_sync( - self, + def lookup_iocs_sync( # pylint:disable=too-many-arguments # noqa: PLR0913 + self: Self, data: pd.DataFrame | Mapping[str, str] | Iterable[str], ioc_col: str | None = None, ioc_type_col: str | None = None, @@ -290,7 +290,7 @@ def lookup_iocs_sync( ) def _load_providers( - self, + self: Self, *, providers: str = "TIProviders", ) -> None: diff --git a/msticpy/context/tiproviders/alienvault_otx.py b/msticpy/context/tiproviders/alienvault_otx.py index f951471e1..4614757a9 100644 --- a/msticpy/context/tiproviders/alienvault_otx.py +++ b/msticpy/context/tiproviders/alienvault_otx.py @@ -14,9 +14,9 @@ """ from __future__ import annotations +from dataclasses import dataclass from typing import Any, ClassVar -import attr from typing_extensions import Self from ..._version import VERSION @@ -30,7 +30,7 @@ # pylint: disable=too-few-public-methods -@attr.s +@dataclass class _OTXParams(APILookupParams): # override APILookupParams to set common defaults def __attrs_post_init__(self: Self) -> None: diff --git a/msticpy/context/tiproviders/binaryedge.py b/msticpy/context/tiproviders/binaryedge.py index a0666fba0..a198eff1c 100644 --- a/msticpy/context/tiproviders/binaryedge.py +++ b/msticpy/context/tiproviders/binaryedge.py @@ -16,6 +16,8 @@ from typing import Any, ClassVar +from typing_extensions import Self + from ..._version import VERSION from ...common.utility import export from ..http_provider import APILookupParams @@ -45,10 +47,11 @@ class BinaryEdge(HttpTIProvider): # aliases _QUERIES["ipv6"] = _QUERIES["ipv4"] - def parse_results(self, response: dict) -> tuple[bool, ResultSeverity, Any]: + def parse_results(self: Self, response: dict) -> tuple[bool, ResultSeverity, Any]: """Return the details of the response.""" if self._failed_response(response) or not isinstance( - response["RawResult"], dict + response["RawResult"], + dict, ): return False, ResultSeverity.information, "Not found." diff --git a/msticpy/context/tiproviders/ibm_xforce.py b/msticpy/context/tiproviders/ibm_xforce.py index 85d33fa59..70705dfae 100644 --- a/msticpy/context/tiproviders/ibm_xforce.py +++ b/msticpy/context/tiproviders/ibm_xforce.py @@ -14,9 +14,9 @@ """ from __future__ import annotations +from dataclasses import dataclass from typing import Any, ClassVar -import attr from typing_extensions import Self from ..._version import VERSION @@ -30,7 +30,7 @@ # pylint: disable=too-few-public-methods -@attr.s +@dataclass class _XForceParams(APILookupParams): # override APILookupParams to set common defaults def __attrs_post_init__(self: Self) -> None: diff --git a/msticpy/context/tiproviders/intsights.py b/msticpy/context/tiproviders/intsights.py index aa00c0007..fe7b6c15c 100644 --- a/msticpy/context/tiproviders/intsights.py +++ b/msticpy/context/tiproviders/intsights.py @@ -14,9 +14,9 @@ from __future__ import annotations import datetime as dt +from dataclasses import dataclass from typing import Any, ClassVar -import attr from typing_extensions import Self from ..._version import VERSION @@ -36,7 +36,7 @@ # pylint: disable=too-few-public-methods -@attr.s +@dataclass class _IntSightsParams(APILookupParams): # override APILookupParams to set common defaults def __attrs_post_init__(self: Self) -> None: diff --git a/msticpy/context/tiproviders/kql_base.py b/msticpy/context/tiproviders/kql_base.py index 55ba67455..54c351b7b 100644 --- a/msticpy/context/tiproviders/kql_base.py +++ b/msticpy/context/tiproviders/kql_base.py @@ -38,7 +38,7 @@ import datetime as dt from Kqlmagic.results import ResultSet - +logger: logging.Logger = logging.getLogger(__name__) __version__ = VERSION __author__ = "Ian Hellen" @@ -70,7 +70,7 @@ def __init__( query_provider, QueryProvider, ): - self._query_provider = query_provider + self._query_provider: QueryProvider = query_provider self._connect_str: str = connect_str or WorkspaceConfig().code_connect_str else: self._query_provider, self._connect_str = self._create_query_provider( @@ -169,7 +169,8 @@ def lookup_iocs( table not in self._query_provider.schema for table in self._REQUIRED_TABLES ): logger.error( - "Required tables not found in schema: %s", self._REQUIRED_TABLES + "Required tables not found in schema: %s", + self._REQUIRED_TABLES, ) return pd.DataFrame() @@ -182,7 +183,10 @@ def lookup_iocs( if result["Status"] != LookupStatus.NOT_SUPPORTED.value: logger.info( - "Check ioc type for %s (%s): %s", ioc, ioc_type, result["Status"] + "Check ioc type for %s (%s): %s", + ioc, + ioc_type, + result["Status"], ) ioc_groups[result["IocType"]].add(result["Ioc"]) @@ -267,15 +271,15 @@ def _check_result_status(data_result: pd.DataFrame | ResultSet) -> LookupStatus: and data_result.completion_query_info["StatusCode"] == 0 and data_result.records_count == 0 ): - print("No results return from data provider.") + logger.info("No results return from data provider.") return LookupStatus.NO_DATA if data_result and hasattr(data_result, "completion_query_info"): - print( - "No results returned from data provider. " - + str(data_result.completion_query_info), + logger.info( + "No results returned from data provider. %s", + data_result.completion_query_info, ) else: - print(f"Unknown response from provider: {data_result!s}") + logger.info("Unknown response from provider: %s", data_result) return LookupStatus.QUERY_FAILED @abc.abstractmethod @@ -336,7 +340,7 @@ def _create_query_provider(self: Self, **kwargs: str) -> tuple[QueryProvider, st def _connect(self: Self) -> None: """Connect to query provider.""" - print("MS Sentinel TI query provider needs authenticated connection.") + logger.info("MS Sentinel TI query provider needs authenticated connection.") self._query_provider.connect(self._connect_str) logging.info("Connected to Sentinel. (%s)", self._connect_str) @@ -353,7 +357,7 @@ def _get_spelled_variants(name: str, **kwargs: str) -> str | None: None, ) - def _get_query_and_params( + def _get_query_and_params( # noqa:PLR0913 self: Self, ioc: str | list[str], ioc_type: str, diff --git a/msticpy/context/tiproviders/result_severity.py b/msticpy/context/tiproviders/result_severity.py index d2b4ce928..90a1fb201 100644 --- a/msticpy/context/tiproviders/result_severity.py +++ b/msticpy/context/tiproviders/result_severity.py @@ -31,7 +31,7 @@ class ResultSeverity(Enum): # pylint: enable=invalid-name @classmethod - def parse(cls: type[ResultSeverity], value: object) -> ResultSeverity: + def parse(cls: type[Self], value: object) -> ResultSeverity: """ Parse string or numeric value to ResultSeverity. diff --git a/msticpy/context/tiproviders/riskiq.py b/msticpy/context/tiproviders/riskiq.py index 19decd55d..a51eea064 100644 --- a/msticpy/context/tiproviders/riskiq.py +++ b/msticpy/context/tiproviders/riskiq.py @@ -340,7 +340,7 @@ def _set_pivot_timespan( ptanalyzer.set_date_range(start_date=start, end_date=end) return changed - def pivot_value( # pylint: disable=too-many-arguments + def pivot_value( # pylint: disable=too-many-arguments #noqa:PLR0913 self: Self, prop: str, host: str, diff --git a/msticpy/context/tiproviders/ti_http_provider.py b/msticpy/context/tiproviders/ti_http_provider.py index ec9de6ad4..2d64db138 100644 --- a/msticpy/context/tiproviders/ti_http_provider.py +++ b/msticpy/context/tiproviders/ti_http_provider.py @@ -151,7 +151,7 @@ def _run_ti_lookup_query( return result @lru_cache(maxsize=256) - def lookup_ioc( + def lookup_ioc( # noqa: PLR0913 self: Self, ioc: str, ioc_type: str | None = None, diff --git a/msticpy/context/tiproviders/ti_provider_base.py b/msticpy/context/tiproviders/ti_provider_base.py index 3e5226a6a..cbaab75bb 100644 --- a/msticpy/context/tiproviders/ti_provider_base.py +++ b/msticpy/context/tiproviders/ti_provider_base.py @@ -14,6 +14,7 @@ """ from __future__ import annotations +import logging from abc import abstractmethod from typing import TYPE_CHECKING, Any, ClassVar, Iterable @@ -30,6 +31,7 @@ from ...init.pivot import Pivot from ...init.pivot_core.pivot_register import PivotRegistration +logger: logging.Logger = logging.getLogger(__name__) __version__ = VERSION __author__ = "Ian Hellen" @@ -314,7 +316,7 @@ def ioc_query_defs(self: Self) -> dict[str, Any]: return self._QUERIES @classmethod - def usage(cls: type[TIProvider]) -> None: + def usage(cls: type[Self]) -> None: """Print usage of provider.""" print(f"{cls.__doc__} Supported query types:") for ioc_key in sorted(cls._QUERIES): diff --git a/msticpy/context/tiproviders/tor_exit_nodes.py b/msticpy/context/tiproviders/tor_exit_nodes.py index 6b5c5cf9b..5554e7627 100644 --- a/msticpy/context/tiproviders/tor_exit_nodes.py +++ b/msticpy/context/tiproviders/tor_exit_nodes.py @@ -49,7 +49,7 @@ class Tor(TIProvider): _cache_lock = Lock() @classmethod - def _check_and_get_nodelist(cls: type[Tor]) -> None: + def _check_and_get_nodelist(cls: type[Self]) -> None: """Pull down Tor exit node list and save to internal attribute.""" if cls._cache_lock.locked(): return diff --git a/msticpy/context/vtlookupv3/vtfile_behavior.py b/msticpy/context/vtlookupv3/vtfile_behavior.py index 8b79c8453..41a548af8 100644 --- a/msticpy/context/vtlookupv3/vtfile_behavior.py +++ b/msticpy/context/vtlookupv3/vtfile_behavior.py @@ -6,6 +6,7 @@ """VirusTotal File Behavior functions.""" from __future__ import annotations +import logging import re from copy import deepcopy from datetime import datetime, timezone @@ -17,6 +18,7 @@ import ipywidgets as widgets import numpy as np import pandas as pd +from typing_extensions import Self from ..._version import VERSION from ...common.exceptions import MsticpyImportExtraError, MsticpyUserError @@ -36,7 +38,7 @@ title="Error importing VirusTotal modules.", extra="vt3", ) from imp_err - +logger: logging.Logger = logging.getLogger(__name__) __version__ = VERSION __author__ = "Ian Hellen" @@ -105,13 +107,13 @@ class VTFileBehavior: } @classmethod - def list_sandboxes(cls) -> list[str]: + def list_sandboxes(cls: type[Self]) -> list[str]: """Return list of known sandbox types.""" return list(cls._SANDBOXES) def __init__( - self, - vt_key: str | None = None, + self: Self, + vt_key: str, file_id: str | None = None, file_summary: pd.DataFrame | pd.Series | dict[str, Any] | None = None, ) -> None: @@ -152,32 +154,32 @@ def __init__( self.behavior_links: dict[str, Any] = {} self.process_tree_df: pd.DataFrame | None = None - def _reset_summary(self) -> None: + def _reset_summary(self: Self) -> None: self._file_behavior = {} self.categories = {} self.process_tree_df = None @property - def sandbox_id(self) -> str: + def sandbox_id(self: Self) -> str: """Return sandbox ID of detonation.""" return self.categories.get("id", "") @property - def has_evtx(self) -> bool: + def has_evtx(self: Self) -> bool: """Return True if EVTX data is available (Enterprise only).""" return self.categories.get("has_evtx", False) @property - def has_memdump(self) -> bool: + def has_memdump(self: Self) -> bool: """Return True if memory dump data is available (Enterprise only).""" return self.categories.get("has_memdump", False) @property - def has_pcap(self) -> bool: + def has_pcap(self: Self) -> bool: """Return True if PCAP data is available (Enterprise only).""" return self.categories.get("has_pcap", False) - def get_file_behavior(self, sandbox: str | None = None) -> None: + def get_file_behavior(self: Self, sandbox: str | None = None) -> None: """ Retrieve the file behavior data. @@ -212,7 +214,7 @@ def get_file_behavior(self, sandbox: str | None = None) -> None: else: self.categories = self._file_behavior - def browse(self) -> widgets.VBox | None: + def browse(self: Self) -> widgets.VBox | None: """Browse the behavior categories.""" if not self.has_behavior_data: self._print_no_data() @@ -249,7 +251,7 @@ def browse(self) -> widgets.VBox | None: return widgets.VBox([html_title, accordion]) @property - def process_tree(self) -> figure | None: + def process_tree(self: Self) -> figure | None: """Return the process tree plot.""" if not self.has_behavior_data: self._print_no_data() @@ -264,13 +266,13 @@ def process_tree(self) -> figure | None: return plot @property - def has_behavior_data(self) -> bool: + def has_behavior_data(self: Self) -> bool: """Return true if file behavior data available.""" return bool(self.categories) - def _print_no_data(self) -> None: + def _print_no_data(self: Self) -> None: """Print a message if operation is tried with no data.""" - print(f"No data available for {self.file_id}.") + logger.info("No data available for %s.", self.file_id) # Process tree extraction @@ -361,7 +363,8 @@ def _extract_processes( def _create_si_proc( - raw_proc: dict[str, Any], procs_created: dict[str, Any] + raw_proc: dict[str, Any], + procs_created: dict[str, Any], ) -> SIProcess: """Return an SIProcess Object from a raw VT proc definition.""" name: str = raw_proc["name"] @@ -417,11 +420,11 @@ def _try_match_commandlines( break if weak_matches: - print( - f"WARNING: {weak_matches} of the {len(command_executions)} commandlines", - "were weakly matched - some commandlines may be attributed", + logger.warning( + "%s of the %d commandlines were weakly matched - some commandlines may be attributed" "to the wrong instance of the process.", - end="\n", + weak_matches, + len(command_executions), ) return procs_cmd diff --git a/msticpy/context/vtlookupv3/vtlookup.py b/msticpy/context/vtlookupv3/vtlookup.py index dbc38b997..4d1dd9848 100644 --- a/msticpy/context/vtlookupv3/vtlookup.py +++ b/msticpy/context/vtlookupv3/vtlookup.py @@ -23,11 +23,13 @@ import contextlib import json +import logging from json import JSONDecodeError from typing import Any, ClassVar, Hashable, Mapping, NamedTuple import httpx import pandas as pd +from typing_extensions import Self from ..._version import VERSION from ...common.pkg_config import get_http_timeout @@ -35,6 +37,7 @@ from ..lookup_result import SanitizedObservable from ..preprocess_observable import preprocess_observable +logger: logging.Logger = logging.getLogger(__name__) __version__ = VERSION __author__ = "Ian Hellen" @@ -128,7 +131,7 @@ class VTLookup: _http_strict_rgxc: None = None - def __init__(self, vtkey: str, verbosity: int = 1) -> None: + def __init__(self: VTLookup, vtkey: str, verbosity: int = 1) -> None: """ Create a new instance of VTLookup class. @@ -149,11 +152,12 @@ def __init__(self, vtkey: str, verbosity: int = 1) -> None: # create a data frame to store the results self.results: pd.DataFrame = pd.DataFrame( - data=None, columns=self._RESULT_COLUMNS + data=None, + columns=self._RESULT_COLUMNS, ) @property - def supported_ioc_types(self) -> list[str]: + def supported_ioc_types(self: Self) -> list[str]: """ Return list of supported IoC type internal names. @@ -166,7 +170,7 @@ def supported_ioc_types(self) -> list[str]: return self._SUPPORTED_INPUT_TYPES @property - def supported_vt_types(self) -> list[str]: + def supported_vt_types(self: Self) -> list[str]: """ Return list of VirusTotal supported IoC type names. @@ -179,7 +183,7 @@ def supported_vt_types(self) -> list[str]: return list(self._VT_API_TYPES.keys()) @property - def ioc_vt_type_mapping(self) -> dict[str, str]: + def ioc_vt_type_mapping(self: Self) -> dict[str, str]: """ Return mapping between internal and VirusTotal IoC type names. @@ -192,7 +196,7 @@ def ioc_vt_type_mapping(self) -> dict[str, str]: return self._VT_TYPE_MAP def lookup_iocs( - self, + self: Self, data: pd.DataFrame, src_col: str = "Observable", type_col: str = "IoCType", @@ -272,7 +276,7 @@ def lookup_iocs( return self.results def lookup_ioc( - self, + self: Self, observable: str, ioc_type: str, output: str = "dict", @@ -352,7 +356,7 @@ def lookup_ioc( # pylint: disable=too-many-locals def _lookup_ioc_type( - self, + self: Self, input_frame: pd.DataFrame, ioc_type: str, src_col: str, @@ -472,9 +476,8 @@ def _lookup_ioc_type( batch_index = 0 obs_batch = [] - # pylint: disable=too-many-branches - def _parse_vt_results( - self, + def _parse_vt_results( # noqa:PLR0913 + self: Self, vt_results: str | list | dict | None, observable: str, ioc_type: str, @@ -524,8 +527,7 @@ def _parse_vt_results( else: observables = [observable] - # pylint: disable=consider-using-enumerate - for result_idx in range(len(results_to_parse)): + for result_idx, _ in enumerate(results_to_parse): df_dict_vtresults: pd.DataFrame = self._parse_single_result( results_to_parse[result_idx], ioc_type, @@ -568,10 +570,9 @@ def _parse_vt_results( ) self.results = new_results - # pylint enable=locally-disabled, C0200 def _parse_single_result( - self, + self: Self, results_dict: Mapping[str, Any], ioc_type: str, ) -> pd.DataFrame: @@ -654,7 +655,7 @@ def _parse_single_result( ) def _validate_observable( - self, + self: Self, observable: str, ioc_type: str, idx: Hashable, @@ -725,7 +726,7 @@ def _validate_observable( return pp_observable def _check_duplicate_submission( - self, + self: Self, observable: str, ioc_type: str, source_index: Hashable, @@ -795,7 +796,7 @@ def _check_duplicate_submission( return DuplicateStatus(is_dup=False, status="ok") def _add_invalid_input_result( - self, + self: Self, observable: str, ioc_type: str, status: str, @@ -829,7 +830,7 @@ def _add_invalid_input_result( self.results = new_results def _vt_submit_request( - self, + self: Self, submission_string: str, vt_param: VTParams, ) -> tuple[dict[Any, Any] | None, int]: @@ -881,7 +882,7 @@ def _vt_submit_request( return None, response.status_code @classmethod - def _get_vt_api_url(cls, api_type: str) -> str: + def _get_vt_api_url(cls: type[Self], api_type: str) -> str: """ Return the VirusTotal API URL for the supplied type. @@ -893,13 +894,13 @@ def _get_vt_api_url(cls, api_type: str) -> str: return cls._VT_API.format(type=api_type) @classmethod - def _get_supported_vt_ioc_types(cls) -> list[str]: + def _get_supported_vt_ioc_types(cls: type[VTLookup]) -> list[str]: """Return the subset of IoC types supported by VT.""" return [ t for t in cls._SUPPORTED_INPUT_TYPES if cls._VT_TYPE_MAP[t] is not None ] - def _print_status(self, message: str, verbosity_level: int) -> None: + def _print_status(self: Self, message: str, verbosity_level: int) -> None: """ Print a status message depending on the current level of verbosity. @@ -912,4 +913,4 @@ def _print_status(self, message: str, verbosity_level: int) -> None: """ if verbosity_level <= self._verbosity: - print(message) + logger.info(message) diff --git a/msticpy/context/vtlookupv3/vtlookupv3.py b/msticpy/context/vtlookupv3/vtlookupv3.py index 7133c536a..352cecca1 100644 --- a/msticpy/context/vtlookupv3/vtlookupv3.py +++ b/msticpy/context/vtlookupv3/vtlookupv3.py @@ -3,12 +3,14 @@ from __future__ import annotations import asyncio +import logging from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar, Coroutine, Iterable import pandas as pd from IPython.core.display import HTML from IPython.display import display +from typing_extensions import Self from ...common.exceptions import MsticpyImportExtraError from ...common.provider_settings import ProviderSettings, get_provider_settings @@ -39,7 +41,7 @@ extra="vt3", ) from imp_err - +logger: logging.Logger = logging.getLogger(__name__) # pylint: disable=too-many-lines @@ -146,7 +148,7 @@ class VTLookupV3: _SEARCH_API_ENDPOINT: ClassVar[str] = "/intelligence/search" @property - def supported_vt_types(self) -> list[str]: + def supported_vt_types(self: Self) -> list[str]: """ Return list of VirusTotal supported IoC type names. @@ -159,7 +161,7 @@ def supported_vt_types(self) -> list[str]: return [str(i_type) for i_type in self._SUPPORTED_VT_TYPES] @classmethod - def _get_endpoint_name(cls, vt_type: str) -> str: + def _get_endpoint_name(cls: type[Self], vt_type: str) -> str: if VTEntityType(vt_type) not in cls._SUPPORTED_VT_TYPES: error_msg: str = f"Property type {vt_type} not supported" raise KeyError(error_msg) @@ -168,7 +170,7 @@ def _get_endpoint_name(cls, vt_type: str) -> str: @classmethod def _parse_vt_object( - cls, + cls: type[Self], vt_object: vt.object.Object, *, all_props: bool = False, @@ -223,7 +225,7 @@ def _parse_vt_object( ) def __init__( - self, + self: VTLookupV3, vt_key: str | None = None, *, force_nestasyncio: bool = False, @@ -246,7 +248,7 @@ def __init__( self._vt_client = vt.Client(apikey=self._vt_key) async def _lookup_ioc_async( - self, + self: Self, observable: str, vt_type: str, *, @@ -295,7 +297,7 @@ async def _lookup_ioc_async( raise MsticpyVTNoDataError(error_msg) from err def lookup_ioc( - self, + self: Self, observable: str, vt_type: str, *, @@ -331,7 +333,7 @@ def lookup_ioc( self._vt_client.close() async def _lookup_iocs_async( - self, + self: Self, observables_df: pd.DataFrame, observable_column: str = ColumnNames.TARGET.value, observable_type_column: str = ColumnNames.TARGET_TYPE.value, @@ -391,7 +393,7 @@ async def _lookup_iocs_async( ) def lookup_iocs( - self, + self: Self, observables_df: pd.DataFrame, observable_column: str = ColumnNames.TARGET.value, observable_type_column: str = ColumnNames.TARGET_TYPE.value, @@ -429,8 +431,8 @@ def lookup_iocs( finally: self._vt_client.close() - async def _lookup_ioc_relationships_async( # pylint: disable=too-many-locals - self, + async def _lookup_ioc_relationships_async( # pylint: disable=too-many-locals #noqa: PLR0913 + self: Self, observable: str, vt_type: str, relationship: str, @@ -479,15 +481,17 @@ async def _lookup_ioc_relationships_async( # pylint: disable=too-many-locals response = self._vt_client.get_object( f"/{endpoint_name}/{observable}?relationship_counters=true", ) - relationships = response.relationships + relationships: dict[str, Any] = response.relationships limit = ( relationships[relationship]["meta"]["count"] if relationship in relationships else 0 ) except KeyError: - print( - f"ERROR: Could not obtain relationship limit for {vt_type} {observable}", + logger.exception( + "Could not obtain relationship limit for %s %s", + vt_type, + observable, ) return self._item_not_found_df(vt_type=vt_type, observable=observable) @@ -546,8 +550,8 @@ async def _lookup_ioc_relationships_async( # pylint: disable=too-many-locals return result_df - def lookup_ioc_relationships( - self, + def lookup_ioc_relationships( # noqa: PLR0913 + self: Self, observable: str, vt_type: str, relationship: str, @@ -600,7 +604,7 @@ def lookup_ioc_relationships( self._vt_client.close() def lookup_ioc_related( - self, + self: Self, observable: str, vt_type: str, relationship: str, @@ -650,8 +654,8 @@ def lookup_ioc_related( finally: self._vt_client.close() - async def _lookup_iocs_relationships_async( - self, + async def _lookup_iocs_relationships_async( # noqa: PLR0913 + self: Self, observables_df: pd.DataFrame, relationship: str, observable_column: str = ColumnNames.TARGET.value, @@ -680,7 +684,8 @@ async def _lookup_iocs_relationships_async( Returns ------- - Future Relationship Pandas DataFrame with the relationships of each observable. + Future Relationship Pandas DataFrame with the relationships + of each observable. Raises ------ @@ -719,8 +724,8 @@ async def _lookup_iocs_relationships_async( ) ) - def lookup_iocs_relationships( - self, + def lookup_iocs_relationships( # noqa: PLR0913 + self: Self, observables_df: pd.DataFrame, relationship: str, observable_column: str = ColumnNames.TARGET.value, @@ -768,7 +773,7 @@ def lookup_iocs_relationships( self._vt_client.close() def create_vt_graph( - self, + self: Self, relationship_dfs: list[pd.DataFrame], name: str, *, @@ -828,7 +833,7 @@ def create_vt_graph( return graph.graph_id - def get_object(self, vt_id: str, vt_type: str) -> pd.DataFrame: + def get_object(self: Self, vt_id: str, vt_type: str) -> pd.DataFrame: """ Return the full VT object as a DataFrame. @@ -895,7 +900,7 @@ def get_object(self, vt_id: str, vt_type: str) -> pd.DataFrame: self._vt_client.close() def get_file_behavior( - self, + self: Self, file_id: str | None = None, file_summary: dict[str, Any] | None = None, sandbox: str | None = None, @@ -918,6 +923,9 @@ def get_file_behavior( VTFileBehavior """ + if not self._vt_key: + error_msg: str = "VT key is required to retrieve file behavior" + raise ValueError(error_msg) vt_behavior = VTFileBehavior( self._vt_key, file_id=file_id, @@ -927,7 +935,7 @@ def get_file_behavior( return vt_behavior def search( - self, + self: Self, query: str, limit: int = _DEFAULT_SEARCH_LIMIT, ) -> pd.DataFrame: @@ -970,8 +978,8 @@ def search( response_df: pd.DataFrame = self._extract_response(response_list) return timestamps_to_utcdate(response_df) - def iterator( - self, + def iterator( # noqa: PLR0913 + self: Self, path: str, *path_args: str, params: dict[str, Any] | None = None, @@ -993,7 +1001,8 @@ def iterator( path : str Path to API endpoint returning a collection. path_args: dict - A variable number of arguments that are put into any placeholders used in path. + A variable number of arguments that are put + into any placeholders used in path. params: dict Additional parameters passed to the endpoint. cursor: str @@ -1023,7 +1032,7 @@ def iterator( batch_size, ) - def _extract_response(self, response_list: list) -> pd.DataFrame: + def _extract_response(self: Self, response_list: list) -> pd.DataFrame: """ Convert list of dictionaries from search() function to DataFrame. @@ -1167,7 +1176,11 @@ def render_vt_graph( ) @classmethod - def _item_not_found_df(cls, vt_type: str, observable: str) -> pd.DataFrame: + def _item_not_found_df( + cls: type[Self], + vt_type: str, + observable: str, + ) -> pd.DataFrame: not_found_dict: dict[str, str] = { ColumnNames.ID.value: observable, ColumnNames.TYPE.value: vt_type, @@ -1183,7 +1196,7 @@ def _item_not_found_df(cls, vt_type: str, observable: str) -> pd.DataFrame: @classmethod def _relation_not_found_df( - cls, + cls: type[Self], vt_type: str, observable: str, relationship: str, diff --git a/msticpy/data/azure/__init__.py b/msticpy/data/azure/__init__.py index e5875bd07..bc0bc1240 100644 --- a/msticpy/data/azure/__init__.py +++ b/msticpy/data/azure/__init__.py @@ -19,7 +19,6 @@ from ...context.azure.azure_data import AzureData # noqa: F401 from ...context.azure.sentinel_core import MicrosoftSentinel # noqa: F401 - WARN_MSSG = ( "This module has moved to msticpy.context.azure\n" "Please change your import to reflect this new location." diff --git a/msticpy/datamodel/entities/entity.py b/msticpy/datamodel/entities/entity.py index fd3860501..cb17aca12 100644 --- a/msticpy/datamodel/entities/entity.py +++ b/msticpy/datamodel/entities/entity.py @@ -4,6 +4,7 @@ # license information. # -------------------------------------------------------------------------- """Entity Entity class.""" +from __future__ import annotations import json import pprint import typing @@ -63,7 +64,11 @@ class Entity(ABC, Node): ID_PROPERTIES: List[str] = [] JSONEncoder = _EntityJSONEncoder - def __init__(self, src_entity: Mapping[str, Any] = None, **kwargs): + def __init__( + self: Entity, + src_entity: Mapping[str, Any] | None = None, + **kwargs, + ) -> None: """ Create a new instance of an entity. @@ -107,7 +112,11 @@ def __init__(self, src_entity: Mapping[str, Any] = None, **kwargs): self.__dict__.update(kwargs) @classmethod - def create(cls, src_entity: Mapping[str, Any] = None, **kwargs) -> "Entity": + def create( + cls, + src_entity: Mapping[str, Any] | None = None, + **kwargs, + ) -> "Entity": """ Create an entity from a mapping type (e.g. pd.Series) or dict or kwargs. @@ -191,7 +200,8 @@ def _instantiate_from_entity(self, attr, val, src_entity): if isinstance(val, type) and issubclass(val, Entity): entity_type = val self[attr] = Entity.instantiate_entity( - src_entity[attr], entity_type=entity_type + src_entity[attr], + entity_type=entity_type, ) if isinstance(self[attr], Entity): self.add_edge(self[attr], edge_attrs={"name": attr}) diff --git a/msticpy/datamodel/entities/ip_address.py b/msticpy/datamodel/entities/ip_address.py index fa099ea4d..70de941dd 100644 --- a/msticpy/datamodel/entities/ip_address.py +++ b/msticpy/datamodel/entities/ip_address.py @@ -4,8 +4,9 @@ # license information. # -------------------------------------------------------------------------- """IpAddress Entity class.""" +from __future__ import annotations from ipaddress import IPv4Address, IPv6Address, ip_address -from typing import Any, List, Mapping, Optional, Union +from typing import Any, Mapping from ..._version import VERSION from ...common.utility import export @@ -36,14 +37,14 @@ class IpAddress(Entity): """ - ID_PROPERTIES = ["Address"] + ID_PROPERTIES: list[str] = ["Address"] def __init__( - self, - src_entity: Mapping[str, Any] = None, - src_event: Mapping[str, Any] = None, + self: IpAddress, + src_entity: Mapping[str, Any] | None = None, + src_event: Mapping[str, Any] | None = None, **kwargs, - ): + ) -> None: """ Create a new instance of the entity type. @@ -65,8 +66,18 @@ def __init__( """ self.Address: str = "" - self.Location: Optional[GeoLocation] = None - self.ThreatIntelligence: List[Threatintelligence] = [] + self.Location: GeoLocation | None = None + self.ThreatIntelligence: list[Threatintelligence] = [] + self.hostname: str | None = None + self.SourceComputerId: str | None = None + self.OSType: str | None = None + self.OSName: str | None = None + self.OSVMajorVersion: str | None = None + self.OSVMinorVersion: str | None = None + self.ComputerEnvironment: str | None = None + self.OmsSolutions: list[str] | None = None + self.VMUUID: str | None = None + self.SubscriptionId: str | None = None super().__init__(src_entity=src_entity, **kwargs) if src_event is not None and "Location" in src_event: @@ -78,7 +89,7 @@ def __init__( self.Address = src_event["Address"] @property - def ip_address(self) -> Union[IPv4Address, IPv6Address, None]: + def ip_address(self) -> IPv4Address | IPv6Address | None: """Return a python IP address object from the entity property.""" try: return ip_address(self.Address) @@ -99,7 +110,7 @@ def name_str(self) -> str: """Return Entity Name.""" return self.Address or self.__class__.__name__ - _entity_schema = { + _entity_schema: dict[str, Any] = { # Address (type System.String) "Address": None, # Location (type Microsoft.Azure.Security.Detection.AlertContracts @@ -116,4 +127,4 @@ def name_str(self) -> str: # Alias for IpAddress -Ip = IpAddress +Ip: type[IpAddress] = IpAddress diff --git a/msticpy/init/azure_ml_tools.py b/msticpy/init/azure_ml_tools.py index 974ddebd3..d09c64103 100644 --- a/msticpy/init/azure_ml_tools.py +++ b/msticpy/init/azure_ml_tools.py @@ -554,9 +554,9 @@ def _check_aml_auth_method_order(): if msi_lower_than_cli or msi_lower_than_devcode: return _disp_html(_MSI_WARNING) - logging.warning("MSI authentication is higher priority than CLI or DeviceCode.") + logger.warning("MSI authentication is higher priority than CLI or DeviceCode.") if "msi" in current_methods: _disp_html("Reordering auth_methods to move MSI to lowest priority.") current_methods.remove("msi") current_methods.append("msi") - logging.info("Reordering auth_methods to move MSI to the end.") + logger.info("Reordering auth_methods to move MSI to the end.") diff --git a/msticpy/init/pivot_core/pivot_register_reader.py b/msticpy/init/pivot_core/pivot_register_reader.py index 27bd659ac..94a3c14b5 100644 --- a/msticpy/init/pivot_core/pivot_register_reader.py +++ b/msticpy/init/pivot_core/pivot_register_reader.py @@ -5,6 +5,7 @@ # -------------------------------------------------------------------------- """Reads pivot registration config files.""" from __future__ import annotations + import importlib import warnings from typing import Any, Callable, Generator diff --git a/msticpy/init/pivot_init/vt_pivot.py b/msticpy/init/pivot_init/vt_pivot.py index cd8590950..dbafd7f84 100644 --- a/msticpy/init/pivot_init/vt_pivot.py +++ b/msticpy/init/pivot_init/vt_pivot.py @@ -158,8 +158,7 @@ def _create_pivots(api_scope: Union[str, VTAPIScope, None]): else: scope = api_scope try: - # pylint: disable=possibly-used-before-assignment - vt_client = VTLookupV3() + vt_client = VTLookupV3() # pylint:disable=possibly-used-before-assignment except (ValueError, AttributeError): # Can't initialize VTLookup - don't add the pivot funcs return {} diff --git a/msticpy/init/user_config.py b/msticpy/init/user_config.py index aef49a996..28ac6f0c1 100644 --- a/msticpy/init/user_config.py +++ b/msticpy/init/user_config.py @@ -242,7 +242,7 @@ def _load_azsent_api(comp_settings=None, **kwargs): res_id = comp_settings.pop("res_id", None) if res_id: - az_sent = MicrosoftSentinel(res_id=res_id) + az_sent = MicrosoftSentinel(resource_id=res_id) else: az_sent = MicrosoftSentinel() connect = comp_settings.pop("connect", True) diff --git a/msticpy/transform/base64unpack.py b/msticpy/transform/base64unpack.py index a4252e925..4eef588a4 100644 --- a/msticpy/transform/base64unpack.py +++ b/msticpy/transform/base64unpack.py @@ -815,9 +815,13 @@ def get_hashes(binary: bytes) -> Dict[str, str]: hash_dict = {} for hash_type in ["md5", "sha1", "sha256"]: if hash_type == "md5": - hash_alg = hashlib.md5() # nosec + hash_alg = ( + hashlib.md5() # nosec # CodeQL [SM02167] Compatibility for TI providers + ) elif hash_type == "sha1": - hash_alg = hashlib.sha1() # nosec + hash_alg = ( + hashlib.sha1() # nosec # CodeQL [SM02167] Compatibility for TI providers + ) else: hash_alg = hashlib.sha256() hash_alg.update(binary) diff --git a/msticpy/transform/iocextract.py b/msticpy/transform/iocextract.py index 496baa614..777a6b32d 100644 --- a/msticpy/transform/iocextract.py +++ b/msticpy/transform/iocextract.py @@ -22,15 +22,18 @@ regular expressions used at runtime. """ +from __future__ import annotations import re import warnings -from collections import defaultdict, namedtuple +from collections import defaultdict from enum import Enum -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any from urllib.parse import unquote import pandas as pd +from attr import dataclass +from typing_extensions import Self from .._version import VERSION from ..common.utility import check_kwargs, export @@ -40,13 +43,21 @@ __author__ = "Ian Hellen" -def _compile_regex(regex): - return re.compile(regex, re.I | re.X | re.M) +def _compile_regex(regex) -> re.Pattern[str]: + return re.compile(regex, re.IGNORECASE | re.VERBOSE | re.MULTILINE) -IoCPattern = namedtuple("IoCPattern", ["ioc_type", "comp_regex", "priority", "group"]) +@dataclass +class IoCPattern: + """Define patterns for IOC.""" -_RESULT_COLS = ["IoCType", "Observable", "SourceIndex", "Input"] + ioc_type: str + comp_regex: re.Pattern[str] + priority: int + group: str | None + + +_RESULT_COLS: list[str] = ["IoCType", "Observable", "SourceIndex", "Input"] @export @@ -71,7 +82,7 @@ class IoCType(Enum): # pylint: enable=invalid-name @classmethod - def parse(cls, value: str) -> "IoCType": + def parse(cls: type[Self], value: str) -> IoCType: """ Return parsed IoCType of string. @@ -175,16 +186,16 @@ class IoCExtract: SHA1_REGEX = r"(?:^|[^A-Fa-f0-9])(?P[A-Fa-f0-9]{40})(?:$|[^A-Fa-f0-9])" SHA256_REGEX = r"(?:^|[^A-Fa-f0-9])(?P[A-Fa-f0-9]{64})(?:$|[^A-Fa-f0-9])" - _content_regex: Dict[str, IoCPattern] = {} - _content_df_regex: Dict[str, IoCPattern] = {} + _content_regex: dict[str, IoCPattern] = {} + _content_df_regex: dict[str, IoCPattern] = {} - def __init__(self, defanged: bool = True): + def __init__(self: IoCExtract, defanged: bool = True) -> None: """ Initialize new instance of IoCExtract. Parameters ---------- - defanged : bool, optional + defanged : bool If True, the regex will be used to match defanged IoC patterns """ @@ -214,7 +225,10 @@ def __init__(self, defanged: bool = True): # Email addresses (lower priority than URLs) self.add_ioc_type(IoCType.email.name, self.EMAIL_REGEX, 1, defang_pattern=False) self.add_ioc_type( - IoCType.email.name, self.EMAIL_DF_REGEX, 1, defang_pattern=True + IoCType.email.name, + self.EMAIL_DF_REGEX, + 1, + defang_pattern=True, ) # File paths self.add_ioc_type(IoCType.windows_path.name, self.WINPATH_REGEX, 3) @@ -236,13 +250,13 @@ def __init__(self, defanged: bool = True): # Public members def add_ioc_type( - self, + self: Self, ioc_type: str, ioc_regex: str, priority: int = 0, - group: str = None, - defang_pattern: Optional[bool] = None, - ): + group: str | None = None, + defang_pattern: bool | None = None, + ) -> None: """ Add an IoC type and regular expression to use to the built-in set. @@ -315,14 +329,13 @@ def ioc_df_types(self) -> dict: """ return self._content_df_regex - # pylint: disable=too-many-locals def extract( self, - src: str = None, - data: pd.DataFrame = None, - columns: List[str] = None, + src: str | None = None, + data: pd.DataFrame | None = None, + columns: list[str] | None = None, **kwargs, - ) -> Union[Dict[str, Set[str]], pd.DataFrame]: + ) -> dict[str, set[str]] | pd.DataFrame: """ Extract IoCs from either a string or pandas DataFrame. @@ -408,7 +421,7 @@ def extract( " in supplied DataFrame", ) - result_rows: List[pd.Series] = [] + result_rows: list[pd.Series] = [] for idx, datarow in data.iterrows(): result_rows.extend( self._search_in_row(datarow, idx, columns, ioc_types_to_use, defanged) @@ -416,15 +429,14 @@ def extract( self._ignore_tld = ignore_tld_current return pd.DataFrame(data=result_rows, columns=_RESULT_COLS) - # pylint: disable=too-many-arguments def _search_in_row( self, datarow: pd.Series, idx: Any, - columns: List[str], - ioc_types_to_use: List[str], + columns: list[str], + ioc_types_to_use: list[str], defanged: bool = True, - ) -> List[pd.Series]: + ) -> list[pd.Series]: """Return results for a single input row.""" result_rows = [] for col in columns: @@ -440,7 +452,7 @@ def _search_in_row( return result_rows def extract_df( - self, data: pd.DataFrame, columns: Union[str, List[str]], **kwargs + self, data: pd.DataFrame, columns: str | list[str], **kwargs ) -> pd.DataFrame: """ Extract IoCs from either a pandas DataFrame. @@ -521,8 +533,8 @@ def extract_df( return pd.DataFrame(data=result_rows, columns=_RESULT_COLS) def _get_ioc_types_to_use( - self, ioc_types: Optional[List[str]], include_paths: bool - ) -> List[str]: + self, ioc_types: list[str] | None, include_paths: bool + ) -> list[str]: # Use only requested IoC Type patterns if ioc_types: ioc_types_to_use = list(set(ioc_types)) @@ -540,7 +552,7 @@ def validate( input_str: str, ioc_type: str, ignore_tlds: bool = False, - defanged: Optional[bool] = None, + defanged: bool | None = None, ) -> bool: """ Check that `input_str` matches the regex for the specified `ioc_type`. @@ -586,7 +598,7 @@ def validate( pattern_match = rgx.comp_regex.fullmatch(input_str) validated = self._validate_tld(input_str) if val_type == "dns" else True self._ignore_tld = ignore_tld_current - return pattern_match and validated + return bool(pattern_match) and validated @staticmethod def file_hash_type(file_hash: str) -> IoCType: @@ -652,15 +664,17 @@ def _validate_tld(self, domain: str) -> bool: def _scan_for_iocs( self, src: str, - ioc_types: List[str] = None, + ioc_types: list[str] | None = None, defanged: bool = True, - ) -> Dict[str, Set[str]]: + ) -> dict[str, set[str]]: """Return IoCs found in the string.""" - ioc_results: Dict[str, Set] = defaultdict(set) - iocs_found: Dict[str, Tuple[str, int]] = {} + ioc_results: dict[str, set] = defaultdict(set) + iocs_found: dict[str, tuple[str, int]] = {} # pylint: disable=too-many-nested-blocks - ioc_regexes = self._content_df_regex if defanged else self._content_regex + ioc_regexes: dict[str, IoCPattern] = ( + self._content_df_regex if defanged else self._content_regex + ) for ioc_type, rgx_def in ioc_regexes.items(): if ioc_types and ioc_type not in ioc_types: continue diff --git a/msticpy/transform/proc_tree_build_winlx.py b/msticpy/transform/proc_tree_build_winlx.py index 864fa4968..1d343dd3d 100644 --- a/msticpy/transform/proc_tree_build_winlx.py +++ b/msticpy/transform/proc_tree_build_winlx.py @@ -4,9 +4,9 @@ # license information. # -------------------------------------------------------------------------- """Process Tree builder for Windows security and Linux auditd events.""" +from dataclasses import asdict from typing import Tuple -import attr import pandas as pd from .._version import VERSION @@ -23,7 +23,7 @@ def extract_process_tree( procs: pd.DataFrame, - schema: "ProcSchema", # type: ignore # noqa: F821 + schema: ProcSchema, debug: bool = False, ) -> pd.DataFrame: """ @@ -101,7 +101,7 @@ def _clean_proc_data( procs_cln = _num_cols_to_str(procs_cln, schema) if schema.logon_id not in procs_cln.columns: - schema = ProcSchema(**(attr.asdict(schema))) + schema = ProcSchema(**(asdict(schema))) schema.logon_id = None # type: ignore if schema.logon_id: @@ -141,7 +141,7 @@ def _num_cols_to_str( """ # Change float/int cols in our core schema to force int schema_cols = [ - col for col in attr.asdict(schema).values() if col and col in procs_cln.columns + col for col in asdict(schema).values() if col and col in procs_cln.columns ] force_int_cols = { col: "int" diff --git a/msticpy/transform/proc_tree_schema.py b/msticpy/transform/proc_tree_schema.py index 4c4db75b0..b150eab88 100644 --- a/msticpy/transform/proc_tree_schema.py +++ b/msticpy/transform/proc_tree_schema.py @@ -6,10 +6,11 @@ """Process Tree Schema module for Process Tree Visualization.""" from __future__ import annotations +from dataclasses import asdict, dataclass, field, fields, MISSING from typing import Any, ClassVar -import attr import pandas as pd +from typing_extensions import Self from .._version import VERSION from ..common.exceptions import MsticpyUserError @@ -27,8 +28,8 @@ class ProcessTreeSchemaException(MsticpyUserError): ) -@attr.s(auto_attribs=True) -class ProcSchema: +@dataclass +class ProcSchema: # pylint: disable=too-many-instance-attributes """ Property name lookup for Process event schema. @@ -45,30 +46,30 @@ class ProcSchema: process_id: str parent_id: str time_stamp: str - cmd_line: str | None = None - path_separator: str = "\\" - user_name: str | None = None - logon_id: str | None = None - host_name_column: str | None = None - parent_name: str | None = None - target_logon_id: str | None = None - user_id: str | None = None - event_id_column: str | None = None - event_id_identifier: Any | None = None - - def __eq__(self, other) -> bool: + cmd_line: str | None = field(default=None) + path_separator: str = field(default="\\") + user_name: str | None = field(default=None) + logon_id: str | None = field(default=None) + host_name_column: str | None = field(default=None) + parent_name: str | None = field(default=None) + target_logon_id: str | None = field(default=None) + user_id: str | None = field(default=None) + event_id_column: str | None = field(default=None) + event_id_identifier: Any | None = field(default=None) + + def __eq__(self: Self, other: object) -> bool: """Return False if any non-blank field values are unequal.""" if not isinstance(other, ProcSchema): return False - self_dict: dict[str, Any] = attr.asdict(self) + self_dict: dict[str, Any] = asdict(self) return not any( value and value != self_dict[field] - for field, value in attr.asdict(other).items() + for field, value in asdict(other).items() ) @property - def required_columns(self) -> list[str]: + def required_columns(self: Self) -> list[str]: """Return columns required for Init.""" return [ "process_name", @@ -80,34 +81,34 @@ def required_columns(self) -> list[str]: ] @property - def column_map(self) -> dict[str, str]: + def column_map(self: Self) -> dict[str, str]: """Return a dictionary that maps fields to schema names.""" return { prop: str(col) - for prop, col in attr.asdict(self).items() + for prop, col in asdict(self).items() if prop not in {"path_separator", "event_id_identifier"} } @property - def columns(self) -> list[str]: + def columns(self: Self) -> list[str]: """Return list of columns in schema data source.""" return [ col - for prop, col in attr.asdict(self).items() + for prop, col in asdict(self).items() if prop not in {"path_separator", "event_id_identifier"} ] - def get_df_cols(self, data: pd.DataFrame) -> list[str]: + def get_df_cols(self: Self, data: pd.DataFrame) -> list[str]: """Return the subset of columns that are present in `data`.""" return [col for col in self.columns if col in data.columns] @property - def host_name(self) -> str | None: + def host_name(self: Self) -> str | None: """Return host name column.""" return self.host_name_column @property - def event_type_col(self) -> str: + def event_type_col(self: Self) -> str: """ Return the column name containing the event identifier. @@ -129,7 +130,7 @@ def event_type_col(self) -> str: ) @property - def event_filter(self) -> Any: + def event_filter(self: Self) -> Any: """ Return the event type/ID to process for the current schema. @@ -151,15 +152,15 @@ def event_filter(self) -> Any: ) @classmethod - def blank_schema_dict(cls) -> dict[str, Any]: + def blank_schema_dict(cls: type[Self]) -> dict[str, Any]: """Return blank schema dictionary.""" return { - field: ( + cls_field.name: ( "required" - if (attrib.default or attrib.default == attr.NOTHING) + if (cls_field.default or cls_field.default == MISSING) else None ) - for field, attrib in attr.fields_dict(cls).items() + for cls_field in fields(cls) } diff --git a/test_cache.ipynb b/test_cache.ipynb new file mode 100644 index 000000000..cbf65f32d --- /dev/null +++ b/test_cache.ipynb @@ -0,0 +1,753 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import msticpy as mp" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "prov: mp.QueryProvider = mp.QueryProvider(\"LogAnalytics\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Attempting connection to Key Vault using cli credentials..." + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "done
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "connected\n" + ] + } + ], + "source": [ + "prov.connect(\n", + " cluster=\"zsocngsadxfollew1adx01\",\n", + " database=\"AXA\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
TenantIdSourceSystemTimeGeneratedResourceIdOperationNameOperationVersionCategoryResultTypeResultSignatureResultDescription...ResourceTenantIdHomeTenantIdUniqueTokenIdentifierSessionLifetimePoliciesAutonomousSystemNumberAuthenticationProtocolCrossTenantAccessTypeAppliedConditionalAccessPoliciesRiskLevelType
016b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:52:34.633771+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bDULTjKv_ek-7uiAT7skBAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
116b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:53:36.764430+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05f3030f0a-998c-4a82-852c-1d0777740cf5M7bly48bj0emiyJHbjMFAA[{\"expirationRequirement\":\"signInFrequencyPeri...203724noneb2bCollaborationSigninLogs
216b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:54:28.251880+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97b_BpEt8ADkEiAoH3lJhICAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
316b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:55:15.628636+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bYrMv8BqKQEWnUhqP6ssUAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
416b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:56:52.007838+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bWJIcf25gXEu4K3HkL_8GAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
\n", + "

5 rows × 76 columns

\n", + "
" + ] + }, + "metadata": { + "arguments": { + "default_time_params": true, + "time_span": { + "end": "2024-02-29T18:25:26.119774Z", + "start": "2024-02-27T18:25:26.119774Z" + } + }, + "data": "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", + "hash": "e845aa5c948e2e7bf1bef6cbff79b976b9730912654e99f53e7189eddb6d5b74", + "name": "list_aad_signins_for_account", + "query": " let accountName = \"!!DEFAULT!!\"; let account = case( accountName has \"@\", tostring(split(accountName, \"@\")[0]), accountName has \"\\\\\", tostring(split(accountName, \"\\\\\")[1]), accountName ); SigninLogs | where TimeGenerated >= datetime(2024-02-27T18:25:26.119774Z) | where TimeGenerated <= datetime(2024-02-29T18:25:26.119774Z) | where ( (account == \"!!DEFAULT!!\" or UserPrincipalName has account) and (\"!!DEFAULT!!\" == \"!!DEFAULT!!\" or UserId =~ \"!!DEFAULT!!\") ) ", + "timestamp": "2024-02-28T18:25:35.375276Z" + }, + "output_type": "display_data" + } + ], + "source": [ + "data = prov.Azure.list_aad_signins_for_account(cache_path=\"/home/azureuser/msticpy/test_cache.ipynb\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
TenantIdSourceSystemTimeGeneratedResourceIdOperationNameOperationVersionCategoryResultTypeResultSignatureResultDescription...ResourceTenantIdHomeTenantIdUniqueTokenIdentifierSessionLifetimePoliciesAutonomousSystemNumberAuthenticationProtocolCrossTenantAccessTypeAppliedConditionalAccessPoliciesRiskLevelType
016b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:52:34.633771+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bDULTjKv_ek-7uiAT7skBAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
116b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:53:36.764430+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05f3030f0a-998c-4a82-852c-1d0777740cf5M7bly48bj0emiyJHbjMFAA[{\"expirationRequirement\":\"signInFrequencyPeri...203724noneb2bCollaborationSigninLogs
216b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:54:28.251880+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97b_BpEt8ADkEiAoH3lJhICAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
316b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:55:15.628636+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bYrMv8BqKQEWnUhqP6ssUAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
416b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 09:56:52.007838+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bWJIcf25gXEu4K3HkL_8GAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
..................................................................
191016b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 17:25:03.891589+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs50074NoneStrong Authentication is required....ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bq0JDa_fU3kuJNTPzHUEPAA[]noneb2bCollaborationSigninLogs
191116b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 17:25:42.408429+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bq0JDa_fU3kuJNTPzHUEPAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
191216b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 18:04:01.890302+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bcboApe6kKEuV5nU94AsSAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
191316b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 18:18:34.658299+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bFKpw3J1eJ0CUF1aE-AYMAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
191416b93757-aba0-41a6-886d-6ef95c755e76Azure AD2024-02-28 18:21:09.122707+00:00/tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/...Sign-in activity1.0SignInLogs0None...ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05396b38cc-aa65-492b-bb0e-3d94ed25a97bquCk9y2To0SwcsSIp88RAA[{\"expirationRequirement\":\"signInFrequencyPeri...43722noneb2bCollaborationSigninLogs
\n", + "

1915 rows × 76 columns

\n", + "
" + ], + "text/plain": [ + " TenantId SourceSystem \\\n", + "0 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "1 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "2 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "3 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "4 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "... ... ... \n", + "1910 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "1911 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "1912 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "1913 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "1914 16b93757-aba0-41a6-886d-6ef95c755e76 Azure AD \n", + "\n", + " TimeGenerated \\\n", + "0 2024-02-28 09:52:34.633771+00:00 \n", + "1 2024-02-28 09:53:36.764430+00:00 \n", + "2 2024-02-28 09:54:28.251880+00:00 \n", + "3 2024-02-28 09:55:15.628636+00:00 \n", + "4 2024-02-28 09:56:52.007838+00:00 \n", + "... ... \n", + "1910 2024-02-28 17:25:03.891589+00:00 \n", + "1911 2024-02-28 17:25:42.408429+00:00 \n", + "1912 2024-02-28 18:04:01.890302+00:00 \n", + "1913 2024-02-28 18:18:34.658299+00:00 \n", + "1914 2024-02-28 18:21:09.122707+00:00 \n", + "\n", + " ResourceId OperationName \\\n", + "0 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "1 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "2 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "3 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "4 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "... ... ... \n", + "1910 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "1911 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "1912 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "1913 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "1914 /tenants/ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05/... Sign-in activity \n", + "\n", + " OperationVersion Category ResultType ResultSignature \\\n", + "0 1.0 SignInLogs 0 None \n", + "1 1.0 SignInLogs 0 None \n", + "2 1.0 SignInLogs 0 None \n", + "3 1.0 SignInLogs 0 None \n", + "4 1.0 SignInLogs 0 None \n", + "... ... ... ... ... \n", + "1910 1.0 SignInLogs 50074 None \n", + "1911 1.0 SignInLogs 0 None \n", + "1912 1.0 SignInLogs 0 None \n", + "1913 1.0 SignInLogs 0 None \n", + "1914 1.0 SignInLogs 0 None \n", + "\n", + " ResultDescription ... \\\n", + "0 ... \n", + "1 ... \n", + "2 ... \n", + "3 ... \n", + "4 ... \n", + "... ... ... \n", + "1910 Strong Authentication is required. ... \n", + "1911 ... \n", + "1912 ... \n", + "1913 ... \n", + "1914 ... \n", + "\n", + " ResourceTenantId \\\n", + "0 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "1 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "2 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "3 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "4 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "... ... \n", + "1910 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "1911 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "1912 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "1913 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "1914 ef53a60b-603f-4cc2-a4fc-a8fd4f8e2b05 \n", + "\n", + " HomeTenantId UniqueTokenIdentifier \\\n", + "0 396b38cc-aa65-492b-bb0e-3d94ed25a97b DULTjKv_ek-7uiAT7skBAA \n", + "1 f3030f0a-998c-4a82-852c-1d0777740cf5 M7bly48bj0emiyJHbjMFAA \n", + "2 396b38cc-aa65-492b-bb0e-3d94ed25a97b _BpEt8ADkEiAoH3lJhICAA \n", + "3 396b38cc-aa65-492b-bb0e-3d94ed25a97b YrMv8BqKQEWnUhqP6ssUAA \n", + "4 396b38cc-aa65-492b-bb0e-3d94ed25a97b WJIcf25gXEu4K3HkL_8GAA \n", + "... ... ... \n", + "1910 396b38cc-aa65-492b-bb0e-3d94ed25a97b q0JDa_fU3kuJNTPzHUEPAA \n", + "1911 396b38cc-aa65-492b-bb0e-3d94ed25a97b q0JDa_fU3kuJNTPzHUEPAA \n", + "1912 396b38cc-aa65-492b-bb0e-3d94ed25a97b cboApe6kKEuV5nU94AsSAA \n", + "1913 396b38cc-aa65-492b-bb0e-3d94ed25a97b FKpw3J1eJ0CUF1aE-AYMAA \n", + "1914 396b38cc-aa65-492b-bb0e-3d94ed25a97b quCk9y2To0SwcsSIp88RAA \n", + "\n", + " SessionLifetimePolicies \\\n", + "0 [{\"expirationRequirement\":\"signInFrequencyPeri... \n", + "1 [{\"expirationRequirement\":\"signInFrequencyPeri... \n", + "2 [{\"expirationRequirement\":\"signInFrequencyPeri... \n", + "3 [{\"expirationRequirement\":\"signInFrequencyPeri... \n", + "4 [{\"expirationRequirement\":\"signInFrequencyPeri... \n", + "... ... \n", + "1910 [] \n", + "1911 [{\"expirationRequirement\":\"signInFrequencyPeri... \n", + "1912 [{\"expirationRequirement\":\"signInFrequencyPeri... \n", + "1913 [{\"expirationRequirement\":\"signInFrequencyPeri... \n", + "1914 [{\"expirationRequirement\":\"signInFrequencyPeri... \n", + "\n", + " AutonomousSystemNumber AuthenticationProtocol CrossTenantAccessType \\\n", + "0 43722 none b2bCollaboration \n", + "1 203724 none b2bCollaboration \n", + "2 43722 none b2bCollaboration \n", + "3 43722 none b2bCollaboration \n", + "4 43722 none b2bCollaboration \n", + "... ... ... ... \n", + "1910 none b2bCollaboration \n", + "1911 43722 none b2bCollaboration \n", + "1912 43722 none b2bCollaboration \n", + "1913 43722 none b2bCollaboration \n", + "1914 43722 none b2bCollaboration \n", + "\n", + " AppliedConditionalAccessPolicies RiskLevel Type \n", + "0 SigninLogs \n", + "1 SigninLogs \n", + "2 SigninLogs \n", + "3 SigninLogs \n", + "4 SigninLogs \n", + "... ... ... ... \n", + "1910 SigninLogs \n", + "1911 SigninLogs \n", + "1912 SigninLogs \n", + "1913 SigninLogs \n", + "1914 SigninLogs \n", + "\n", + "[1915 rows x 76 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "msticpy", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} \ No newline at end of file diff --git a/tests/context/azure/sentinel_test_fixtures.py b/tests/context/azure/sentinel_test_fixtures.py index ef57d4cb4..088041a3c 100644 --- a/tests/context/azure/sentinel_test_fixtures.py +++ b/tests/context/azure/sentinel_test_fixtures.py @@ -4,12 +4,13 @@ # license information. # -------------------------------------------------------------------------- """Sentinel test fixtures.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from msticpy import VERSION -from msticpy.context.azure import MicrosoftSentinel +from msticpy.context.azure.azure_data import AzureData +from msticpy.context.azure.sentinel_core import MicrosoftSentinel from ...unit_test_lib import custom_mp_config, get_test_data_path @@ -43,7 +44,7 @@ def _set_default_workspace(self, sub_id, workspace=None): @pytest.fixture @patch(f"{MicrosoftSentinel.__module__}.get_token") -@patch(f"{MicrosoftSentinel.__module__}.AzureData.connect") +@patch.object(AzureData, "connect") def sent_loader(mock_creds, get_token, monkeypatch): """Generate MicrosoftSentinel instance for testing.""" monkeypatch.setattr( @@ -58,6 +59,7 @@ def sent_loader(mock_creds, get_token, monkeypatch): # sub_id="fd09863b-5cec-4833-ab9c-330ad07b0c1a", res_grp="RG", ws_name="WSName" workspace="WSName" ) + setattr(sentinel, "credentials", MagicMock()) sentinel.connect() sentinel.connected = True sentinel._token = "fd09863b-5cec-4833-ab9c-330ad07b0c1a" diff --git a/tests/context/azure/test_sentinel_core.py b/tests/context/azure/test_sentinel_core.py index 746d1bdde..23ca35c27 100644 --- a/tests/context/azure/test_sentinel_core.py +++ b/tests/context/azure/test_sentinel_core.py @@ -10,8 +10,10 @@ import pytest from azure.core.exceptions import ClientAuthenticationError +import msticpy.context.azure from msticpy.common.wsconfig import WorkspaceConfig -from msticpy.context.azure import AzureData, MicrosoftSentinel +from msticpy.context.azure.azure_data import AzureData +from msticpy.context.azure.sentinel_core import MicrosoftSentinel from ...unit_test_lib import custom_mp_config, get_test_data_path @@ -61,7 +63,7 @@ def test_azuresent_init(): assert sentinel_inst.default_workspace_name == "WSName" -@patch(MicrosoftSentinel.__module__ + ".AzureData.connect") +@patch.object(AzureData,"connect") @patch(MicrosoftSentinel.__module__ + ".get_token") def test_azuresent_connect_token(get_token: Mock, az_data_connect: Mock): """Test connect success.""" @@ -73,6 +75,7 @@ def test_azuresent_connect_token(get_token: Mock, az_data_connect: Mock): sentinel_inst = MicrosoftSentinel(res_id=_RES_ID) setattr(sentinel_inst, "set_default_workspace", MagicMock()) + setattr(sentinel_inst, "credentials", MagicMock()) sentinel_inst.connect(auth_methods=["env"], token=token) assert sentinel_inst._token == token @@ -84,6 +87,7 @@ def test_azuresent_connect_token(get_token: Mock, az_data_connect: Mock): res_id=_RES_ID, ) setattr(sentinel_inst, "set_default_workspace", MagicMock()) + setattr(sentinel_inst, "credentials", MagicMock()) sentinel_inst.connect(auth_methods=["env"], tenant_id="12345") assert sentinel_inst._token == token @@ -94,7 +98,7 @@ def test_azuresent_connect_token(get_token: Mock, az_data_connect: Mock): ) -@patch(MicrosoftSentinel.__module__ + ".AzureData.connect") +@patch.object(AzureData, "connect") def test_azuresent_connect_fail(az_data_connect: Mock): """Test connect failure.""" az_data_connect.side_effect = ClientAuthenticationError("Could not authenticate.") @@ -205,7 +209,7 @@ def test_set_default_workspace(mock_res_dets, mock_res, sentinel_inst_loader): "resource_id, workspace_name, subscription_id, resource_group, expected_url", _CONNECT_TESTS, ) -@patch(MicrosoftSentinel.__module__ + ".AzureData.connect") +@patch.object(AzureData, "connect") def test_sentinel_connect( mock_connect, resource_id, @@ -225,6 +229,7 @@ def test_sentinel_connect( with patch( MicrosoftSentinel.__module__ + ".get_token", return_value="test_token" ): + sentinel_inst_loader.credentials = MagicMock() # Call the connect method with test parameters sentinel_inst_loader.connect( tenant_id="test_tenant_id", @@ -275,7 +280,7 @@ def test_sentinel_connect( "resource_id, workspace_name, subscription_id, resource_group, expected_url", _CONNECT_TESTS_2, ) -@patch(MicrosoftSentinel.__module__ + ".AzureData.connect") +@patch.object(AzureData, "connect") def test_sentinel_connect_no_init_params( mock_connect, resource_id, @@ -300,6 +305,7 @@ def test_sentinel_connect_no_init_params( connect_kwargs = {key: val for key, val in connect_kwargs.items() if val} # Call the connect method with test parameters + setattr(sentinel_inst, "credentials", MagicMock()) sentinel_inst.connect(**connect_kwargs) if isinstance(expected_url, str): assert sentinel_inst.url == expected_url diff --git a/tests/context/azure/test_sentinel_dynamic_summary.py b/tests/context/azure/test_sentinel_dynamic_summary.py index 65accbd12..b163fb065 100644 --- a/tests/context/azure/test_sentinel_dynamic_summary.py +++ b/tests/context/azure/test_sentinel_dynamic_summary.py @@ -9,7 +9,7 @@ import uuid from copy import deepcopy from datetime import datetime, timezone -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pandas as pd import pytest @@ -20,6 +20,7 @@ from msticpy.common.exceptions import MsticpyAzureConnectionError from msticpy.common.pkg_config import SettingsDict from msticpy.common.wsconfig import WorkspaceConfig +from msticpy.context.azure.azure_data import AzureData from msticpy.context.azure.sentinel_core import MicrosoftSentinel from msticpy.context.azure.sentinel_dynamic_summary import SentinelQueryProvider from msticpy.context.azure.sentinel_dynamic_summary_types import ( @@ -254,7 +255,7 @@ def _set_default_workspace(self, sub_id, workspace=None): @pytest.fixture @patch(f"{MicrosoftSentinel.__module__}.get_token") -@patch(f"{MicrosoftSentinel.__module__}.AzureData.connect") +@patch.object(AzureData, "connect") def sentinel_loader(mock_creds, get_token, monkeypatch): """Generate MicrosoftSentinel for testing.""" monkeypatch.setattr( @@ -268,12 +269,13 @@ def sentinel_loader(mock_creds, get_token, monkeypatch): get_test_data_path().parent.joinpath("msticpyconfig-test.yaml") ): sent = MicrosoftSentinel( - sub_id=settings.get( + subscription_id=settings.get( "SubscriptionId", "fd09863b-5cec-4833-ab9c-330ad07b0c1a" ), - res_grp=settings.get("ResourceGroup", "RG"), - ws_name=settings.get("WorkspaceName", "Default"), + resource_group=settings.get("ResourceGroup", "RG"), + workspace_name=settings.get("WorkspaceName", "Default"), ) + sent.credentials = MagicMock() sent._default_workspace_name = ws_key sent.connect(workspace=ws_key, token=["PLACEHOLDER"]) # nosec sent.connected = True @@ -416,7 +418,6 @@ def test_new_dynamic_summary(sentinel_loader): summary_id="test_id", name="Test Summary", description="This is a test summary", - data=ti_data, tactics=["discovery", "exploitation"], techniques=["T1000"], search_key="TI stuff", diff --git a/tests/context/azure/test_sentinel_ti.py b/tests/context/azure/test_sentinel_ti.py index e4b5f1620..99cef9992 100644 --- a/tests/context/azure/test_sentinel_ti.py +++ b/tests/context/azure/test_sentinel_ti.py @@ -137,9 +137,7 @@ } ], "lastUpdatedTimeUtc": "2022-09-30T21:21:28.6388624Z", - "objectMarkingRefs": [ - "marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da" - ], + "objectMarkingRefs": ["marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da"], "source": "Microsoft Emerging Threat Feed", "displayName": "Microsoft Identified Botnet", "threatIntelligenceTags": ["test"], @@ -276,7 +274,7 @@ def test_sent_ti_query_indicator(sent_loader): respx.post(re.compile(r"https://management\.azure\.com/.*")).respond( 200, json=_TI_RESULTS ) - sent_loader.query_indicators(minConfidence=10, maxConfidence=100) + sent_loader.query_indicators(min_confidence=10, max_confidence=100) @respx.mock diff --git a/tests/context/test_ip_utils.py b/tests/context/test_ip_utils.py index 6d670a0ab..c2d18deef 100644 --- a/tests/context/test_ip_utils.py +++ b/tests/context/test_ip_utils.py @@ -20,6 +20,7 @@ get_ip_type, get_whois_df, ip_whois, + _IpWhoIsResult, ) from ..unit_test_lib import TEST_DATA_PATH, get_test_data_path @@ -452,8 +453,8 @@ def test_get_whois(mock_asn_whois_query): respx.get(re.compile(r"http://rdap\.arin\.net/.*")).respond(200, json=RDAP_RESPONSE) ms_ip = "13.107.4.50" ms_asn = "MICROSOFT-CORP" - asn, _ = ip_whois(ms_ip) - check.is_in(ms_asn, asn) + asn: _IpWhoIsResult = ip_whois(ms_ip) + check.is_in(ms_asn, asn.name) @respx.mock @@ -504,9 +505,7 @@ def test_asn_query_features(mock_asn_whois_query): """Test ASN query features""" # mock the potaroo request html_resp = get_test_data_path().joinpath("potaroo.html").read_bytes() - respx.get("https://bgp.potaroo.net/cidr/autnums.html").respond( - 200, content=html_resp - ) + respx.get("https://bgp.potaroo.net/cidr/autnums.html").respond(200, content=html_resp) # mock the whois response mock_asn_whois_query.return_value = ASN_RESPONSE_2 # run tests diff --git a/tests/context/test_vtlookupv3.py b/tests/context/test_vtlookupv3.py index ba621a785..5becd8573 100644 --- a/tests/context/test_vtlookupv3.py +++ b/tests/context/test_vtlookupv3.py @@ -46,7 +46,7 @@ def create_vt_client(vt_lib) -> VTLookupV3: """Test simple lookup of IoC.""" vt_lib.Client = VTClient vt_lib.APIError = VTAPIError - return VTLookupV3() + return VTLookupV3(vt_key="vt_key") @pytest.fixture