Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion msticpy/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Version file."""

VERSION = "2.16.2"
VERSION = "2.17.0"
89 changes: 59 additions & 30 deletions msticpy/data/drivers/mdatp_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
"""MDATP OData Driver class."""
"""MS Defender/Defender 365 OData Driver class."""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, ClassVar, Iterable
from typing import TYPE_CHECKING, Any, ClassVar
from urllib.parse import urljoin

import pandas as pd
from typing_extensions import Self
Expand All @@ -24,6 +25,9 @@
from ..core.query_defns import DataEnvironment
from .odata_driver import OData, QuerySource, _get_driver_settings

if TYPE_CHECKING:
from collections.abc import Iterable

__version__ = VERSION
__author__ = "Pete Bryan"

Expand Down Expand Up @@ -93,25 +97,31 @@ def __init__(
)

cs_dict: dict[str, str] = _get_driver_settings(
self.CONFIG_NAME, self._ALT_CONFIG_NAMES, instance
self.CONFIG_NAME,
self._ALT_CONFIG_NAMES,
instance,
)

self.cloud: str = cs_dict.pop("cloud", "global")
if cloud:
self.cloud = cloud

m365d_params: M365DConfiguration = _select_api(
self.data_environment, self.cloud
self.data_environment,
self.cloud,
)
self._m365d_params: M365DConfiguration = m365d_params
self.oauth_url = m365d_params.login_uri
self.api_root = m365d_params.resource_uri
self.api_ver = m365d_params.api_version
self.api_suffix: str = m365d_params.api_endpoint
# Use scopes from configuration (already normalized '<resource>/.default')
self.scopes = m365d_params.scopes
resource_base = self.api_root.strip().rstrip("/")

self.add_query_filter(
"data_environments", ("MDE", "M365D", "MDATP", "M365DGraph", "GraphHunting")
"data_environments",
("MDE", "M365D", "MDATP", "M365DGraph", "GraphHunting"),
)

self.req_body: dict[str, Any] = {}
Expand All @@ -123,7 +133,9 @@ def __init__(
self.req_body["grant_type"] = "client_credentials"

if not m365d_params.oauth_v2:
self.req_body["resource"] = self.scopes
# OAuth v1 flow: send 'resource' (base URI, no /.default) not scopes
self.req_body["resource"] = resource_base
# OAuth v2: scopes handled by parent auth logic; do not add 'resource'

if connection_str:
self.current_connection = connection_str
Expand Down Expand Up @@ -159,11 +171,13 @@ def query(
"""
del query_source, kwargs
data, response = self.query_with_results(
query, body=True, api_end=self.api_suffix
query,
body=True,
api_end=self.api_suffix,
)
if isinstance(data, pd.DataFrame):
# If we got a schema we should convert the DateTimes to pandas datetimes
if ("Schema" or "schema") not in response:
if "Schema" not in response and "schema" not in response:
return data

if self.data_environment == DataEnvironment.M365DGraph:
Expand All @@ -178,51 +192,66 @@ def query(
for field in response["Schema"]
if field["Type"] == "DateTime"
]
data = ensure_df_datetimes(data, columns=date_fields)
return data
return ensure_df_datetimes(data, columns=date_fields)
return str(response)


def _select_api(data_environment: DataEnvironment, cloud: str) -> M365DConfiguration:
# pylint: disable=line-too-long
"""Return API and login URIs for selected provider type.

Note that the Microsoft Graph is the preferred API.

| API Name | Resource ID | Scopes Requested | API URI (global cloud) | API Endpoint | Login URI | MSTICpy Data Environment |
| -------- | ----------- | ---------------- | ---------------------- | ------------ | --------- | ------------------------ |
| WindowsDefenderATP | fc780465-2017-40d4-a0c5-307022471b92 | `AdvancedQuery.Read` | `https://api.securitycenter.microsoft.com` | `/advancedqueries/run` | `https://login.microsoftonline.com/<tenantId>/oauth2/token` | `MDE`, `MDATP` |
| Microsoft Threat Protection | 8ee8fdad-f234-4243-8f3b-15c294843740 | `AdvancedHunting.Read` | `https://api.security.microsoft.com` | `/advancedhunting/run` | `https://login.microsoftonline.com/<tenantId>/oauth2/token` | `M365D` |
| Microsoft Graph | 00000003-0000-0000-c000-000000000000 | `ThreatHunting.Read.All` | `https://graph.microsoft.com/<version>/` | `/security/runHuntingQuery` | `https://login.microsoftonline.com/<tenantId>/oauth2/v2.0/token` | `M365DGraph` |

Validated against Microsoft documentation:
MDE Advanced Queries:
https://learn.microsoft.com/microsoft-365/security/defender-endpoint/run-advanced-query-api
POST https://api.securitycenter.microsoft.com/api/advancedqueries/run
M365 Defender Advanced Hunting:
https://learn.microsoft.com/microsoft-365/security/defender/api/run-advanced-hunting-query
POST https://api.security.microsoft.com/api/advancedhunting/run
Microsoft Graph Security Hunting:
https://learn.microsoft.com/graph/api/security-security-runhuntingquery
POST https://graph.microsoft.com/v1.0/security/runHuntingQuery

Token acquisition (OAuth 2.0):
https://learn.microsoft.com/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow
Endpoint: https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
Scope format: https://<resource-host>/.default

Manual path join retained (urljoin would drop api_version if api_endpoint starts with '/').
Comment thread
ianhelle marked this conversation as resolved.
Resolved
"""
# pylint: enable=line-too-long
if data_environment == DataEnvironment.M365DGraph:
az_cloud_config = AzureCloudConfig(cloud=cloud)
login_uri: str = (
f"{az_cloud_config.authority_uri}{{tenantId}}/oauth2/v2.0/token"
login_uri: str = urljoin(
az_cloud_config.authority_uri,
"{tenantId}/oauth2/v2.0/token",
)
resource_uri: str = az_cloud_config.endpoints["microsoftGraphResourceId"]
api_version = "v1.0"
api_endpoint = "/security/runHuntingQuery"
scopes: list[str] = [f"{resource_uri}ThreatHunting.Read.All"]

elif data_environment == DataEnvironment.M365D:
login_uri = f"{get_m365d_login_endpoint(cloud)}{{tenantId}}/oauth2/token"
login_uri = urljoin(
get_m365d_login_endpoint(cloud),
"{tenantId}/oauth2/v2.0/token",
)
resource_uri = get_m365d_endpoint(cloud)
api_version = "api"
api_endpoint = "/advancedhunting/run"
scopes = [f"{resource_uri}AdvancedHunting.Read"]

else:
login_uri = f"{get_m365d_login_endpoint(cloud)}{{tenantId}}/oauth2/token"
# Default to MDE
login_uri = urljoin(
get_m365d_login_endpoint(cloud),
"{tenantId}/oauth2/v2.0/token",
)
resource_uri = get_defender_endpoint(cloud)
api_version = "api"
api_endpoint = "/advancedqueries/run"
scopes = [f"{resource_uri}AdvancedQuery.Read"]

api_uri: str = f"{resource_uri}{api_version}{api_endpoint}"
# Do not add '.default' here; keep raw base URI for reuse in both flows.
Comment thread
ianhelle marked this conversation as resolved.
Resolved
resource_base = resource_uri.strip().rstrip("/")
scopes: list[str] = [f"{resource_base}/.default"]

# Construct final query execution URI:
# <resource_base>/<api_version>/<endpoint-without-leading-slash>
api_endpoint_part = api_endpoint.lstrip("/")
api_uri: str = f"{resource_base}/{api_version.strip('/')}/{api_endpoint_part}"
return M365DConfiguration(
login_uri=login_uri,
resource_uri=resource_uri,
Expand Down
28 changes: 20 additions & 8 deletions msticpy/data/drivers/odata_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import re
import urllib.parse
from pathlib import Path
from typing import Any, ClassVar, Iterable
from typing import TYPE_CHECKING, Any, ClassVar

import httpx
import pandas as pd
Expand All @@ -21,8 +21,6 @@
from msal.application import ConfidentialClientApplication
from typing_extensions import Self

from msticpy.common.provider_settings import ProviderSettings

from ..._version import VERSION
from ...auth.msal_auth import MSALDelegatedAuth
from ...common.exceptions import MsticpyConnectionError, MsticpyUserConfigError
Expand All @@ -31,6 +29,11 @@
from ...common.utility import mp_ua_header
from .driver_base import DriverBase, DriverProps, QuerySource

if TYPE_CHECKING:
from collections.abc import Iterable

from msticpy.common.provider_settings import ProviderSettings

__version__: str = VERSION
__author__: str = "Pete Bryan"

Expand Down Expand Up @@ -209,9 +212,13 @@ def connect(
err_msg = f"Sub class {self.__class__.__name__} did not set self.api_root"
raise ValueError(err_msg)
api_ver: str | None = cs_dict.get("apiVersion", self.api_ver)
self.request_uri = self.api_root + str(api_ver)
# Normalize join of api_root and api_ver (api_ver should normally be set)
if api_ver:
self.request_uri = f"{self.api_root.rstrip('/')}/{api_ver.strip('/')}"
else:
self.request_uri = self.api_root.rstrip("/")

print("Connected.")
LOGGER.info("Connected.")
self._connected = True

def _get_token_certificate_auth(self: Self, cs_dict: dict[str, Any]) -> None:
Expand Down Expand Up @@ -246,9 +253,14 @@ def _get_token_certificate_auth(self: Self, cs_dict: dict[str, Any]) -> None:
client_credential=client_credential,
authority=authority,
)
result = app.acquire_token_for_client(
scopes=[self.api_root + "/.default"],
)
# Prefer precomputed scopes (OAuth v2). If not present, derive from api_root.
scopes: list[str]
if self.scopes:
scopes = self.scopes
else:
base_root = (self.api_root or "").rstrip("/")
scopes = [f"{base_root}/.default"]
result = app.acquire_token_for_client(scopes=scopes)
if not result or "access_token" not in result:
err_msg = "Could not obtain access token"
raise MsticpyConnectionError(err_msg)
Expand Down
Loading