howler-sentinel-plugin 0.2.0.dev105__py3-none-any.whl → 0.2.0.dev137__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.
- {howler_sentinel_plugin-0.2.0.dev105.dist-info → howler_sentinel_plugin-0.2.0.dev137.dist-info}/METADATA +1 -1
- howler_sentinel_plugin-0.2.0.dev137.dist-info/RECORD +18 -0
- sentinel/actions/send_to_sentinel.py +1 -1
- sentinel/actions/update_defender_xdr_alert.py +9 -9
- sentinel/actions/update_defender_xdr_incident.py +194 -0
- sentinel/mapping/sentinel_incident.py +10 -4
- sentinel/mapping/xdr_alert.py +3 -9
- sentinel/mapping/xdr_alert_evidence.py +44 -41
- sentinel/routes/ingest.py +194 -104
- howler_sentinel_plugin-0.2.0.dev105.dist-info/RECORD +0 -17
- {howler_sentinel_plugin-0.2.0.dev105.dist-info → howler_sentinel_plugin-0.2.0.dev137.dist-info}/LICENSE +0 -0
- {howler_sentinel_plugin-0.2.0.dev105.dist-info → howler_sentinel_plugin-0.2.0.dev137.dist-info}/WHEEL +0 -0
- {howler_sentinel_plugin-0.2.0.dev105.dist-info → howler_sentinel_plugin-0.2.0.dev137.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
sentinel/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sentinel/actions/azure_emit_hash.py,sha256=ES9u3iIkm18qcwVT7-f3r8K5d-haCnjSTC7cyOKxH7A,4000
|
|
3
|
+
sentinel/actions/send_to_sentinel.py,sha256=RRmUSiPDKr7oQjA4f-iSGjEtziUQH77O2s-578pb1Uc,4022
|
|
4
|
+
sentinel/actions/update_defender_xdr_alert.py,sha256=bHBHEZwAAT1pHTF2L4JK5gttzguq12NdV58fx0tj4dQ,6799
|
|
5
|
+
sentinel/actions/update_defender_xdr_incident.py,sha256=HjvK8yTDafYePY5LoMkYat_cFUHnFWK3tzpCfAO3JNE,6854
|
|
6
|
+
sentinel/mapping/sentinel_incident.py,sha256=UtC430gqxV8r_7L56TCXdvwQKgjgXaNzmWmKmgVV3mI,9740
|
|
7
|
+
sentinel/mapping/xdr_alert.py,sha256=J-o76G6gJy2R_bZO7FBgG3Ks8QToIkr5Uy7HRHzmb6w,17994
|
|
8
|
+
sentinel/mapping/xdr_alert_evidence.py,sha256=iMn9Wd5NB7Wi9l0Fl0vmJhugX8L6hAO9jYA9AtLLX2o,31429
|
|
9
|
+
sentinel/odm/hit.py,sha256=hAuO2ONMK3Ml8Xu6E7tHrmZ7M6HG5tT38RD9ZxwY254,666
|
|
10
|
+
sentinel/odm/models/sentinel.py,sha256=XT3XdT92uoCV5vmY9dT1jmcxRyuu9vp1gE8AwZdKBIc,337
|
|
11
|
+
sentinel/routes/__init__.py,sha256=JYmKRwIfEsiPos1XuMQ2mlGDbxk6TN_cVEM0K_RNze4,130
|
|
12
|
+
sentinel/routes/ingest.py,sha256=lVBr6I5p5WUAUCkgHe4UBUwdjsJCGb_FjwkeXQLXcaI,10902
|
|
13
|
+
sentinel/utils/tenant_utils.py,sha256=nGOCbLzUx9OyATLAZ5UbW0WNao_1ioW4wL-htn2ltKU,1324
|
|
14
|
+
howler_sentinel_plugin-0.2.0.dev137.dist-info/LICENSE,sha256=Wg2luVnxEkP2NSn11nh1US6W_nFFbICBAVTG9iG3t5M,1091
|
|
15
|
+
howler_sentinel_plugin-0.2.0.dev137.dist-info/METADATA,sha256=1lrkeLVxG_TuAedKYDmEKGj9n3OS5ZXPz0SSOYvUo2c,749
|
|
16
|
+
howler_sentinel_plugin-0.2.0.dev137.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
17
|
+
howler_sentinel_plugin-0.2.0.dev137.dist-info/entry_points.txt,sha256=4IJyMY0V49s3Wp659ngN_7U8g66-czeKxI-_dNAFP5g,60
|
|
18
|
+
howler_sentinel_plugin-0.2.0.dev137.dist-info/RECORD,,
|
|
@@ -101,7 +101,7 @@ def execute(query: str, **kwargs) -> list[dict[str, Any]]:
|
|
|
101
101
|
"query": f"howler.id:{hit.howler.id}",
|
|
102
102
|
"outcome": "success",
|
|
103
103
|
"title": "Alert updated in Sentinel",
|
|
104
|
-
"message": "Howler has
|
|
104
|
+
"message": "Howler has successfully propagated changes to this alert to Sentinel.",
|
|
105
105
|
}
|
|
106
106
|
)
|
|
107
107
|
|
|
@@ -165,15 +165,15 @@ def execute(query: str, **kwargs):
|
|
|
165
165
|
"message": f"PATCH request to Microsoft Graph failed with status code {response.status_code}.",
|
|
166
166
|
}
|
|
167
167
|
)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
168
|
+
else:
|
|
169
|
+
report.append(
|
|
170
|
+
{
|
|
171
|
+
"query": f"howler.id:{hit.howler.id}",
|
|
172
|
+
"outcome": "success",
|
|
173
|
+
"title": "Alert updated in XDR Defender",
|
|
174
|
+
"message": "Howler has successfully propagated changes to this alert to XDR Defender.",
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
177
|
|
|
178
178
|
return report
|
|
179
179
|
|
|
@@ -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
|
+
}
|
|
@@ -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": {
|
|
@@ -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",
|
sentinel/mapping/xdr_alert.py
CHANGED
|
@@ -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": {
|
|
@@ -232,10 +232,13 @@ class XDRAlertEvidence:
|
|
|
232
232
|
@staticmethod
|
|
233
233
|
def device_evidence(evidence: dict[str, Any]) -> dict[str, Any]:
|
|
234
234
|
"""Convert device evidence to howler evidence format."""
|
|
235
|
+
hostname = evidence.get("hostName")
|
|
236
|
+
if not hostname:
|
|
237
|
+
hostname = evidence.get("deviceDnsName")
|
|
235
238
|
return {
|
|
236
239
|
"host": {
|
|
237
240
|
"id": evidence.get("mdeDeviceId"),
|
|
238
|
-
"name":
|
|
241
|
+
"name": hostname,
|
|
239
242
|
"domain": [evidence.get("ntDomain", "unknown")],
|
|
240
243
|
"os": {
|
|
241
244
|
"platform": evidence.get("osPlatform"),
|
|
@@ -668,51 +671,51 @@ class XDRAlertEvidence:
|
|
|
668
671
|
"status_remediation_details": evidence.get("remediationStatusDetails"),
|
|
669
672
|
},
|
|
670
673
|
"teams": {
|
|
671
|
-
"campaign_id": evidence.get("
|
|
672
|
-
"channel_id": evidence.get("
|
|
673
|
-
"delivery_action": evidence.get("
|
|
674
|
-
"delivery_location": evidence.get("
|
|
675
|
-
"detailed_roles": evidence.get("
|
|
674
|
+
"campaign_id": evidence.get("campaignId"),
|
|
675
|
+
"channel_id": evidence.get("channelId"),
|
|
676
|
+
"delivery_action": evidence.get("deliveryAction"),
|
|
677
|
+
"delivery_location": evidence.get("deliveryLocation"),
|
|
678
|
+
"detailed_roles": evidence.get("detailedRoles"),
|
|
676
679
|
"files": [
|
|
677
680
|
{
|
|
678
|
-
"path": file.get("
|
|
679
|
-
"name": file.get("
|
|
681
|
+
"path": file.get("fileDetails", {}).get("filePath"),
|
|
682
|
+
"name": file.get("fileDetails", {}).get("fileName"),
|
|
680
683
|
"hash": {
|
|
681
|
-
"sha1": file.get("
|
|
682
|
-
"sha256": file.get("
|
|
683
|
-
"md5": file.get("
|
|
684
|
+
"sha1": file.get("fileDetails", {}).get("sha1"),
|
|
685
|
+
"sha256": file.get("fileDetails", {}).get("sha256"),
|
|
686
|
+
"md5": file.get("fileDetails", {}).get("md5"),
|
|
684
687
|
},
|
|
685
688
|
"code_signature": {
|
|
686
|
-
"signing_id": file.get("
|
|
687
|
-
"team_id": file.get("
|
|
689
|
+
"signing_id": file.get("fileDetails", {}).get("signer"),
|
|
690
|
+
"team_id": file.get("fileDetails", {}).get("issuer"),
|
|
688
691
|
},
|
|
689
|
-
"size": file.get("
|
|
692
|
+
"size": file.get("fileDetails", {}).get("fileSize"),
|
|
690
693
|
}
|
|
691
|
-
for file in evidence.get("
|
|
694
|
+
for file in evidence.get("files", [])
|
|
692
695
|
],
|
|
693
|
-
"group_id": evidence.get("
|
|
694
|
-
"is_external": evidence.get("
|
|
695
|
-
"is_owned": evidence.get("
|
|
696
|
-
"last_modified": evidence.get("
|
|
697
|
-
"message_direction": evidence.get("
|
|
698
|
-
"message_id": evidence.get("
|
|
699
|
-
"owning_tenant_id": evidence.get("
|
|
700
|
-
"parent_message_id": evidence.get("
|
|
701
|
-
"received": evidence.get("
|
|
702
|
-
"recipients": evidence.get("
|
|
703
|
-
"sender_from_address": evidence.get("
|
|
704
|
-
"sender_ip": evidence.get("
|
|
705
|
-
"source_add_name": evidence.get("
|
|
706
|
-
"source_id": evidence.get("
|
|
707
|
-
"subject": evidence.get("
|
|
708
|
-
"suspicious_recipients": evidence.get("
|
|
709
|
-
"thread_id": evidence.get("
|
|
710
|
-
"thread_type": evidence.get("
|
|
696
|
+
"group_id": evidence.get("groupId"),
|
|
697
|
+
"is_external": evidence.get("isExternal"),
|
|
698
|
+
"is_owned": evidence.get("isOwned"),
|
|
699
|
+
"last_modified": evidence.get("lastModified"),
|
|
700
|
+
"message_direction": evidence.get("messageDirection"),
|
|
701
|
+
"message_id": evidence.get("messageId"),
|
|
702
|
+
"owning_tenant_id": evidence.get("owningTenantId"),
|
|
703
|
+
"parent_message_id": evidence.get("parentMessageId"),
|
|
704
|
+
"received": evidence.get("received"),
|
|
705
|
+
"recipients": evidence.get("recipients"),
|
|
706
|
+
"sender_from_address": evidence.get("senderFromAddress"),
|
|
707
|
+
"sender_ip": evidence.get("senderIp"),
|
|
708
|
+
"source_add_name": evidence.get("sourceAddName"),
|
|
709
|
+
"source_id": evidence.get("sourceId"),
|
|
710
|
+
"subject": evidence.get("subject"),
|
|
711
|
+
"suspicious_recipients": evidence.get("suspiciousRecipients"),
|
|
712
|
+
"thread_id": evidence.get("threadId"),
|
|
713
|
+
"thread_type": evidence.get("threadType"),
|
|
711
714
|
"urls": [
|
|
712
715
|
{
|
|
713
|
-
"full": url.get("
|
|
716
|
+
"full": url.get("url"),
|
|
714
717
|
}
|
|
715
|
-
for url in evidence.get("
|
|
718
|
+
for url in evidence.get("urls", [])
|
|
716
719
|
],
|
|
717
720
|
},
|
|
718
721
|
}
|
|
@@ -722,22 +725,22 @@ class XDRAlertEvidence:
|
|
|
722
725
|
"""Convert URL evidence to howler evidence format."""
|
|
723
726
|
return {
|
|
724
727
|
"url": {
|
|
725
|
-
"full": evidence.get("
|
|
728
|
+
"full": evidence.get("url"),
|
|
726
729
|
},
|
|
727
730
|
}
|
|
728
731
|
|
|
729
732
|
@staticmethod
|
|
730
733
|
def user_evidence(evidence: dict[str, Any]) -> dict[str, Any]:
|
|
731
734
|
"""Convert user evidence to howler evidence format."""
|
|
732
|
-
user = evidence.get("
|
|
735
|
+
user = evidence.get("userAccount", {})
|
|
733
736
|
if not user:
|
|
734
737
|
user = {}
|
|
735
738
|
return {
|
|
736
739
|
"user": {
|
|
737
|
-
"name": user.get("
|
|
738
|
-
"full_name": user.get("
|
|
739
|
-
"id": user.get("
|
|
740
|
-
"domain": user.get("
|
|
740
|
+
"name": user.get("accountName"),
|
|
741
|
+
"full_name": user.get("userPrincipalName"),
|
|
742
|
+
"id": user.get("azureAdUserId"),
|
|
743
|
+
"domain": user.get("domainName"),
|
|
741
744
|
},
|
|
742
745
|
}
|
|
743
746
|
|
sentinel/routes/ingest.py
CHANGED
|
@@ -3,7 +3,7 @@ import re
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
5
|
from flask import request
|
|
6
|
-
from howler.api import bad_request, created, make_subapi_blueprint, unauthorized
|
|
6
|
+
from howler.api import bad_request, created, internal_error, make_subapi_blueprint, ok, unauthorized
|
|
7
7
|
from howler.common.exceptions import HowlerException
|
|
8
8
|
from howler.common.loader import datastore
|
|
9
9
|
from howler.common.logging import get_logger
|
|
@@ -31,141 +31,231 @@ if SECRET.startswith("abcdef"):
|
|
|
31
31
|
def ingest_xdr_incident(**kwargs) -> tuple[dict[str, Any], int]: # noqa C901
|
|
32
32
|
"""Ingest a Microsoft Sentinel XDR incident into Howler.
|
|
33
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
34
|
Variables:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
35
|
+
None
|
|
36
|
+
|
|
37
|
+
Arguments:
|
|
38
|
+
None
|
|
39
|
+
|
|
40
|
+
Data Block:
|
|
41
|
+
{
|
|
42
|
+
...Sentinel XDR incident JSON...
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Headers:
|
|
46
|
+
Authorization: API in the format "Basic <key>"
|
|
47
|
+
|
|
48
|
+
Result Example (201 Created):
|
|
49
|
+
{
|
|
50
|
+
"success": True,
|
|
51
|
+
"bundle_hit_id": "howler-bundle-id",
|
|
52
|
+
"bundle_id": "sentinel-incident-id",
|
|
53
|
+
"individual_hit_ids": ["alert-hit-id-1", "alert-hit-id-2"],
|
|
54
|
+
"total_hits_created": 3,
|
|
55
|
+
"bundle_size": 2,
|
|
56
|
+
"organization": "Acme Corporation"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
Result Example (200 OK, update):
|
|
60
|
+
{
|
|
61
|
+
"success": True,
|
|
62
|
+
"bundle_hit_id": "howler-bundle-id",
|
|
63
|
+
"bundle_id": "sentinel-incident-id",
|
|
64
|
+
"individual_hit_ids": ["alert-hit-id-1", "alert-hit-id-2"],
|
|
65
|
+
"total_hits_updated": 3,
|
|
66
|
+
"bundle_size": 2,
|
|
67
|
+
"organization": "Acme Corporation",
|
|
68
|
+
"updated": True
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Error Codes:
|
|
72
|
+
400 - Bad request (e.g., missing JSON)
|
|
73
|
+
401 - Unauthorized (invalid API key)
|
|
74
|
+
500 - Internal server error
|
|
75
|
+
|
|
76
|
+
Description:
|
|
77
|
+
Receives a Microsoft Sentinel XDR incident as JSON, maps it to Howler format, and creates or updates a bundle
|
|
78
|
+
and its underlying alerts in Howler. Returns details about the created or updated bundle and alerts.
|
|
51
79
|
"""
|
|
52
|
-
# API Key authentication
|
|
53
80
|
apikey = request.headers.get("Authorization", "Basic ", type=str).split(" ")[1]
|
|
54
|
-
|
|
55
81
|
if not apikey or apikey != SECRET:
|
|
56
82
|
return unauthorized(err="API Key does not match expected value.")
|
|
57
83
|
|
|
58
84
|
logger.info("Received authorization header with value %s", re.sub(r"^(.{3}).+(.{3})$", r"\1...\2", apikey))
|
|
59
85
|
|
|
60
|
-
# Validate JSON payload
|
|
61
86
|
xdr_incident = request.json
|
|
62
87
|
if not xdr_incident:
|
|
63
88
|
return bad_request(err="No JSON data provided in request body")
|
|
64
89
|
|
|
65
|
-
logger.info("XDR Incident received")
|
|
66
|
-
|
|
67
90
|
try:
|
|
68
|
-
#
|
|
91
|
+
# TODO needs to be replaced with actual tenant mapping logic
|
|
69
92
|
tenant_mapping = {"020cd98f-1002-45b7-90ff-69fc68bdd027": "Acme Corporation"}
|
|
70
|
-
|
|
71
93
|
incident_mapper = SentinelIncident(tid_mapping=tenant_mapping)
|
|
72
94
|
bundle_hit = incident_mapper.map_incident_to_bundle(xdr_incident)
|
|
73
|
-
|
|
74
95
|
if bundle_hit is None:
|
|
75
|
-
return
|
|
96
|
+
return internal_error(err="Failed to map XDR incident to Howler bundle format")
|
|
76
97
|
|
|
77
|
-
|
|
98
|
+
sentinel_id = xdr_incident.get("id")
|
|
99
|
+
if sentinel_id:
|
|
100
|
+
existing_bundles = datastore().hit.search(f"sentinel.id:{sentinel_id}", as_obj=True)["items"]
|
|
101
|
+
if existing_bundles:
|
|
102
|
+
return _update_existing_incident(existing_bundles[0], xdr_incident, incident_mapper)
|
|
78
103
|
|
|
79
|
-
|
|
80
|
-
tenant_id = xdr_incident.get("tenantId", "")
|
|
104
|
+
return _create_new_incident(bundle_hit, xdr_incident, tenant_mapping)
|
|
81
105
|
|
|
82
|
-
|
|
106
|
+
except HowlerException as e:
|
|
107
|
+
logger.exception("Failed to process XDR incident")
|
|
108
|
+
return internal_error(err=f"Failed to process XDR incident: {str(e)}")
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.exception("Unexpected error during XDR incident ingestion")
|
|
111
|
+
return internal_error(err=f"Internal error occurred during ingestion: {str(e)}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _update_existing_incident(
|
|
115
|
+
existing_bundle: Any, xdr_incident: dict[str, Any], incident_mapper: SentinelIncident
|
|
116
|
+
) -> tuple[dict[str, Any], int]:
|
|
117
|
+
"""Update an existing incident and its underlying alerts in Howler.
|
|
83
118
|
|
|
84
|
-
|
|
85
|
-
|
|
119
|
+
Args:
|
|
120
|
+
existing_bundle: The existing Howler bundle object.
|
|
121
|
+
xdr_incident: The incoming XDR incident data.
|
|
122
|
+
incident_mapper: The incident mapper instance.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Tuple containing response dictionary and HTTP status code.
|
|
126
|
+
"""
|
|
127
|
+
new_status = xdr_incident.get("status")
|
|
128
|
+
if new_status:
|
|
129
|
+
existing_bundle.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
|
|
130
|
+
datastore().hit.save(existing_bundle.howler.id, existing_bundle)
|
|
131
|
+
for child_id in getattr(existing_bundle.howler, "hits", []):
|
|
132
|
+
child_hit = datastore().hit.get(child_id, as_obj=True)
|
|
133
|
+
if child_hit:
|
|
134
|
+
child_hit.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
|
|
135
|
+
datastore().hit.save(child_id, child_hit)
|
|
136
|
+
datastore().hit.commit()
|
|
137
|
+
logger.info("Updated status for existing bundle %s and its child hits", existing_bundle.howler.id)
|
|
138
|
+
return ok(
|
|
139
|
+
{
|
|
140
|
+
"success": True,
|
|
141
|
+
"bundle_hit_id": existing_bundle.howler.id,
|
|
142
|
+
"bundle_id": existing_bundle.sentinel.id if hasattr(existing_bundle, "sentinel") else None,
|
|
143
|
+
"individual_hit_ids": getattr(existing_bundle.howler, "hits", []),
|
|
144
|
+
"total_hits_updated": 1 + len(getattr(existing_bundle.howler, "hits", [])),
|
|
145
|
+
"bundle_size": len(getattr(existing_bundle.howler, "hits", [])),
|
|
146
|
+
"organization": getattr(existing_bundle, "organization", {}).get("name", ""),
|
|
147
|
+
"updated": True,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _create_alert_hits(alerts: list[dict[str, Any]], tenant_id: str, alert_mapper: XDRAlert) -> list[str]:
|
|
153
|
+
"""Create alert hits from the provided alerts and return their IDs.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
alerts: List of alert dictionaries.
|
|
157
|
+
tenant_id: The tenant ID string.
|
|
158
|
+
alert_mapper: The alert mapper instance.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
List of created alert hit IDs.
|
|
162
|
+
"""
|
|
163
|
+
child_hit_ids = []
|
|
164
|
+
for i, alert in enumerate(alerts):
|
|
165
|
+
try:
|
|
166
|
+
mapped_hit = alert_mapper.map_alert(alert, tenant_id)
|
|
167
|
+
if mapped_hit:
|
|
168
|
+
alert_hit_odm, _ = hit_service.convert_hit(mapped_hit, unique=True, ignore_extra_values=True)
|
|
169
|
+
if alert_hit_odm.event is not None:
|
|
170
|
+
alert_hit_odm.event.id = alert_hit_odm.howler.id
|
|
171
|
+
logger.info("Creating individual alert hit %s with ID %s", i, alert_hit_odm.howler.id)
|
|
172
|
+
hit_service.create_hit(alert_hit_odm.howler.id, alert_hit_odm, user="system")
|
|
173
|
+
analytic_service.save_from_hit(alert_hit_odm, {"uname": "system"})
|
|
174
|
+
child_hit_ids.append(alert_hit_odm.howler.id)
|
|
175
|
+
logger.debug("Successfully created alert hit %s: %s", i, alert_hit_odm.howler.id)
|
|
176
|
+
else:
|
|
177
|
+
logger.warning("Alert mapper returned None for alert %s: %s", i, alert.get("id", "unknown"))
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.exception("Failed to create individual alert hit %s", i)
|
|
180
|
+
continue
|
|
181
|
+
return child_hit_ids
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _link_child_hits_to_bundle(bundle_odm: Any, child_hit_ids: list[str]) -> None:
|
|
185
|
+
"""Link child hits to the bundle and update their bundle references.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
bundle_odm: The bundle ODM object.
|
|
189
|
+
child_hit_ids: List of child hit IDs to link.
|
|
190
|
+
"""
|
|
191
|
+
for hit_id in bundle_odm.howler.hits:
|
|
192
|
+
child_hit = hit_service.get_hit(hit_id, as_odm=True)
|
|
86
193
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if mapped_hit:
|
|
91
|
-
alert_hit_odm, _ = hit_service.convert_hit(mapped_hit, unique=True, ignore_extra_values=True)
|
|
194
|
+
if child_hit.howler.is_bundle:
|
|
195
|
+
logger.warning("Child hit %s is a bundle - skipping bundle assignment", child_hit.howler.id)
|
|
196
|
+
continue
|
|
92
197
|
|
|
93
|
-
|
|
94
|
-
|
|
198
|
+
new_bundle_list = child_hit.howler.get("bundles", [])
|
|
199
|
+
new_bundle_list.append(bundle_odm.howler.id)
|
|
200
|
+
child_hit.howler.bundles = new_bundle_list
|
|
201
|
+
datastore().hit.save(child_hit.howler.id, child_hit)
|
|
95
202
|
|
|
96
|
-
logger.info("Creating individual alert hit %s with ID %s", i, alert_hit_odm.howler.id)
|
|
97
|
-
hit_service.create_hit(alert_hit_odm.howler.id, alert_hit_odm, user="system")
|
|
98
|
-
analytic_service.save_from_hit(alert_hit_odm, {"uname": "system"})
|
|
99
203
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
except Exception:
|
|
105
|
-
logger.exception("Failed to create individual alert hit %s", i)
|
|
106
|
-
continue
|
|
204
|
+
def _create_new_incident(
|
|
205
|
+
bundle_hit: dict[str, Any], xdr_incident: dict[str, Any], tenant_mapping: dict[str, str]
|
|
206
|
+
) -> tuple[dict[str, Any], int]:
|
|
207
|
+
"""Create a new incident bundle and its underlying alerts in Howler.
|
|
107
208
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
209
|
+
Args:
|
|
210
|
+
bundle_hit: The mapped Howler bundle data.
|
|
211
|
+
xdr_incident: The incoming XDR incident data.
|
|
212
|
+
tenant_mapping: The tenant mapping dictionary.
|
|
111
213
|
|
|
214
|
+
Returns:
|
|
215
|
+
Tuple containing response dictionary and HTTP status code.
|
|
216
|
+
"""
|
|
217
|
+
alerts = xdr_incident.get("alerts", [])
|
|
218
|
+
tenant_id = xdr_incident.get("tenantId", "")
|
|
219
|
+
alert_mapper = XDRAlert(tid_mapping=tenant_mapping)
|
|
220
|
+
child_hit_ids = _create_alert_hits(alerts, tenant_id, alert_mapper)
|
|
221
|
+
try:
|
|
222
|
+
bundle_odm, _ = hit_service.convert_hit(bundle_hit, unique=True)
|
|
223
|
+
# If there are no alerts, do not treat as bundle
|
|
224
|
+
if child_hit_ids:
|
|
225
|
+
bundle_odm.howler.is_bundle = True
|
|
112
226
|
if not hasattr(bundle_odm.howler, "hits") or not isinstance(bundle_odm.howler.hits, list):
|
|
113
227
|
bundle_odm.howler.hits = []
|
|
114
228
|
for hit_id in child_hit_ids:
|
|
115
229
|
if hit_id not in bundle_odm.howler.hits:
|
|
116
230
|
bundle_odm.howler.hits.append(hit_id)
|
|
117
|
-
|
|
118
|
-
if len(bundle_odm.howler.hits) < 1:
|
|
119
|
-
logger.error("No valid child hits were created from the XDR incident alerts")
|
|
120
|
-
return bad_request(err="No valid child hits were created from the XDR incident alerts.")
|
|
121
|
-
|
|
122
231
|
bundle_odm.howler.bundle_size = len(bundle_odm.howler.hits)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
new_bundle_list = child_hit.howler.get("bundles", [])
|
|
140
|
-
new_bundle_list.append(bundle_odm.howler.id)
|
|
141
|
-
child_hit.howler.bundles = new_bundle_list
|
|
142
|
-
datastore().hit.save(child_hit.howler.id, child_hit)
|
|
143
|
-
|
|
144
|
-
datastore().hit.commit()
|
|
232
|
+
else:
|
|
233
|
+
bundle_odm.howler.is_bundle = False
|
|
234
|
+
bundle_odm.howler.hits = []
|
|
235
|
+
bundle_odm.howler.bundle_size = 0
|
|
236
|
+
|
|
237
|
+
if bundle_odm.event is not None:
|
|
238
|
+
bundle_odm.event.id = bundle_odm.howler.id
|
|
239
|
+
|
|
240
|
+
logger.info("Creating incident hit with ID %s", bundle_odm.howler.id)
|
|
241
|
+
hit_service.create_hit(bundle_odm.howler.id, bundle_odm, user="system")
|
|
242
|
+
analytic_service.save_from_hit(bundle_odm, {"uname": "system"})
|
|
243
|
+
if child_hit_ids:
|
|
244
|
+
_link_child_hits_to_bundle(bundle_odm, child_hit_ids)
|
|
245
|
+
datastore().hit.commit()
|
|
246
|
+
if child_hit_ids:
|
|
145
247
|
action_service.bulk_execute_on_query(f"howler.id:{bundle_odm.howler.id}", user={"uname": "system"})
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return created(response_body)
|
|
160
|
-
|
|
161
|
-
except HowlerException as e:
|
|
162
|
-
logger.exception("Failed to create bundle")
|
|
163
|
-
return bad_request(err=f"Failed to create bundle: {str(e)}")
|
|
164
|
-
|
|
248
|
+
logger.info("Successfully completed XDR incident ingestion")
|
|
249
|
+
response_body = {
|
|
250
|
+
"success": True,
|
|
251
|
+
"bundle_hit_id": bundle_odm.howler.id,
|
|
252
|
+
"bundle_id": bundle_hit["howler"].get("xdr.incident.id"),
|
|
253
|
+
"individual_hit_ids": child_hit_ids,
|
|
254
|
+
"total_hits_created": len(child_hit_ids) + 1,
|
|
255
|
+
"bundle_size": len(child_hit_ids),
|
|
256
|
+
"organization": bundle_hit["organization"]["name"],
|
|
257
|
+
}
|
|
258
|
+
return created(response_body)
|
|
165
259
|
except HowlerException as e:
|
|
166
|
-
logger.exception("Failed to
|
|
167
|
-
return
|
|
168
|
-
|
|
169
|
-
except Exception as e:
|
|
170
|
-
logger.exception("Unexpected error during XDR incident ingestion")
|
|
171
|
-
return bad_request(err=f"Internal error occurred during ingestion: {str(e)}")
|
|
260
|
+
logger.exception("Failed to create bundle")
|
|
261
|
+
return internal_error(err=f"Failed to create bundle: {str(e)}")
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
sentinel/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
sentinel/actions/azure_emit_hash.py,sha256=ES9u3iIkm18qcwVT7-f3r8K5d-haCnjSTC7cyOKxH7A,4000
|
|
3
|
-
sentinel/actions/send_to_sentinel.py,sha256=s07DTPSVTSC4JwmUQe2q6W6MabfHtE43dmRj3nuE7EM,4021
|
|
4
|
-
sentinel/actions/update_defender_xdr_alert.py,sha256=gXuVZfr7Ou0Lc6BuECmo18E8hfJPhUlM1VMKVCkofk4,6753
|
|
5
|
-
sentinel/mapping/sentinel_incident.py,sha256=U7fIh8N4Jdr1A4z1E0jPRP28Ll0Cq7u9Q6292AnyRDI,9548
|
|
6
|
-
sentinel/mapping/xdr_alert.py,sha256=UPoqdZsjUXmJz0dCf_qMlh9Jr0D2HcSNOFvbg8lE4wY,18250
|
|
7
|
-
sentinel/mapping/xdr_alert_evidence.py,sha256=q622G4eZwFR3TCj418ZCpE83DGVicrWIQZo8Gkj_3FM,31323
|
|
8
|
-
sentinel/odm/hit.py,sha256=hAuO2ONMK3Ml8Xu6E7tHrmZ7M6HG5tT38RD9ZxwY254,666
|
|
9
|
-
sentinel/odm/models/sentinel.py,sha256=XT3XdT92uoCV5vmY9dT1jmcxRyuu9vp1gE8AwZdKBIc,337
|
|
10
|
-
sentinel/routes/__init__.py,sha256=JYmKRwIfEsiPos1XuMQ2mlGDbxk6TN_cVEM0K_RNze4,130
|
|
11
|
-
sentinel/routes/ingest.py,sha256=_9OdOw_9nBJseKIBnmHDLjnqZ_bDdM4wfLpLrek4-ak,7018
|
|
12
|
-
sentinel/utils/tenant_utils.py,sha256=nGOCbLzUx9OyATLAZ5UbW0WNao_1ioW4wL-htn2ltKU,1324
|
|
13
|
-
howler_sentinel_plugin-0.2.0.dev105.dist-info/LICENSE,sha256=Wg2luVnxEkP2NSn11nh1US6W_nFFbICBAVTG9iG3t5M,1091
|
|
14
|
-
howler_sentinel_plugin-0.2.0.dev105.dist-info/METADATA,sha256=XI_GN5e_-qHwfo7SpRzuMC0WVEk9s8Ok3e5jcX5mG2o,749
|
|
15
|
-
howler_sentinel_plugin-0.2.0.dev105.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
16
|
-
howler_sentinel_plugin-0.2.0.dev105.dist-info/entry_points.txt,sha256=4IJyMY0V49s3Wp659ngN_7U8g66-czeKxI-_dNAFP5g,60
|
|
17
|
-
howler_sentinel_plugin-0.2.0.dev105.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|