howler-sentinel-plugin 0.2.0.dev137__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.dev137 → howler_sentinel_plugin-0.2.0.dev147}/PKG-INFO +1 -1
- {howler_sentinel_plugin-0.2.0.dev137 → howler_sentinel_plugin-0.2.0.dev147}/pyproject.toml +1 -1
- {howler_sentinel_plugin-0.2.0.dev137 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/actions/send_to_sentinel.py +20 -3
- {howler_sentinel_plugin-0.2.0.dev137 → 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.dev137 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/mapping/sentinel_incident.py +1 -1
- howler_sentinel_plugin-0.2.0.dev147/sentinel/odm/__init__.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev137 → 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.dev137 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/routes/ingest.py +9 -10
- howler_sentinel_plugin-0.2.0.dev147/sentinel/utils/tenant_utils.py +56 -0
- howler_sentinel_plugin-0.2.0.dev137/sentinel/routes/__init__.py +0 -5
- howler_sentinel_plugin-0.2.0.dev137/sentinel/utils/tenant_utils.py +0 -40
- {howler_sentinel_plugin-0.2.0.dev137 → howler_sentinel_plugin-0.2.0.dev147}/LICENSE +0 -0
- {howler_sentinel_plugin-0.2.0.dev137 → howler_sentinel_plugin-0.2.0.dev147}/README.md +0 -0
- {howler_sentinel_plugin-0.2.0.dev137 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/__init__.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev137 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/actions/azure_emit_hash.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev137 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/actions/update_defender_xdr_incident.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev137 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/mapping/xdr_alert.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev137 → howler_sentinel_plugin-0.2.0.dev147}/sentinel/mapping/xdr_alert_evidence.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev137 → 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
|
|
@@ -100,7 +100,7 @@ class SentinelIncident:
|
|
|
100
100
|
# Add assessment conditionally if classification is not null
|
|
101
101
|
if classification is not None:
|
|
102
102
|
bundle["howler"]["assessment"] = self.map_classification(classification)
|
|
103
|
-
logger.info("Successfully mapped
|
|
103
|
+
logger.info("Successfully mapped Sentinel Incident %s", incident_id)
|
|
104
104
|
return bundle
|
|
105
105
|
|
|
106
106
|
except Exception as exc:
|
|
File without changes
|
{howler_sentinel_plugin-0.2.0.dev137 → 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
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import re
|
|
3
2
|
from typing import Any
|
|
4
3
|
|
|
@@ -10,8 +9,8 @@ from howler.common.logging import get_logger
|
|
|
10
9
|
from howler.common.swagger import generate_swagger_docs
|
|
11
10
|
from howler.services import action_service, analytic_service, hit_service
|
|
12
11
|
|
|
13
|
-
from
|
|
14
|
-
from
|
|
12
|
+
from sentinel.mapping.sentinel_incident import SentinelIncident
|
|
13
|
+
from sentinel.mapping.xdr_alert import XDRAlert
|
|
15
14
|
|
|
16
15
|
SUB_API = "sentinel"
|
|
17
16
|
sentinel_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
@@ -19,12 +18,6 @@ sentinel_api._doc = "Ingest Microsoft Sentinel XDR incidents into Howler"
|
|
|
19
18
|
|
|
20
19
|
logger = get_logger(__file__)
|
|
21
20
|
|
|
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
21
|
|
|
29
22
|
@generate_swagger_docs()
|
|
30
23
|
@sentinel_api.route("/ingest", methods=["POST"])
|
|
@@ -77,8 +70,14 @@ def ingest_xdr_incident(**kwargs) -> tuple[dict[str, Any], int]: # noqa C901
|
|
|
77
70
|
Receives a Microsoft Sentinel XDR incident as JSON, maps it to Howler format, and creates or updates a bundle
|
|
78
71
|
and its underlying alerts in Howler. Returns details about the created or updated bundle and alerts.
|
|
79
72
|
"""
|
|
73
|
+
from sentinel.config import config
|
|
74
|
+
|
|
75
|
+
# API Key authentication
|
|
80
76
|
apikey = request.headers.get("Authorization", "Basic ", type=str).split(" ")[1]
|
|
81
|
-
|
|
77
|
+
|
|
78
|
+
link_key = config.auth.link_key
|
|
79
|
+
|
|
80
|
+
if not apikey or apikey != link_key:
|
|
82
81
|
return unauthorized(err="API Key does not match expected value.")
|
|
83
82
|
|
|
84
83
|
logger.info("Received authorization header with value %s", re.sub(r"^(.{3}).+(.{3})$", r"\1...\2", apikey))
|
|
@@ -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,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.dev137 → 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
|
|
File without changes
|