howler-sentinel-plugin 0.2.0.dev170__py3-none-any.whl

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.
@@ -0,0 +1,194 @@
1
+ import requests
2
+ from howler.common.exceptions import HowlerRuntimeError
3
+ from howler.common.loader import datastore
4
+ from howler.common.logging import get_logger
5
+ from howler.odm.models.action import VALID_TRIGGERS
6
+ from howler.odm.models.hit import Hit
7
+ from howler.odm.models.howler_data import Assessment, HitStatus
8
+
9
+ from sentinel.utils.tenant_utils import get_token
10
+
11
+ logger = get_logger(__file__)
12
+
13
+ OPERATION_ID = "update_defender_xdr_incident"
14
+
15
+ properties_map = {
16
+ "graph": {
17
+ "status": {
18
+ HitStatus.OPEN: "active",
19
+ HitStatus.IN_PROGRESS: "inProgress",
20
+ HitStatus.ON_HOLD: "inProgress",
21
+ HitStatus.RESOLVED: "resolved",
22
+ },
23
+ "classification": {
24
+ Assessment.AMBIGUOUS: "unknown",
25
+ Assessment.SECURITY: "informationalExpectedActivity",
26
+ Assessment.DEVELOPMENT: "informationalExpectedActivity",
27
+ Assessment.FALSE_POSITIVE: "falsePositive",
28
+ Assessment.LEGITIMATE: "informationalExpectedActivity",
29
+ Assessment.TRIVIAL: "falsePositive",
30
+ Assessment.RECON: "truePositive",
31
+ Assessment.ATTEMPT: "truePositive",
32
+ Assessment.COMPROMISE: "truePositive",
33
+ Assessment.MITIGATED: "truePositive",
34
+ None: "unknown",
35
+ },
36
+ "determination": {
37
+ Assessment.AMBIGUOUS: "unknown",
38
+ Assessment.SECURITY: "securityTesting",
39
+ Assessment.DEVELOPMENT: "confirmedUserActivity",
40
+ Assessment.FALSE_POSITIVE: "other",
41
+ Assessment.LEGITIMATE: "lineOfBusinessApplication",
42
+ Assessment.TRIVIAL: "other",
43
+ Assessment.RECON: "multiStagedAttack",
44
+ Assessment.ATTEMPT: "other",
45
+ Assessment.COMPROMISE: "maliciousUserActivity",
46
+ Assessment.MITIGATED: "other",
47
+ None: "unknown",
48
+ },
49
+ },
50
+ }
51
+
52
+
53
+ def execute(query: str, **kwargs):
54
+ """Update Microsoft Defender XDR incident.
55
+
56
+ Args:
57
+ query (str): The query on which to apply this automation.
58
+ """
59
+ report = []
60
+ ds = datastore()
61
+
62
+ hits: list[Hit] = ds.hit.search(query, as_obj=True)["items"]
63
+
64
+ if not hits:
65
+ report.append(
66
+ {
67
+ "query": query,
68
+ "outcome": "error",
69
+ "title": "No hits returned by query",
70
+ "message": f"No hits returned by '{query}'",
71
+ }
72
+ )
73
+ return report
74
+
75
+ for hit in hits:
76
+ if hit.azure and hit.azure.tenant_id:
77
+ tenant_id = hit.azure.tenant_id
78
+ elif hit.organization.id:
79
+ tenant_id = hit.organization.id
80
+ else:
81
+ report.append(
82
+ {
83
+ "query": f"howler.id:{hit.howler.id}",
84
+ "outcome": "skipped",
85
+ "title": "Azure Tenant ID is missing",
86
+ "message": "This incident does not have a set tenant ID.",
87
+ }
88
+ )
89
+ continue
90
+
91
+ try:
92
+ token = get_token(tenant_id, "https://graph.microsoft.com/.default")[0]
93
+ except HowlerRuntimeError as err:
94
+ logger.exception("Error on token fetching")
95
+ report.append(
96
+ {
97
+ "query": f"howler.id:{hit.howler.id}",
98
+ "outcome": "error",
99
+ "title": "Invalid Credentials",
100
+ "message": err.message,
101
+ }
102
+ )
103
+ continue
104
+
105
+ # Fetch incident details
106
+ incident_url = f"https://graph.microsoft.com/v1.0/security/incidents/{hit.sentinel.id}"
107
+ response = requests.get(incident_url, headers={"Authorization": f"Bearer {token}"}, timeout=5.0)
108
+ if not response.ok:
109
+ logger.warning(
110
+ "GET request to Microsoft Graph failed with status code %s. Content:\n%s",
111
+ response.status_code,
112
+ response.text,
113
+ )
114
+ report.append(
115
+ {
116
+ "query": query,
117
+ "outcome": "error",
118
+ "title": "Microsoft Graph API request failed",
119
+ "message": f"GET request to Microsoft Graph failed with status code {response.status_code}.",
120
+ }
121
+ )
122
+ continue
123
+
124
+ incident_data = response.json()
125
+
126
+ # Update incident
127
+ if (
128
+ "assessment" in hit.howler
129
+ and hit.howler.assessment in properties_map["graph"]["classification"]
130
+ and hit.howler.assessment in properties_map["graph"]["determination"]
131
+ ):
132
+ classification = properties_map["graph"]["classification"][hit.howler.assessment]
133
+ determination = properties_map["graph"]["determination"][hit.howler.assessment]
134
+ else:
135
+ classification = incident_data["classification"]
136
+ determination = incident_data["determination"]
137
+
138
+ status = properties_map["graph"]["status"][hit.howler.status]
139
+ assigned_to = incident_data["assignedTo"]
140
+
141
+ data = {
142
+ "assignedTo": assigned_to,
143
+ "classification": classification,
144
+ "determination": determination,
145
+ "status": status,
146
+ }
147
+
148
+ response = requests.patch(
149
+ incident_url,
150
+ json=data,
151
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
152
+ timeout=5.0,
153
+ )
154
+ if not response.ok:
155
+ logger.warning(
156
+ "PATCH request to Microsoft Graph failed with status code %s. Content:\n%s",
157
+ response.status_code,
158
+ response.text,
159
+ )
160
+ report.append(
161
+ {
162
+ "query": query,
163
+ "outcome": "error",
164
+ "title": "Microsoft Graph API request failed",
165
+ "message": f"PATCH request to Microsoft Graph failed with status code {response.status_code}.",
166
+ }
167
+ )
168
+ else:
169
+ report.append(
170
+ {
171
+ "query": f"howler.id:{hit.howler.id}",
172
+ "outcome": "success",
173
+ "title": "Incident updated in XDR Defender",
174
+ "message": "Howler has successfully propagated changes to this incident to XDR Defender.",
175
+ }
176
+ )
177
+
178
+ return report
179
+
180
+
181
+ def specification():
182
+ "Update Defender action specification"
183
+ return {
184
+ "id": OPERATION_ID,
185
+ "title": "Update Microsoft Defender XDR incident",
186
+ "priority": 8,
187
+ "description": {
188
+ "short": "Update Microsoft Defender XDR incident",
189
+ "long": execute.__doc__,
190
+ },
191
+ "roles": ["automation_basic"],
192
+ "steps": [{"args": {}, "options": {}, "validation": {}}],
193
+ "triggers": VALID_TRIGGERS,
194
+ }
sentinel/config.py ADDED
@@ -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
sentinel/manifest.yml ADDED
@@ -0,0 +1,13 @@
1
+ name: sentinel
2
+ modules:
3
+ odm:
4
+ modify_odm:
5
+ hit: true
6
+ generation:
7
+ hit: true
8
+ operations:
9
+ - azure_emit_hash
10
+ - send_to_sentinel
11
+ - update_defender_xdr_alert
12
+ routes:
13
+ - ingest:sentinel_api
@@ -0,0 +1,240 @@
1
+ """Sentinel Incident mapper for converting Microsoft Sentinel Incidents to Howler bundles."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Any, Optional
6
+
7
+ from dateutil import parser
8
+
9
+ # Use standard logging for now
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class SentinelIncident:
14
+ """Class to handle mapping of Sentiel Incidents to Howler bundles."""
15
+
16
+ DEFAULT_CUSTOMER_NAME = "Unknown Customer"
17
+
18
+ def __init__(self, tid_mapping: Optional[dict[str, str]] = None):
19
+ """Initialize the Sentiel Incident mapper.
20
+
21
+ Args:
22
+ tid_mapping: Mapping of tenant IDs to customer names
23
+ """
24
+ self.tid_mapping = tid_mapping or {}
25
+
26
+ # --- Public mapping methods ---
27
+
28
+ def map_incident_to_bundle(self, sentinel_incident: dict[str, Any]) -> Optional[dict[str, Any]]:
29
+ """Map an Sentiel Incident to a Howler bundle.
30
+
31
+ Args:
32
+ sentinel_incident (dict[str, Any]): The Sentiel Incident data from Microsoft Graph.
33
+
34
+ Returns:
35
+ Optional[dict[str, Any]]: Mapped bundle dictionary or None if mapping fails.
36
+
37
+ Example:
38
+ >>> mapper = SentinelIncident()
39
+ >>> bundle = mapper.map_incident_to_bundle(sentinel_incident)
40
+ """
41
+ try:
42
+ if not sentinel_incident:
43
+ logger.error("Empty incident data provided")
44
+ return None
45
+
46
+ required_fields = ["id", "tenantId", "createdDateTime"]
47
+ for field in required_fields:
48
+ if not sentinel_incident.get(field):
49
+ logger.warning("Missing required field '%s' in incident: %s", field, sentinel_incident)
50
+
51
+ tenant_id: str = sentinel_incident.get("tenantId", "")
52
+ customer_name: str = self.get_customer_name(tenant_id)
53
+
54
+ incident_id: Optional[str] = sentinel_incident.get("id")
55
+ status: str = sentinel_incident.get("status", "active")
56
+ display_name: str = sentinel_incident.get("displayName", "")
57
+ created_datetime: Optional[str] = sentinel_incident.get("createdDateTime")
58
+ assigned_to: Optional[str] = sentinel_incident.get("assignedTo")
59
+ classification: str = sentinel_incident.get("classification", "unknown")
60
+ severity: str = sentinel_incident.get("severity", "medium")
61
+ custom_tags: list[str] = sentinel_incident.get("customTags") or []
62
+ system_tags: list[str] = sentinel_incident.get("systemTags") or []
63
+ description: str = sentinel_incident.get("description", "")
64
+ resolving_comment: str = sentinel_incident.get("resolvingComment", "")
65
+ if not isinstance(custom_tags, list):
66
+ logger.warning("customTags is not a list: %s", custom_tags)
67
+ custom_tags = []
68
+ if not isinstance(system_tags, list):
69
+ logger.warning("systemTags is not a list: %s", system_tags)
70
+ system_tags = []
71
+
72
+ bundle: dict[str, Any] = {
73
+ "howler": {
74
+ "status": self.map_sentinel_status_to_howler(status),
75
+ "detection": display_name,
76
+ "assignment": self.map_sentinel_user_to_howler(assigned_to),
77
+ "score": self.map_severity_to_score(severity),
78
+ "outline.summary": description,
79
+ "rationale": resolving_comment,
80
+ "analytic": "Sentinel",
81
+ "is_bundle": True,
82
+ "bundle_size": 0,
83
+ "hits": [],
84
+ "labels.generic": self._build_labels(custom_tags, system_tags),
85
+ "data": [json.dumps(sentinel_incident)],
86
+ },
87
+ "organization": {"name": customer_name, "id": tenant_id},
88
+ "sentinel": {
89
+ "id": incident_id,
90
+ },
91
+ "evidence": {"cloud": {"account": {"id": tenant_id}}},
92
+ "event": {
93
+ "created": created_datetime,
94
+ "start": created_datetime,
95
+ "end": created_datetime,
96
+ },
97
+ }
98
+ self._map_graph_host_link(sentinel_incident, bundle)
99
+ self._map_timestamps(sentinel_incident, bundle)
100
+ # Add assessment conditionally if classification is not null
101
+ if classification is not None:
102
+ bundle["howler"]["assessment"] = self.map_classification(classification)
103
+ logger.info("Successfully mapped Sentinel Incident %s", incident_id)
104
+ return bundle
105
+
106
+ except Exception as exc:
107
+ logger.error("Failed to map Sentiel Incident: %s", exc, exc_info=True)
108
+ return None
109
+
110
+ def get_customer_name(self, tid: str) -> str:
111
+ """Get customer name from tenant ID, return default if not found.
112
+
113
+ Args:
114
+ tid (str): Tenant ID.
115
+ Returns:
116
+ str: Customer name or default.
117
+ """
118
+ return self.tid_mapping.get(tid, self.DEFAULT_CUSTOMER_NAME)
119
+
120
+ def map_sentinel_status_to_howler(self, sentinel_status: Optional[str]) -> str:
121
+ """Map Sentinel Incident status to Howler status.
122
+
123
+ Args:
124
+ sentinel_status (str | None): Sentinel status string or None.
125
+
126
+ Returns:
127
+ str: Howler status string.
128
+ """
129
+ if not isinstance(sentinel_status, str) or not sentinel_status:
130
+ return "open"
131
+
132
+ status_mapping: dict[str, str] = {
133
+ "new": "open",
134
+ "active": "in-progress",
135
+ "inProgress": "in-progress",
136
+ "resolved": "resolved",
137
+ "closed": "resolved",
138
+ }
139
+ return status_mapping.get(sentinel_status, "open")
140
+
141
+ def map_sentinel_user_to_howler(self, sentinel_user: Optional[str]) -> str:
142
+ """Map Sentinel user assignment to Howler format.
143
+
144
+ Args:
145
+ sentinel_user (Optional[str]): Sentinel user assignment.
146
+ Returns:
147
+ str: Howler assignment string.
148
+ """
149
+ if not sentinel_user or sentinel_user in ["null", "None"]:
150
+ return "unassigned"
151
+ return sentinel_user
152
+
153
+ def map_severity_to_score(self, severity: str) -> int:
154
+ """Map string severity to numeric score.
155
+
156
+ Args:
157
+ severity (str): Severity string.
158
+ Returns:
159
+ int: Numeric score.
160
+ """
161
+ severity_mapping: dict[str, int] = {"low": 25, "medium": 50, "high": 75, "critical": 100}
162
+ return severity_mapping.get(severity.lower() if severity else "medium", 50)
163
+
164
+ def map_classification(self, classification: str) -> str:
165
+ """Map Sentinel classification to Howler assessment.
166
+
167
+ Args:
168
+ classification (str): Sentinel classification string.
169
+ Returns:
170
+ str: Howler assessment string.
171
+ """
172
+ classification_mapping: dict[str, str] = {
173
+ "unknown": "ambiguous",
174
+ "truePositive": "compromise",
175
+ "falsePositive": "false-positive",
176
+ "informationalExpectedActivity": "legitimate",
177
+ "benignPositive": "legitimate",
178
+ }
179
+ return classification_mapping.get(classification, "")
180
+
181
+ # --- Private helper methods ---
182
+
183
+ def _map_graph_host_link(self, graph_alert: dict[str, Any], howler_hit: dict[str, Any]) -> None:
184
+ """Map Graph host link from Graph alert to Howler hit.
185
+
186
+ Args:
187
+ graph_alert (dict[str, Any]): Graph alert data.
188
+ howler_hit (dict[str, Any]): Howler hit/bundle to update.
189
+ """
190
+ link: dict[str, str] = {
191
+ "icon": "https://security.microsoft.com/favicon.ico",
192
+ "title": "Open in Microsoft Sentinel portal",
193
+ "href": graph_alert.get("incidentWebUrl", ""),
194
+ }
195
+ if graph_alert.get("incidentWebUrl"):
196
+ howler_hit["howler"]["links"] = howler_hit["howler"].get("links", [])
197
+ howler_hit["howler"]["links"].append(link)
198
+
199
+ def _map_timestamps(self, graph_alert: dict[str, Any], howler_hit: dict[str, Any]) -> None:
200
+ """Map timestamps from Graph alert to Howler hit.
201
+
202
+ Args:
203
+ graph_alert (dict[str, Any]): Graph alert data.
204
+ howler_hit (dict[str, Any]): Howler hit/bundle to update.
205
+ """
206
+ for time_field in [
207
+ "createdDateTime",
208
+ "lastUpdateDateTime",
209
+ "firstActivityDateTime",
210
+ "lastActivityDateTime",
211
+ ]:
212
+ timestamp: Optional[str] = graph_alert.get(time_field)
213
+ if timestamp:
214
+ try:
215
+ dt_obj = parser.isoparse(timestamp)
216
+ if time_field == "createdDateTime":
217
+ howler_hit["event"]["created"] = dt_obj.isoformat()
218
+ elif time_field == "firstActivityDateTime":
219
+ howler_hit["event"]["start"] = dt_obj.isoformat()
220
+ elif time_field == "lastActivityDateTime":
221
+ howler_hit["event"]["end"] = dt_obj.isoformat()
222
+ except Exception as exc:
223
+ logger.warning("Invalid timestamp format for %s: %s (%s)", time_field, timestamp, exc)
224
+
225
+ def _build_labels(self, custom_tags: list[str], system_tags: list[str]) -> list[str]:
226
+ """Build combined labels from custom and system tags.
227
+
228
+ Args:
229
+ custom_tags (list[str]): Custom tags.
230
+ system_tags (list[str]): System tags.
231
+ Returns:
232
+ list[str]: Combined label list.
233
+ """
234
+ labels: list[str] = []
235
+ if custom_tags:
236
+ labels.extend(custom_tags)
237
+ if system_tags:
238
+ labels.extend(["system_%s" % tag for tag in system_tags])
239
+ labels.append("sentinel_incident")
240
+ return labels