howler-sentinel-plugin 0.2.0.dev113__tar.gz → 0.2.0.dev147__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/PKG-INFO +1 -1
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/pyproject.toml +1 -1
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/actions/send_to_sentinel.py +20 -3
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/actions/update_defender_xdr_alert.py +1 -1
- howler_sentinel_plugin-0.2.0.dev147/sentinel/config.py +70 -0
- howler_sentinel_plugin-0.2.0.dev147/sentinel/manifest.yml +13 -0
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/mapping/sentinel_incident.py +11 -5
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/mapping/xdr_alert.py +3 -9
- howler_sentinel_plugin-0.2.0.dev147/sentinel/odm/__init__.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/odm/hit.py +13 -4
- howler_sentinel_plugin-0.2.0.dev147/sentinel/routes/__init__.py +0 -0
- howler_sentinel_plugin-0.2.0.dev147/sentinel/routes/ingest.py +260 -0
- howler_sentinel_plugin-0.2.0.dev147/sentinel/utils/tenant_utils.py +56 -0
- howler_sentinel_plugin-0.2.0.dev113/sentinel/routes/__init__.py +0 -5
- howler_sentinel_plugin-0.2.0.dev113/sentinel/routes/ingest.py +0 -199
- howler_sentinel_plugin-0.2.0.dev113/sentinel/utils/tenant_utils.py +0 -40
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/LICENSE +0 -0
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/README.md +0 -0
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/__init__.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/actions/azure_emit_hash.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/actions/update_defender_xdr_incident.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/mapping/xdr_alert_evidence.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/odm/models/sentinel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "howler-sentinel-plugin"
|
|
3
|
-
version = "0.2.0.
|
|
3
|
+
version = "0.2.0.dev147"
|
|
4
4
|
description = "A howler plugin for integration with Microsoft's Sentinel API"
|
|
5
5
|
authors = [{ name = "CCCS", email = "analysis-development@cyber.gc.ca" }]
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -35,6 +35,8 @@ def execute(query: str, **kwargs) -> list[dict[str, Any]]:
|
|
|
35
35
|
)
|
|
36
36
|
return report
|
|
37
37
|
|
|
38
|
+
from sentinel.config import config
|
|
39
|
+
|
|
38
40
|
for hit in hits:
|
|
39
41
|
if hit.azure and hit.azure.tenant_id:
|
|
40
42
|
tenant_id = hit.azure.tenant_id
|
|
@@ -52,7 +54,7 @@ def execute(query: str, **kwargs) -> list[dict[str, Any]]:
|
|
|
52
54
|
continue
|
|
53
55
|
|
|
54
56
|
try:
|
|
55
|
-
token
|
|
57
|
+
token = get_token(tenant_id, "https://monitor.azure.com/.default")
|
|
56
58
|
except HowlerRuntimeError as err:
|
|
57
59
|
logger.exception("Error on token fetching")
|
|
58
60
|
report.append(
|
|
@@ -65,9 +67,24 @@ def execute(query: str, **kwargs) -> list[dict[str, Any]]:
|
|
|
65
67
|
)
|
|
66
68
|
continue
|
|
67
69
|
|
|
70
|
+
ingestor = next((ingestor for ingestor in config.ingestors if ingestor.tenant_id == tenant_id), None)
|
|
71
|
+
|
|
72
|
+
if not ingestor:
|
|
73
|
+
report.append(
|
|
74
|
+
{
|
|
75
|
+
"query": f"howler.id:{hit.howler.id}",
|
|
76
|
+
"outcome": "error",
|
|
77
|
+
"title": "Invalid Tenant ID",
|
|
78
|
+
"message": (
|
|
79
|
+
f"The tenant ID ({tenant_id}) associated with this alert has not been correctly configured."
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
continue
|
|
84
|
+
|
|
68
85
|
uri = (
|
|
69
|
-
f"https://{
|
|
70
|
-
+ f"streams/{
|
|
86
|
+
f"https://{ingestor.dce}.ingest.monitor.azure.com/dataCollectionRules/{ingestor.dcr}/"
|
|
87
|
+
+ f"streams/{ingestor.table}?api-version=2021-11-01-preview"
|
|
71
88
|
)
|
|
72
89
|
|
|
73
90
|
payload = [
|
|
@@ -89,7 +89,7 @@ def execute(query: str, **kwargs):
|
|
|
89
89
|
continue
|
|
90
90
|
|
|
91
91
|
try:
|
|
92
|
-
token = get_token(tenant_id, "https://graph.microsoft.com/.default")
|
|
92
|
+
token = get_token(tenant_id, "https://graph.microsoft.com/.default")
|
|
93
93
|
except HowlerRuntimeError as err:
|
|
94
94
|
logger.exception("Error on token fetching")
|
|
95
95
|
report.append(
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from howler.plugins.config import BasePluginConfig
|
|
7
|
+
from pydantic import BaseModel, ImportString
|
|
8
|
+
from pydantic_settings import SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
APP_NAME = os.environ.get("APP_NAME", "howler")
|
|
11
|
+
PLUGIN_NAME = "sentinel"
|
|
12
|
+
|
|
13
|
+
root_path = Path("/etc") / APP_NAME.replace("-dev", "").replace("-stg", "")
|
|
14
|
+
|
|
15
|
+
config_locations = [
|
|
16
|
+
Path(__file__).parent / "manifest.yml",
|
|
17
|
+
root_path / "conf" / f"{PLUGIN_NAME}.yml",
|
|
18
|
+
Path(os.environ.get("HWL_CONF_FOLDER", root_path)) / f"{PLUGIN_NAME}.yml",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClientCredentials(BaseModel):
|
|
23
|
+
"OAuth2 credentials for client_credential OAuth2 Flow"
|
|
24
|
+
|
|
25
|
+
client_id: str
|
|
26
|
+
client_secret: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Auth(BaseModel):
|
|
30
|
+
"Configuration for the various authentication methods, both to azure and incoming requests."
|
|
31
|
+
|
|
32
|
+
link_key: str = "abcdefghijklmnopqrstuvwxyz1234567890"
|
|
33
|
+
|
|
34
|
+
client_credentials: Optional[ClientCredentials] = None
|
|
35
|
+
|
|
36
|
+
custom_auth: Optional[ImportString] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Ingestor(BaseModel):
|
|
40
|
+
"Defines necessary data to ingest howler alerts into a specific azure tenancy"
|
|
41
|
+
|
|
42
|
+
tenant_id: str
|
|
43
|
+
dce: str
|
|
44
|
+
dcr: str
|
|
45
|
+
table: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SentinelConfig(BasePluginConfig):
|
|
49
|
+
"Sentinel Plugin Configuration Model"
|
|
50
|
+
|
|
51
|
+
auth: Auth = Auth()
|
|
52
|
+
|
|
53
|
+
ingestors: list[Ingestor] = []
|
|
54
|
+
|
|
55
|
+
model_config = SettingsConfigDict(
|
|
56
|
+
yaml_file=config_locations,
|
|
57
|
+
yaml_file_encoding="utf-8",
|
|
58
|
+
strict=True,
|
|
59
|
+
env_nested_delimiter="__",
|
|
60
|
+
env_prefix=f"{PLUGIN_NAME.upper()}_",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
config = SentinelConfig()
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
# When executed, the config model will print the default values of the configuration
|
|
68
|
+
import yaml
|
|
69
|
+
|
|
70
|
+
print(yaml.safe_dump(SentinelConfig().model_dump(mode="json"))) # noqa: T201
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Sentinel Incident mapper for converting Microsoft Sentinel Incidents to Howler bundles."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import logging
|
|
4
5
|
from typing import Any, Optional
|
|
5
6
|
|
|
@@ -81,6 +82,7 @@ class SentinelIncident:
|
|
|
81
82
|
"bundle_size": 0,
|
|
82
83
|
"hits": [],
|
|
83
84
|
"labels.generic": self._build_labels(custom_tags, system_tags),
|
|
85
|
+
"data": [json.dumps(sentinel_incident)],
|
|
84
86
|
},
|
|
85
87
|
"organization": {"name": customer_name, "id": tenant_id},
|
|
86
88
|
"sentinel": {
|
|
@@ -98,7 +100,7 @@ class SentinelIncident:
|
|
|
98
100
|
# Add assessment conditionally if classification is not null
|
|
99
101
|
if classification is not None:
|
|
100
102
|
bundle["howler"]["assessment"] = self.map_classification(classification)
|
|
101
|
-
logger.info("Successfully mapped
|
|
103
|
+
logger.info("Successfully mapped Sentinel Incident %s", incident_id)
|
|
102
104
|
return bundle
|
|
103
105
|
|
|
104
106
|
except Exception as exc:
|
|
@@ -115,14 +117,18 @@ class SentinelIncident:
|
|
|
115
117
|
"""
|
|
116
118
|
return self.tid_mapping.get(tid, self.DEFAULT_CUSTOMER_NAME)
|
|
117
119
|
|
|
118
|
-
def map_sentinel_status_to_howler(self, sentinel_status: str) -> str:
|
|
119
|
-
"""Map
|
|
120
|
+
def map_sentinel_status_to_howler(self, sentinel_status: Optional[str]) -> str:
|
|
121
|
+
"""Map Sentinel Incident status to Howler status.
|
|
120
122
|
|
|
121
123
|
Args:
|
|
122
|
-
sentinel_status (str): Sentinel status string.
|
|
124
|
+
sentinel_status (str | None): Sentinel status string or None.
|
|
125
|
+
|
|
123
126
|
Returns:
|
|
124
127
|
str: Howler status string.
|
|
125
128
|
"""
|
|
129
|
+
if not isinstance(sentinel_status, str) or not sentinel_status:
|
|
130
|
+
return "open"
|
|
131
|
+
|
|
126
132
|
status_mapping: dict[str, str] = {
|
|
127
133
|
"new": "open",
|
|
128
134
|
"active": "in-progress",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
2
3
|
from datetime import datetime
|
|
3
4
|
from typing import Any, Optional
|
|
@@ -149,7 +150,7 @@ class XDRAlert:
|
|
|
149
150
|
# TODO evaluate if we should set this field if new alert
|
|
150
151
|
return classification.get(graph_classification, "ambiguous")
|
|
151
152
|
|
|
152
|
-
def map_alert(self, graph_alert: dict[str, Any], customer_id: str) -> dict[str, Any]
|
|
153
|
+
def map_alert(self, graph_alert: dict[str, Any], customer_id: str) -> Optional[dict[str, Any]]:
|
|
153
154
|
"""Transform a Microsoft Graph alert into a Howler hit format.
|
|
154
155
|
|
|
155
156
|
This is the main mapping function that converts a Graph API alert object
|
|
@@ -231,14 +232,7 @@ class XDRAlert:
|
|
|
231
232
|
"operation": [],
|
|
232
233
|
"generic": [],
|
|
233
234
|
},
|
|
234
|
-
"
|
|
235
|
-
{
|
|
236
|
-
"content": [display_name],
|
|
237
|
-
"metadata": [graph_alert],
|
|
238
|
-
"label": {"en": "MSGraph Alert", "fr": "Alerte MSGraph"},
|
|
239
|
-
"format": "markdown",
|
|
240
|
-
}
|
|
241
|
-
],
|
|
235
|
+
"data": [json.dumps(graph_alert)],
|
|
242
236
|
},
|
|
243
237
|
"evidence": {"data": []},
|
|
244
238
|
"event": {
|
|
File without changes
|
{howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/odm/hit.py
RENAMED
|
@@ -1,24 +1,33 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
1
3
|
import howler.odm as odm
|
|
2
4
|
from howler.common.logging import get_logger
|
|
3
|
-
from howler.odm.models.
|
|
5
|
+
from howler.odm.models.azure import Azure
|
|
4
6
|
|
|
5
7
|
from sentinel.odm.models.sentinel import Sentinel
|
|
6
8
|
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from howler.odm.models.hit import Hit
|
|
11
|
+
|
|
12
|
+
|
|
7
13
|
logger = get_logger(__file__)
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
def modify_odm(target):
|
|
11
17
|
"Add additional internal fields to the ODM"
|
|
12
|
-
logger.info("Modifying ODM with additional fields")
|
|
13
|
-
|
|
14
18
|
target.add_namespace(
|
|
15
19
|
"sentinel",
|
|
16
20
|
odm.Optional(odm.Compound(Sentinel, description="Sentinel metadata associated with this alert")),
|
|
17
21
|
)
|
|
18
22
|
|
|
19
23
|
|
|
20
|
-
def
|
|
24
|
+
def generate(hit: "Hit") -> "Hit": # pragma: no cover
|
|
21
25
|
"Add cccs-specific changes to hits on generation"
|
|
22
26
|
hit.sentinel = Sentinel({"id": "example-sentinel-id"})
|
|
23
27
|
|
|
28
|
+
if not hit.azure:
|
|
29
|
+
hit.azure = Azure({"tenant_id": "example-tenant-id"})
|
|
30
|
+
else:
|
|
31
|
+
hit.azure.tenant_id = "example-tenant-id"
|
|
32
|
+
|
|
24
33
|
return ["sentinel"], hit
|
|
File without changes
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from flask import request
|
|
5
|
+
from howler.api import bad_request, created, internal_error, make_subapi_blueprint, ok, unauthorized
|
|
6
|
+
from howler.common.exceptions import HowlerException
|
|
7
|
+
from howler.common.loader import datastore
|
|
8
|
+
from howler.common.logging import get_logger
|
|
9
|
+
from howler.common.swagger import generate_swagger_docs
|
|
10
|
+
from howler.services import action_service, analytic_service, hit_service
|
|
11
|
+
|
|
12
|
+
from sentinel.mapping.sentinel_incident import SentinelIncident
|
|
13
|
+
from sentinel.mapping.xdr_alert import XDRAlert
|
|
14
|
+
|
|
15
|
+
SUB_API = "sentinel"
|
|
16
|
+
sentinel_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
17
|
+
sentinel_api._doc = "Ingest Microsoft Sentinel XDR incidents into Howler"
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__file__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@generate_swagger_docs()
|
|
23
|
+
@sentinel_api.route("/ingest", methods=["POST"])
|
|
24
|
+
def ingest_xdr_incident(**kwargs) -> tuple[dict[str, Any], int]: # noqa C901
|
|
25
|
+
"""Ingest a Microsoft Sentinel XDR incident into Howler.
|
|
26
|
+
|
|
27
|
+
Variables:
|
|
28
|
+
None
|
|
29
|
+
|
|
30
|
+
Arguments:
|
|
31
|
+
None
|
|
32
|
+
|
|
33
|
+
Data Block:
|
|
34
|
+
{
|
|
35
|
+
...Sentinel XDR incident JSON...
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Headers:
|
|
39
|
+
Authorization: API in the format "Basic <key>"
|
|
40
|
+
|
|
41
|
+
Result Example (201 Created):
|
|
42
|
+
{
|
|
43
|
+
"success": True,
|
|
44
|
+
"bundle_hit_id": "howler-bundle-id",
|
|
45
|
+
"bundle_id": "sentinel-incident-id",
|
|
46
|
+
"individual_hit_ids": ["alert-hit-id-1", "alert-hit-id-2"],
|
|
47
|
+
"total_hits_created": 3,
|
|
48
|
+
"bundle_size": 2,
|
|
49
|
+
"organization": "Acme Corporation"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Result Example (200 OK, update):
|
|
53
|
+
{
|
|
54
|
+
"success": True,
|
|
55
|
+
"bundle_hit_id": "howler-bundle-id",
|
|
56
|
+
"bundle_id": "sentinel-incident-id",
|
|
57
|
+
"individual_hit_ids": ["alert-hit-id-1", "alert-hit-id-2"],
|
|
58
|
+
"total_hits_updated": 3,
|
|
59
|
+
"bundle_size": 2,
|
|
60
|
+
"organization": "Acme Corporation",
|
|
61
|
+
"updated": True
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Error Codes:
|
|
65
|
+
400 - Bad request (e.g., missing JSON)
|
|
66
|
+
401 - Unauthorized (invalid API key)
|
|
67
|
+
500 - Internal server error
|
|
68
|
+
|
|
69
|
+
Description:
|
|
70
|
+
Receives a Microsoft Sentinel XDR incident as JSON, maps it to Howler format, and creates or updates a bundle
|
|
71
|
+
and its underlying alerts in Howler. Returns details about the created or updated bundle and alerts.
|
|
72
|
+
"""
|
|
73
|
+
from sentinel.config import config
|
|
74
|
+
|
|
75
|
+
# API Key authentication
|
|
76
|
+
apikey = request.headers.get("Authorization", "Basic ", type=str).split(" ")[1]
|
|
77
|
+
|
|
78
|
+
link_key = config.auth.link_key
|
|
79
|
+
|
|
80
|
+
if not apikey or apikey != link_key:
|
|
81
|
+
return unauthorized(err="API Key does not match expected value.")
|
|
82
|
+
|
|
83
|
+
logger.info("Received authorization header with value %s", re.sub(r"^(.{3}).+(.{3})$", r"\1...\2", apikey))
|
|
84
|
+
|
|
85
|
+
xdr_incident = request.json
|
|
86
|
+
if not xdr_incident:
|
|
87
|
+
return bad_request(err="No JSON data provided in request body")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
# TODO needs to be replaced with actual tenant mapping logic
|
|
91
|
+
tenant_mapping = {"020cd98f-1002-45b7-90ff-69fc68bdd027": "Acme Corporation"}
|
|
92
|
+
incident_mapper = SentinelIncident(tid_mapping=tenant_mapping)
|
|
93
|
+
bundle_hit = incident_mapper.map_incident_to_bundle(xdr_incident)
|
|
94
|
+
if bundle_hit is None:
|
|
95
|
+
return internal_error(err="Failed to map XDR incident to Howler bundle format")
|
|
96
|
+
|
|
97
|
+
sentinel_id = xdr_incident.get("id")
|
|
98
|
+
if sentinel_id:
|
|
99
|
+
existing_bundles = datastore().hit.search(f"sentinel.id:{sentinel_id}", as_obj=True)["items"]
|
|
100
|
+
if existing_bundles:
|
|
101
|
+
return _update_existing_incident(existing_bundles[0], xdr_incident, incident_mapper)
|
|
102
|
+
|
|
103
|
+
return _create_new_incident(bundle_hit, xdr_incident, tenant_mapping)
|
|
104
|
+
|
|
105
|
+
except HowlerException as e:
|
|
106
|
+
logger.exception("Failed to process XDR incident")
|
|
107
|
+
return internal_error(err=f"Failed to process XDR incident: {str(e)}")
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.exception("Unexpected error during XDR incident ingestion")
|
|
110
|
+
return internal_error(err=f"Internal error occurred during ingestion: {str(e)}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _update_existing_incident(
|
|
114
|
+
existing_bundle: Any, xdr_incident: dict[str, Any], incident_mapper: SentinelIncident
|
|
115
|
+
) -> tuple[dict[str, Any], int]:
|
|
116
|
+
"""Update an existing incident and its underlying alerts in Howler.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
existing_bundle: The existing Howler bundle object.
|
|
120
|
+
xdr_incident: The incoming XDR incident data.
|
|
121
|
+
incident_mapper: The incident mapper instance.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Tuple containing response dictionary and HTTP status code.
|
|
125
|
+
"""
|
|
126
|
+
new_status = xdr_incident.get("status")
|
|
127
|
+
if new_status:
|
|
128
|
+
existing_bundle.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
|
|
129
|
+
datastore().hit.save(existing_bundle.howler.id, existing_bundle)
|
|
130
|
+
for child_id in getattr(existing_bundle.howler, "hits", []):
|
|
131
|
+
child_hit = datastore().hit.get(child_id, as_obj=True)
|
|
132
|
+
if child_hit:
|
|
133
|
+
child_hit.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
|
|
134
|
+
datastore().hit.save(child_id, child_hit)
|
|
135
|
+
datastore().hit.commit()
|
|
136
|
+
logger.info("Updated status for existing bundle %s and its child hits", existing_bundle.howler.id)
|
|
137
|
+
return ok(
|
|
138
|
+
{
|
|
139
|
+
"success": True,
|
|
140
|
+
"bundle_hit_id": existing_bundle.howler.id,
|
|
141
|
+
"bundle_id": existing_bundle.sentinel.id if hasattr(existing_bundle, "sentinel") else None,
|
|
142
|
+
"individual_hit_ids": getattr(existing_bundle.howler, "hits", []),
|
|
143
|
+
"total_hits_updated": 1 + len(getattr(existing_bundle.howler, "hits", [])),
|
|
144
|
+
"bundle_size": len(getattr(existing_bundle.howler, "hits", [])),
|
|
145
|
+
"organization": getattr(existing_bundle, "organization", {}).get("name", ""),
|
|
146
|
+
"updated": True,
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _create_alert_hits(alerts: list[dict[str, Any]], tenant_id: str, alert_mapper: XDRAlert) -> list[str]:
|
|
152
|
+
"""Create alert hits from the provided alerts and return their IDs.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
alerts: List of alert dictionaries.
|
|
156
|
+
tenant_id: The tenant ID string.
|
|
157
|
+
alert_mapper: The alert mapper instance.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
List of created alert hit IDs.
|
|
161
|
+
"""
|
|
162
|
+
child_hit_ids = []
|
|
163
|
+
for i, alert in enumerate(alerts):
|
|
164
|
+
try:
|
|
165
|
+
mapped_hit = alert_mapper.map_alert(alert, tenant_id)
|
|
166
|
+
if mapped_hit:
|
|
167
|
+
alert_hit_odm, _ = hit_service.convert_hit(mapped_hit, unique=True, ignore_extra_values=True)
|
|
168
|
+
if alert_hit_odm.event is not None:
|
|
169
|
+
alert_hit_odm.event.id = alert_hit_odm.howler.id
|
|
170
|
+
logger.info("Creating individual alert hit %s with ID %s", i, alert_hit_odm.howler.id)
|
|
171
|
+
hit_service.create_hit(alert_hit_odm.howler.id, alert_hit_odm, user="system")
|
|
172
|
+
analytic_service.save_from_hit(alert_hit_odm, {"uname": "system"})
|
|
173
|
+
child_hit_ids.append(alert_hit_odm.howler.id)
|
|
174
|
+
logger.debug("Successfully created alert hit %s: %s", i, alert_hit_odm.howler.id)
|
|
175
|
+
else:
|
|
176
|
+
logger.warning("Alert mapper returned None for alert %s: %s", i, alert.get("id", "unknown"))
|
|
177
|
+
except Exception:
|
|
178
|
+
logger.exception("Failed to create individual alert hit %s", i)
|
|
179
|
+
continue
|
|
180
|
+
return child_hit_ids
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _link_child_hits_to_bundle(bundle_odm: Any, child_hit_ids: list[str]) -> None:
|
|
184
|
+
"""Link child hits to the bundle and update their bundle references.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
bundle_odm: The bundle ODM object.
|
|
188
|
+
child_hit_ids: List of child hit IDs to link.
|
|
189
|
+
"""
|
|
190
|
+
for hit_id in bundle_odm.howler.hits:
|
|
191
|
+
child_hit = hit_service.get_hit(hit_id, as_odm=True)
|
|
192
|
+
|
|
193
|
+
if child_hit.howler.is_bundle:
|
|
194
|
+
logger.warning("Child hit %s is a bundle - skipping bundle assignment", child_hit.howler.id)
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
new_bundle_list = child_hit.howler.get("bundles", [])
|
|
198
|
+
new_bundle_list.append(bundle_odm.howler.id)
|
|
199
|
+
child_hit.howler.bundles = new_bundle_list
|
|
200
|
+
datastore().hit.save(child_hit.howler.id, child_hit)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _create_new_incident(
|
|
204
|
+
bundle_hit: dict[str, Any], xdr_incident: dict[str, Any], tenant_mapping: dict[str, str]
|
|
205
|
+
) -> tuple[dict[str, Any], int]:
|
|
206
|
+
"""Create a new incident bundle and its underlying alerts in Howler.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
bundle_hit: The mapped Howler bundle data.
|
|
210
|
+
xdr_incident: The incoming XDR incident data.
|
|
211
|
+
tenant_mapping: The tenant mapping dictionary.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Tuple containing response dictionary and HTTP status code.
|
|
215
|
+
"""
|
|
216
|
+
alerts = xdr_incident.get("alerts", [])
|
|
217
|
+
tenant_id = xdr_incident.get("tenantId", "")
|
|
218
|
+
alert_mapper = XDRAlert(tid_mapping=tenant_mapping)
|
|
219
|
+
child_hit_ids = _create_alert_hits(alerts, tenant_id, alert_mapper)
|
|
220
|
+
try:
|
|
221
|
+
bundle_odm, _ = hit_service.convert_hit(bundle_hit, unique=True)
|
|
222
|
+
# If there are no alerts, do not treat as bundle
|
|
223
|
+
if child_hit_ids:
|
|
224
|
+
bundle_odm.howler.is_bundle = True
|
|
225
|
+
if not hasattr(bundle_odm.howler, "hits") or not isinstance(bundle_odm.howler.hits, list):
|
|
226
|
+
bundle_odm.howler.hits = []
|
|
227
|
+
for hit_id in child_hit_ids:
|
|
228
|
+
if hit_id not in bundle_odm.howler.hits:
|
|
229
|
+
bundle_odm.howler.hits.append(hit_id)
|
|
230
|
+
bundle_odm.howler.bundle_size = len(bundle_odm.howler.hits)
|
|
231
|
+
else:
|
|
232
|
+
bundle_odm.howler.is_bundle = False
|
|
233
|
+
bundle_odm.howler.hits = []
|
|
234
|
+
bundle_odm.howler.bundle_size = 0
|
|
235
|
+
|
|
236
|
+
if bundle_odm.event is not None:
|
|
237
|
+
bundle_odm.event.id = bundle_odm.howler.id
|
|
238
|
+
|
|
239
|
+
logger.info("Creating incident hit with ID %s", bundle_odm.howler.id)
|
|
240
|
+
hit_service.create_hit(bundle_odm.howler.id, bundle_odm, user="system")
|
|
241
|
+
analytic_service.save_from_hit(bundle_odm, {"uname": "system"})
|
|
242
|
+
if child_hit_ids:
|
|
243
|
+
_link_child_hits_to_bundle(bundle_odm, child_hit_ids)
|
|
244
|
+
datastore().hit.commit()
|
|
245
|
+
if child_hit_ids:
|
|
246
|
+
action_service.bulk_execute_on_query(f"howler.id:{bundle_odm.howler.id}", user={"uname": "system"})
|
|
247
|
+
logger.info("Successfully completed XDR incident ingestion")
|
|
248
|
+
response_body = {
|
|
249
|
+
"success": True,
|
|
250
|
+
"bundle_hit_id": bundle_odm.howler.id,
|
|
251
|
+
"bundle_id": bundle_hit["howler"].get("xdr.incident.id"),
|
|
252
|
+
"individual_hit_ids": child_hit_ids,
|
|
253
|
+
"total_hits_created": len(child_hit_ids) + 1,
|
|
254
|
+
"bundle_size": len(child_hit_ids),
|
|
255
|
+
"organization": bundle_hit["organization"]["name"],
|
|
256
|
+
}
|
|
257
|
+
return created(response_body)
|
|
258
|
+
except HowlerException as e:
|
|
259
|
+
logger.exception("Failed to create bundle")
|
|
260
|
+
return internal_error(err=f"Failed to create bundle: {str(e)}")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from howler.common.exceptions import HowlerRuntimeError
|
|
6
|
+
from howler.common.logging import get_logger
|
|
7
|
+
from howler.config import cache
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__file__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def skip_cache(*args):
|
|
13
|
+
"Function to skip cache in testing mode"
|
|
14
|
+
return "pytest" in sys.modules
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cache.memoize(15 * 60, unless=skip_cache)
|
|
18
|
+
def get_token(tenant_id: str, scope: str) -> Optional[str]:
|
|
19
|
+
"""Get a borealis token based on the current howler token"""
|
|
20
|
+
from sentinel.config import config
|
|
21
|
+
|
|
22
|
+
token = None
|
|
23
|
+
|
|
24
|
+
if config.auth.client_credentials:
|
|
25
|
+
logger.info(
|
|
26
|
+
"Using client_credentials flow for client id %s with scope %s",
|
|
27
|
+
config.auth.client_credentials.client_id,
|
|
28
|
+
scope,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
token_request_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
|
32
|
+
response = requests.post(
|
|
33
|
+
token_request_url,
|
|
34
|
+
data={
|
|
35
|
+
"grant_type": "client_credentials",
|
|
36
|
+
"client_id": config.auth.client_credentials.client_id,
|
|
37
|
+
"client_secret": config.auth.client_credentials.client_secret,
|
|
38
|
+
"scope": scope,
|
|
39
|
+
},
|
|
40
|
+
timeout=5.0,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if not response.ok:
|
|
44
|
+
raise HowlerRuntimeError(
|
|
45
|
+
"Authentication to Azure Monitor API using client_credentials flow failed with status code"
|
|
46
|
+
f" {response.status_code}. Response:\n{response.text}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
token = response.json()["access_token"]
|
|
50
|
+
elif config.auth.custom_auth:
|
|
51
|
+
token = config.auth.custom_auth(tenant_id, scope)
|
|
52
|
+
|
|
53
|
+
if not token:
|
|
54
|
+
logger.warning("No access token received")
|
|
55
|
+
|
|
56
|
+
return token
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import re
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
from flask import request
|
|
6
|
-
from howler.api import bad_request, created, make_subapi_blueprint, ok, unauthorized
|
|
7
|
-
from howler.common.exceptions import HowlerException
|
|
8
|
-
from howler.common.loader import datastore
|
|
9
|
-
from howler.common.logging import get_logger
|
|
10
|
-
from howler.common.swagger import generate_swagger_docs
|
|
11
|
-
from howler.services import action_service, analytic_service, hit_service
|
|
12
|
-
|
|
13
|
-
from ..mapping.sentinel_incident import SentinelIncident
|
|
14
|
-
from ..mapping.xdr_alert import XDRAlert
|
|
15
|
-
|
|
16
|
-
SUB_API = "sentinel"
|
|
17
|
-
sentinel_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
18
|
-
sentinel_api._doc = "Ingest Microsoft Sentinel XDR incidents into Howler"
|
|
19
|
-
|
|
20
|
-
logger = get_logger(__file__)
|
|
21
|
-
|
|
22
|
-
# For testing purposes, replace with actual secret in production
|
|
23
|
-
SECRET = os.environ.get("SENTINEL_LINK_KEY", "abcdefghijklmnopqrstuvwxyz1234567890")
|
|
24
|
-
|
|
25
|
-
if SECRET.startswith("abcdef"):
|
|
26
|
-
logger.warning("Default secret used!")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@generate_swagger_docs()
|
|
30
|
-
@sentinel_api.route("/ingest", methods=["POST"])
|
|
31
|
-
def ingest_xdr_incident(**kwargs) -> tuple[dict[str, Any], int]: # noqa C901
|
|
32
|
-
"""Ingest a Microsoft Sentinel XDR incident into Howler.
|
|
33
|
-
|
|
34
|
-
This endpoint receives an XDR incident as JSON, maps it to Howler format using XDRIncidentMapper,
|
|
35
|
-
and creates a bundle following the same pattern as the create_bundle endpoint.
|
|
36
|
-
|
|
37
|
-
Uses API key authentication via Authorization header.
|
|
38
|
-
|
|
39
|
-
Variables:
|
|
40
|
-
None
|
|
41
|
-
|
|
42
|
-
Data Body:
|
|
43
|
-
XDR incident JSON data to be ingested
|
|
44
|
-
|
|
45
|
-
Result Example:
|
|
46
|
-
{
|
|
47
|
-
"success": true,
|
|
48
|
-
"bundle_id": "generated_bundle_id",
|
|
49
|
-
"hit_count": 1
|
|
50
|
-
}
|
|
51
|
-
"""
|
|
52
|
-
# TODO this endpoint need to be refactored to make it more readable and maintainable
|
|
53
|
-
apikey = request.headers.get("Authorization", "Basic ", type=str).split(" ")[1]
|
|
54
|
-
|
|
55
|
-
if not apikey or apikey != SECRET:
|
|
56
|
-
return unauthorized(err="API Key does not match expected value.")
|
|
57
|
-
|
|
58
|
-
logger.info("Received authorization header with value %s", re.sub(r"^(.{3}).+(.{3})$", r"\1...\2", apikey))
|
|
59
|
-
|
|
60
|
-
xdr_incident = request.json
|
|
61
|
-
if not xdr_incident:
|
|
62
|
-
return bad_request(err="No JSON data provided in request body")
|
|
63
|
-
|
|
64
|
-
logger.info("XDR Incident received")
|
|
65
|
-
|
|
66
|
-
try:
|
|
67
|
-
tenant_mapping = {"020cd98f-1002-45b7-90ff-69fc68bdd027": "Acme Corporation"}
|
|
68
|
-
|
|
69
|
-
incident_mapper = SentinelIncident(tid_mapping=tenant_mapping)
|
|
70
|
-
bundle_hit = incident_mapper.map_incident_to_bundle(xdr_incident)
|
|
71
|
-
|
|
72
|
-
if bundle_hit is None:
|
|
73
|
-
return bad_request(err="Failed to map XDR incident to Howler bundle format")
|
|
74
|
-
|
|
75
|
-
sentinel_id = xdr_incident.get("id")
|
|
76
|
-
if sentinel_id:
|
|
77
|
-
existing_bundles = datastore().hit.search(f"sentinel.id:{sentinel_id}", as_obj=True)["items"]
|
|
78
|
-
if existing_bundles:
|
|
79
|
-
existing_bundle = existing_bundles[0]
|
|
80
|
-
new_status = xdr_incident.get("status")
|
|
81
|
-
if new_status:
|
|
82
|
-
existing_bundle.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
|
|
83
|
-
datastore().hit.save(existing_bundle.howler.id, existing_bundle)
|
|
84
|
-
for child_id in getattr(existing_bundle.howler, "hits", []):
|
|
85
|
-
child_hit = datastore().hit.get(child_id, as_obj=True)
|
|
86
|
-
if child_hit:
|
|
87
|
-
child_hit.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
|
|
88
|
-
datastore().hit.save(child_id, child_hit)
|
|
89
|
-
datastore().hit.commit()
|
|
90
|
-
logger.info("Updated status for existing bundle %s and its child hits", existing_bundle.howler.id)
|
|
91
|
-
return ok(
|
|
92
|
-
{
|
|
93
|
-
"success": True,
|
|
94
|
-
"bundle_hit_id": existing_bundle.howler.id,
|
|
95
|
-
"bundle_id": existing_bundle.sentinel.id if hasattr(existing_bundle, "sentinel") else None,
|
|
96
|
-
"individual_hit_ids": getattr(existing_bundle.howler, "hits", []),
|
|
97
|
-
"total_hits_updated": 1 + len(getattr(existing_bundle.howler, "hits", [])),
|
|
98
|
-
"bundle_size": len(getattr(existing_bundle.howler, "hits", [])),
|
|
99
|
-
"organization": getattr(existing_bundle, "organization", {}).get("name", ""),
|
|
100
|
-
"updated": True,
|
|
101
|
-
}
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
logger.info("Successfully mapped XDR incident to bundle")
|
|
105
|
-
|
|
106
|
-
alerts = xdr_incident.get("alerts", [])
|
|
107
|
-
tenant_id = xdr_incident.get("tenantId", "")
|
|
108
|
-
|
|
109
|
-
alert_mapper = XDRAlert(tid_mapping=tenant_mapping)
|
|
110
|
-
|
|
111
|
-
# Create individual hits from alerts first
|
|
112
|
-
child_hit_ids = []
|
|
113
|
-
|
|
114
|
-
for i, alert in enumerate(alerts):
|
|
115
|
-
try:
|
|
116
|
-
mapped_hit = alert_mapper.map_alert(alert, tenant_id)
|
|
117
|
-
if mapped_hit:
|
|
118
|
-
alert_hit_odm, _ = hit_service.convert_hit(mapped_hit, unique=True, ignore_extra_values=True)
|
|
119
|
-
|
|
120
|
-
if alert_hit_odm.event is not None:
|
|
121
|
-
alert_hit_odm.event.id = alert_hit_odm.howler.id
|
|
122
|
-
|
|
123
|
-
logger.info("Creating individual alert hit %s with ID %s", i, alert_hit_odm.howler.id)
|
|
124
|
-
hit_service.create_hit(alert_hit_odm.howler.id, alert_hit_odm, user="system")
|
|
125
|
-
analytic_service.save_from_hit(alert_hit_odm, {"uname": "system"})
|
|
126
|
-
|
|
127
|
-
child_hit_ids.append(alert_hit_odm.howler.id)
|
|
128
|
-
logger.debug("Successfully created alert hit %s: %s", i, alert_hit_odm.howler.id)
|
|
129
|
-
else:
|
|
130
|
-
logger.warning("Alert mapper returned None for alert %s: %s", i, alert.get("id", "unknown"))
|
|
131
|
-
except Exception:
|
|
132
|
-
logger.exception("Failed to create individual alert hit %s", i)
|
|
133
|
-
continue
|
|
134
|
-
|
|
135
|
-
try:
|
|
136
|
-
bundle_odm, _ = hit_service.convert_hit(bundle_hit, unique=True)
|
|
137
|
-
bundle_odm.howler.is_bundle = True
|
|
138
|
-
|
|
139
|
-
if not hasattr(bundle_odm.howler, "hits") or not isinstance(bundle_odm.howler.hits, list):
|
|
140
|
-
bundle_odm.howler.hits = []
|
|
141
|
-
for hit_id in child_hit_ids:
|
|
142
|
-
if hit_id not in bundle_odm.howler.hits:
|
|
143
|
-
bundle_odm.howler.hits.append(hit_id)
|
|
144
|
-
|
|
145
|
-
if len(bundle_odm.howler.hits) < 1:
|
|
146
|
-
# TODO figure out how to handle incidens without alerts
|
|
147
|
-
logger.info("No valid child hits were created from the XDR incident alerts")
|
|
148
|
-
return ok("Incident contains no valid alerts to create hits from")
|
|
149
|
-
|
|
150
|
-
bundle_odm.howler.bundle_size = len(bundle_odm.howler.hits)
|
|
151
|
-
|
|
152
|
-
if bundle_odm.event is not None:
|
|
153
|
-
bundle_odm.event.id = bundle_odm.howler.id
|
|
154
|
-
|
|
155
|
-
logger.info("Creating bundle hit with ID %s", bundle_odm.howler.id)
|
|
156
|
-
hit_service.create_hit(bundle_odm.howler.id, bundle_odm, user="system")
|
|
157
|
-
analytic_service.save_from_hit(bundle_odm, {"uname": "system"})
|
|
158
|
-
|
|
159
|
-
# Link child hits to bundle (same as create_bundle)
|
|
160
|
-
for hit_id in bundle_odm.howler.hits:
|
|
161
|
-
child_hit = hit_service.get_hit(hit_id, as_odm=True)
|
|
162
|
-
|
|
163
|
-
if child_hit.howler.is_bundle:
|
|
164
|
-
logger.warning("Child hit %s is a bundle - skipping bundle assignment", child_hit.howler.id)
|
|
165
|
-
continue
|
|
166
|
-
|
|
167
|
-
new_bundle_list = child_hit.howler.get("bundles", [])
|
|
168
|
-
new_bundle_list.append(bundle_odm.howler.id)
|
|
169
|
-
child_hit.howler.bundles = new_bundle_list
|
|
170
|
-
datastore().hit.save(child_hit.howler.id, child_hit)
|
|
171
|
-
|
|
172
|
-
datastore().hit.commit()
|
|
173
|
-
action_service.bulk_execute_on_query(f"howler.id:{bundle_odm.howler.id}", user={"uname": "system"})
|
|
174
|
-
|
|
175
|
-
logger.info("Successfully completed XDR incident ingestion")
|
|
176
|
-
|
|
177
|
-
response_body = {
|
|
178
|
-
"success": True,
|
|
179
|
-
"bundle_hit_id": bundle_odm.howler.id,
|
|
180
|
-
"bundle_id": bundle_hit["howler"].get("xdr.incident.id"),
|
|
181
|
-
"individual_hit_ids": child_hit_ids,
|
|
182
|
-
"total_hits_created": len(child_hit_ids) + 1,
|
|
183
|
-
"bundle_size": len(child_hit_ids),
|
|
184
|
-
"organization": bundle_hit["organization"]["name"],
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return created(response_body)
|
|
188
|
-
|
|
189
|
-
except HowlerException as e:
|
|
190
|
-
logger.exception("Failed to create bundle")
|
|
191
|
-
return bad_request(err=f"Failed to create bundle: {str(e)}")
|
|
192
|
-
|
|
193
|
-
except HowlerException as e:
|
|
194
|
-
logger.exception("Failed to process XDR incident")
|
|
195
|
-
return bad_request(err=f"Failed to process XDR incident: {str(e)}")
|
|
196
|
-
|
|
197
|
-
except Exception as e:
|
|
198
|
-
logger.exception("Unexpected error during XDR incident ingestion")
|
|
199
|
-
return bad_request(err=f"Internal error occurred during ingestion: {str(e)}")
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import os
|
|
3
|
-
|
|
4
|
-
import requests
|
|
5
|
-
from howler.common.exceptions import HowlerRuntimeError
|
|
6
|
-
from howler.common.logging import get_logger
|
|
7
|
-
from howler.config import cache
|
|
8
|
-
|
|
9
|
-
logger = get_logger(__file__)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@cache.memoize(15 * 60)
|
|
13
|
-
def get_token(tenant_id: str, scope: str) -> tuple[str, dict[str, str]]:
|
|
14
|
-
"""Get a borealis token based on the current howler token"""
|
|
15
|
-
# Get bearer token
|
|
16
|
-
try:
|
|
17
|
-
credentials = json.loads(os.environ["HOWLER_SENTINEL_INGEST_CREDENTIALS"])
|
|
18
|
-
except (KeyError, json.JSONDecodeError):
|
|
19
|
-
raise HowlerRuntimeError("Credential data not configured.")
|
|
20
|
-
|
|
21
|
-
logger.info("Generating client credential token for client id %s with scope %s", credentials["client_id"], scope)
|
|
22
|
-
|
|
23
|
-
token_request_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
|
24
|
-
response = requests.post(
|
|
25
|
-
token_request_url,
|
|
26
|
-
data={
|
|
27
|
-
"grant_type": "client_credentials",
|
|
28
|
-
"client_id": credentials["client_id"],
|
|
29
|
-
"client_secret": credentials["client_secret"],
|
|
30
|
-
"scope": scope,
|
|
31
|
-
},
|
|
32
|
-
timeout=5.0,
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
if not response.ok:
|
|
36
|
-
raise HowlerRuntimeError(f"Authentication to Azure Monitor API failed with status code {response.status_code}.")
|
|
37
|
-
|
|
38
|
-
token = response.json()["access_token"]
|
|
39
|
-
|
|
40
|
-
return token, credentials
|
|
File without changes
|
|
File without changes
|
{howler_sentinel_plugin-0.2.0.dev113 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|