Ask socfortress (#107)
* Add AskSocfortress router and update connector URL * Fix alert_id description in CreateAlertRequest * Add Ask SOCFortress Message to Alert Document * Fix issue with adding alert to document * added provision github link to function description * added github link to function docstring * improved notifications * dfir iris customer creation in client provisioning * dfir iris customer decom and alert settings db * added ask_socfortress api * general alert route and check if rule id should be excluded * ask_socfortress_message * alert creation integration for general alerts * added ask_socfortress btn * precommit fixes --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>
taylor_socfortress committed
Dec 12, 2023 at 14:00 UTC
63c9ce4dad3b1d5694da29e3d815f5c08051dcd6
35 files changed
+2295
-119
backend/app/agents/routes/agents.py
+2
-2
@@ -108,8 +108,8 @@ async def get_agent(agent_id: str, db: AsyncSession = Depends(get_session)) -> A
108
else:
109
raise HTTPException(status_code=404, detail=f"Agent with agent_id {agent_id} not found")
110
except Exception as e:
111
- logger.error(f"Failed to fetch agent: {e}")
112
- raise HTTPException(status_code=500, detail=f"Failed to fetch agent: {e}")
111
+ logger.error(f"Failed to fetch agent: {agent_id}. Does it exist?")
112
+ raise HTTPException(status_code=500, detail=f"Failed to fetch agent: {agent_id}. Does it exist?")
113
114
115
@agents_router.get(
backend/app/connectors/dfir_iris/schema/admin.py
new
+37
@@ -0,0 +1,37 @@
1
+import uuid
2
+from datetime import datetime
3
+from typing import Dict
4
+from typing import List
5
+from typing import Optional
6
+
7
+from pydantic import UUID4
8
+from pydantic import BaseModel
9
+
10
+
11
+class CreateCustomerData(BaseModel):
12
+ customer_id: int
13
+ customer_sla: Optional[str] = None
14
+ customer_name: str
15
+ customer_description: Optional[str] = None
16
+ creation_date: datetime
17
+ custom_attributes: Dict
18
+ last_update_date: datetime
19
+ client_uuid: uuid.UUID
20
+
21
+
22
+class CreateCustomerResponse(BaseModel):
23
+ success: bool
24
+ data: CreateCustomerData
25
+
26
+
27
+class Customer(BaseModel):
28
+ customer_name: str
29
+ customer_id: int
30
+ customer_uuid: UUID4
31
+ customer_description: Optional[str] = None
32
+ customer_sla: Optional[str] = None
33
+
34
+
35
+class ListCustomers(BaseModel):
36
+ success: bool
37
+ data: List[Customer]
backend/app/connectors/dfir_iris/utils/universal.py
+15
-1
@@ -6,8 +6,10 @@ from typing import Tuple
6
from typing import Union
7
8
import requests
9
+from dfir_iris_client.admin import AdminHelper
10
from dfir_iris_client.alert import Alert
11
from dfir_iris_client.case import Case
12
+from dfir_iris_client.customer import Customer
13
from dfir_iris_client.helper.utils import assert_api_resp
14
from dfir_iris_client.helper.utils import get_data_from_resp
15
from dfir_iris_client.session import ClientSession
@@ -113,7 +115,7 @@ async def fetch_and_parse_data(session: ClientSession, action: Callable, *args)
115
return {"success": True, "data": data}
116
except Exception as err:
117
logger.error(f"Failed to execute {action.__name__}: {err}")
116
- return HTTPException(status_code=500, detail=f"Failed to execute {action.__name__}: {err}")
118
+ raise HTTPException(status_code=500, detail=f"Failed to execute {action.__name__}: {err}")
119
120
121
async def initialize_client_and_case(service_name: str) -> Tuple[Any, Case]:
@@ -134,6 +136,18 @@ async def initialize_client_and_user(service_name: str) -> Tuple[Any, Alert]:
136
return dfir_iris_client, user
137
138
139
+async def initialize_client_and_admin(service_name: str) -> Tuple[Any, Alert]:
140
+ dfir_iris_client = await create_dfir_iris_client(service_name)
141
+ admin = AdminHelper(session=dfir_iris_client)
142
+ return dfir_iris_client, admin
143
+
144
+
145
+async def initialize_client_and_customer(service_name: str) -> Tuple[Any, Alert]:
146
+ dfir_iris_client = await create_dfir_iris_client(service_name)
147
+ customer = Customer(session=dfir_iris_client)
148
+ return dfir_iris_client, customer
149
+
150
+
151
def handle_error(error_message: str, status_code: int = 500):
152
logger.error(error_message)
153
raise HTTPException(status_code=status_code, detail=error_message)
backend/app/customer_provisioning/schema/provision.py
+3
-1
@@ -34,7 +34,7 @@ class ProvisionNewCustomer(BaseModel):
34
description="List of subscriptions for the customer",
35
)
36
dashboards_to_include: DashboardProvisionRequest = Field(
37
- "EDR_DLL_SIDE_LOADING",
37
+ ...,
38
description="Dashboards to include in the customer's Grafana instance",
39
)
40
wazuh_auth_password: str = Field(..., description="Password for the Wazuh API user")
@@ -44,6 +44,7 @@ class ProvisionNewCustomer(BaseModel):
44
wazuh_cluster_name: str = Field(..., description="Name of the Wazuh cluster")
45
wazuh_cluster_key: str = Field(..., description="Password for the Wazuh cluster")
46
wazuh_master_ip: str = Field(..., description="IP address of the Wazuh master")
47
+ grafana_url: str = Field(..., description="URL of the Grafana instance")
48
49
@validator("customer_index_name")
50
def validate_customer_index_name(cls, v):
@@ -62,6 +63,7 @@ class CustomerProvisionMeta(BaseModel):
63
grafana_organization_id: int
64
wazuh_datasource_uid: str
65
grafana_edr_folder_id: int
66
+ iris_customer_id: int
67
68
69
class CustomerProvisionResponse(BaseModel):
backend/app/customer_provisioning/services/decommission.py
+5
-1
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
6
from app.customer_provisioning.schema.decommission import DecommissionCustomerResponse
7
from app.customer_provisioning.schema.wazuh_worker import DecommissionWorkerRequest
8
from app.customer_provisioning.schema.wazuh_worker import DecommissionWorkerResponse
9
+from app.customer_provisioning.services.dfir_iris import delete_customer
10
from app.customer_provisioning.services.grafana import delete_grafana_organization
11
from app.customer_provisioning.services.graylog import delete_index_set
12
from app.customer_provisioning.services.graylog import delete_stream
@@ -54,6 +55,9 @@ async def decomission_wazuh_customer(customer_meta: CustomersMeta, session: Asyn
55
# Delete Grafana Organization
56
await delete_grafana_organization(organization_id=int(customer_meta.customer_meta_grafana_org_id))
57
58
+ # Delete DFIR-IRIS Customer
59
+ await delete_customer(customer_id=customer_meta.customer_meta_iris_customer_id)
60
+
61
# Decommission Wazuh Worker
62
await decommission_wazuh_worker(request=DecommissionWorkerRequest(customer_name=customer_meta.customer_name), session=session)
63
@@ -76,7 +80,7 @@ async def decomission_wazuh_customer(customer_meta: CustomersMeta, session: Asyn
80
######### ! Decommission Wazuh Worker ! ############
81
async def decommission_wazuh_worker(request: DecommissionWorkerRequest, session: AsyncSession) -> DecommissionWorkerResponse:
82
"""
79
- Decomissions a Wazuh worker.
83
+ Decomissions a Wazuh worker. https://github.com/socfortress/Customer-Provisioning-Worker
84
85
Args:
86
request (DecommissionWorkerRequest): The request object containing the necessary information for provisioning.
backend/app/customer_provisioning/services/dfir_iris.py
new
+34
@@ -0,0 +1,34 @@
1
+from fastapi import HTTPException
2
+from loguru import logger
3
+
4
+from app.connectors.dfir_iris.schema.admin import CreateCustomerResponse
5
+from app.connectors.dfir_iris.schema.admin import ListCustomers
6
+from app.connectors.dfir_iris.utils.universal import fetch_and_validate_data
7
+from app.connectors.dfir_iris.utils.universal import initialize_client_and_admin
8
+from app.connectors.dfir_iris.utils.universal import initialize_client_and_customer
9
+
10
+
11
+async def check_customer_exists(customer_name: str) -> bool:
12
+ client, customer = await initialize_client_and_customer("DFIR-IRIS")
13
+ result = await fetch_and_validate_data(client, customer.list_customers)
14
+ customers = ListCustomers(**result)
15
+ for customer in customers.data:
16
+ if customer.customer_name == customer_name:
17
+ return True
18
+
19
+
20
+async def create_customer(customer_name: str) -> CreateCustomerResponse:
21
+ # check if the customer exists
22
+ exists = await check_customer_exists(customer_name)
23
+ if exists:
24
+ raise HTTPException(status_code=400, detail=f"Customer {customer_name} already exists")
25
+ client, admin = await initialize_client_and_admin("DFIR-IRIS")
26
+ result = await fetch_and_validate_data(client, admin.add_customer, customer_name)
27
+ return CreateCustomerResponse(success=result["success"], data=result["data"])
28
+
29
+
30
+async def delete_customer(customer_id: int):
31
+ client, admin = await initialize_client_and_admin("DFIR-IRIS")
32
+ result = await fetch_and_validate_data(client, admin.delete_customer, customer_id)
33
+ logger.info(f"Result: {result}")
34
+ return None
backend/app/customer_provisioning/services/provision.py
+37
-1
@@ -12,6 +12,7 @@ from app.customer_provisioning.schema.provision import CustomerProvisionResponse
12
from app.customer_provisioning.schema.provision import ProvisionNewCustomer
13
from app.customer_provisioning.schema.wazuh_worker import ProvisionWorkerRequest
14
from app.customer_provisioning.schema.wazuh_worker import ProvisionWorkerResponse
15
+from app.customer_provisioning.services.dfir_iris import create_customer
16
from app.customer_provisioning.services.grafana import create_grafana_datasource
17
from app.customer_provisioning.services.grafana import create_grafana_folder
18
from app.customer_provisioning.services.grafana import create_grafana_organization
@@ -22,6 +23,7 @@ from app.customer_provisioning.services.graylog import get_pipeline_id
23
from app.customer_provisioning.services.wazuh_manager import apply_group_configurations
24
from app.customer_provisioning.services.wazuh_manager import create_wazuh_groups
25
from app.db.universal_models import CustomersMeta
26
+from app.integrations.alert_creation.models.alert_settings import AlertCreationSettings
27
from app.utils import get_connector_attribute
28
29
@@ -69,8 +71,11 @@ async def provision_wazuh_customer(request: ProvisionNewCustomer, session: Async
71
),
72
)
73
74
+ provision_meta_data["iris_customer_id"] = (await create_customer(request.customer_name)).data.customer_id
75
+
76
customer_provision_meta = CustomerProvisionMeta(**provision_meta_data)
77
customer_meta = await update_customer_meta_table(request, customer_provision_meta, session)
78
+ await update_customer_alert_settings_table(request, customer_provision_meta, session)
79
80
provision_worker = await provision_wazuh_worker(
81
ProvisionWorkerRequest(
@@ -126,16 +131,47 @@ async def update_customer_meta_table(request: ProvisionNewCustomer, customer_met
131
customer_meta_wazuh_registration_port=request.wazuh_registration_port,
132
customer_meta_wazuh_log_ingestion_port=request.wazuh_logs_port,
133
customer_meta_wazuh_auth_password=request.wazuh_auth_password,
134
+ customer_meta_iris_customer_id=customer_meta.iris_customer_id,
135
)
136
session.add(customer_meta)
137
await session.commit()
138
return customer_meta
139
140
141
+######### ! Update Customer Alert Settings Table ! ############
142
+async def update_customer_alert_settings_table(request: ProvisionNewCustomer, customer_meta: CustomerProvisionMeta, session: AsyncSession):
143
+ """
144
+ Update the customer alert settings table with the provided information.
145
+
146
+ Args:
147
+ request (ProvisionNewCustomer): The request object containing customer information.
148
+ customer_meta (CustomerProvisionMeta): The customer meta object containing additional information.
149
+ session (AsyncSession): The database session.
150
+
151
+ Returns:
152
+ AlertCreationSettings: The updated customer meta object.
153
+ """
154
+ logger.info(f"Updating customer alert settings table for customer {request.customer_name}")
155
+ customer_alert_settings = AlertCreationSettings(
156
+ customer_code=request.customer_code,
157
+ customer_name=request.customer_name,
158
+ timefield="timestamp_utc",
159
+ iris_customer_id=customer_meta.iris_customer_id,
160
+ iris_customer_name=request.customer_name,
161
+ iris_index=f'dfir_iris_{request.customer_name.lower().replace(" ", "_")}',
162
+ grafana_url=request.grafana_url,
163
+ custom_message="Open In SOCFortress",
164
+ nvd_url="https://services.nvd.nist.gov/rest/json/cves/2.0?cveId",
165
+ )
166
+ session.add(customer_alert_settings)
167
+ await session.commit()
168
+ return customer_alert_settings
169
+
170
+
171
######### ! Provision Wazuh Worker ! ############
172
async def provision_wazuh_worker(request: ProvisionWorkerRequest, session: AsyncSession) -> ProvisionWorkerResponse:
173
"""
138
- Provisions a Wazuh worker.
174
+ Provisions a Wazuh worker. https://github.com/socfortress/Customer-Provisioning-Worker
175
176
Args:
177
request (ProvisionWorkerRequest): The request object containing the necessary information for provisioning.
backend/app/db/all_models.py
+1
@@ -7,4 +7,5 @@ from app.db.universal_models import Agents
7
from app.db.universal_models import Customers
8
from app.db.universal_models import CustomersMeta
9
from app.db.universal_models import LogEntry
10
+from app.integrations.alert_creation.models.alert_settings import AlertCreationSettings
11
from app.schedulers.models.scheduler import JobMetadata
backend/app/db/db_populate.py
+1
-1
@@ -103,7 +103,7 @@ async def add_connectors_if_not_exist(session: AsyncSession):
103
{
104
"connector_name": "AskSocfortress",
105
"connector_type": "3",
106
- "connector_url": "https://api.socfortress.co/rule",
106
+ "connector_url": "https://knowledge.socfortress.co",
107
"connector_username": None,
108
"connector_password": None,
109
"connector_api_key": "CkKmw1B9NM1hG669tC4sTazLm1HlRfSXVvMZkxa9",
backend/app/db/universal_models.py
+4
@@ -56,6 +56,8 @@ class CustomersMeta(SQLModel, table=True):
56
customer_meta_wazuh_registration_port: Optional[str] = Field()
57
customer_meta_wazuh_log_ingestion_port: Optional[str] = Field()
58
customer_meta_wazuh_auth_password: Optional[str] = Field(max_length=1024)
59
+ customer_meta_iris_customer_id: Optional[int] = Field()
60
+ customer_meta_office365_organization_id: Optional[str] = Field(max_length=1024)
61
62
# Link back to Customers
63
customer: Optional["Customers"] = Relationship(back_populates="meta")
@@ -73,6 +75,8 @@ class CustomersMeta(SQLModel, table=True):
75
self.customer_meta_wazuh_registration_port = customer_meta.customer_meta_wazuh_registration_port
76
self.customer_meta_wazuh_log_ingestion_port = customer_meta.customer_meta_wazuh_log_ingestion_port
77
self.customer_meta_wazuh_auth_password = customer_meta.customer_meta_wazuh_auth_password
78
+ self.customer_meta_iris_customer_id = customer_meta.customer_meta_iris_customer_id
79
+ self.customer_meta_office365_organization_id = customer_meta.customer_meta_office365_organization_id
80
81
82
class Agents(SQLModel, table=True):
backend/app/integrations/alert_creation/general/routes/alert.py
new
+47
@@ -0,0 +1,47 @@
1
+from fastapi import APIRouter
2
+from fastapi import Depends
3
+from fastapi import HTTPException
4
+from loguru import logger
5
+from sqlalchemy.ext.asyncio import AsyncSession
6
+from sqlalchemy.future import select
7
+
8
+from app.db.db_session import get_session
9
+from app.integrations.alert_creation.general.schema.alert import CreateAlertRequest
10
+from app.integrations.alert_creation.general.schema.alert import CreateAlertResponse
11
+from app.integrations.alert_creation.general.services.alert import create_alert
12
+from app.integrations.alert_creation.models.alert_settings import AlertCreationSettings
13
+
14
+general_alerts_router = APIRouter()
15
+
16
+
17
+async def is_rule_id_valid(create_alert_request: CreateAlertRequest, session: AsyncSession) -> bool:
18
+ logger.info(f"Checking if rule_id: {create_alert_request.rule_id} is valid for customer: {create_alert_request.agent_labels_customer}")
19
+
20
+ result = await session.execute(
21
+ select(AlertCreationSettings).where(AlertCreationSettings.customer_code == create_alert_request.agent_labels_customer),
22
+ )
23
+ settings = result.scalars().first()
24
+
25
+ if settings and str(create_alert_request.rule_id) in (settings.excluded_wazuh_rules or "").split(","):
26
+ return False
27
+
28
+ return True
29
+
30
+
31
+@general_alerts_router.post(
32
+ "/general",
33
+ response_model=CreateAlertResponse,
34
+ description="Create a general alert in IRIS.",
35
+)
36
+async def create_general_alert(
37
+ create_alert_request: CreateAlertRequest,
38
+ session: AsyncSession = Depends(get_session),
39
+):
40
+ logger.info(f"create_alert_request: {create_alert_request.dict()}")
41
+
42
+ if await is_rule_id_valid(create_alert_request, session) is False:
43
+ logger.info(f"Invalid rule_id: {create_alert_request.rule_id}")
44
+ raise HTTPException(status_code=200, detail="Invalid rule_id.")
45
+
46
+ logger.info(f"Rule id is valid: {create_alert_request.rule_id}")
47
+ return await create_alert(create_alert_request, session=session)
backend/app/integrations/alert_creation/general/schema/alert.py
new
+297
@@ -0,0 +1,297 @@
1
+from enum import Enum
2
+from typing import Any
3
+from typing import Dict
4
+from typing import List
5
+from typing import Optional
6
+
7
+from pydantic import BaseModel
8
+from pydantic import Extra
9
+from pydantic import Field
10
+
11
+
12
+class ValidIocFields(Enum):
13
+ MISP_VALUE = "misp_value"
14
+ OPENCTI_VALUE = "opencti_value"
15
+ THREAT_INTEL_VALUE = "threat_intel_value"
16
+
17
+
18
+class CreateAlertRequest(BaseModel):
19
+ index: str = Field(
20
+ ...,
21
+ alias="_index",
22
+ ) # Needing to alias these fields because they are reserved words in Python
23
+ id: str = Field(
24
+ ...,
25
+ alias="_id",
26
+ ) # Needing to alias these fields because they are reserved words in Python
27
+ agent_name: str = Field(..., description="The name of the agent.")
28
+ agent_ip: str = Field(
29
+ ...,
30
+ description="IP address of the agent that triggered the alert",
31
+ example="1.1.1.1",
32
+ )
33
+ agent_id: str = Field(..., description="The id of the agent.")
34
+ agent_labels_customer: str = Field(..., description="The customer of the agent.")
35
+ rule_id: str = Field(..., description="The id of the rule.")
36
+ rule_level: int = Field(..., description="The level of the rule.")
37
+ rule_description: str = Field(..., description="The description of the rule.")
38
+ timestamp: str = Field(..., description="The timestamp of the alert.")
39
+ timestamp_utc: Optional[str] = Field(
40
+ ...,
41
+ description="The UTC timestamp of the alert.",
42
+ )
43
+ time_field: Optional[str] = Field(
44
+ "timestamp",
45
+ description="The timefield of the alert to be used when creating the IRIS alert.",
46
+ )
47
+ asset_type_id: Optional[int] = Field(
48
+ 9,
49
+ description="The asset type id of the alert which is needed for when we add the asset to IRIS.",
50
+ )
51
+ ioc_value: Optional[str] = Field(
52
+ None,
53
+ description="The IoC value of the alert which is needed for when we add the IoC to IRIS.",
54
+ )
55
+ ioc_type: Optional[str] = Field(
56
+ None,
57
+ description="The IoC type of the alert which is needed for when we add the IoC to IRIS.",
58
+ )
59
+
60
+ class Config:
61
+ allow_population_by_field_name = True
62
+ extra = Extra.allow
63
+
64
+ def to_dict(self):
65
+ return self.dict(exclude_none=True)
66
+
67
+
68
+class CreateAlertResponse(BaseModel):
69
+ success: bool
70
+ message: str
71
+ alert_id: int = Field(..., description="The alert id as created in IRIS.")
72
+ customer: str = Field(..., description="The customer name.")
73
+ alert_source_link: str = Field(
74
+ ...,
75
+ description="The link to the alert within Grafana.",
76
+ )
77
+
78
+
79
+class GenericSourceModel(BaseModel):
80
+ agent_name: str = Field(..., description="The name of the agent.")
81
+ agent_id: str = Field(..., description="The id of the agent.")
82
+ agent_labels_customer: str = Field(..., description="The customer of the agent.")
83
+ rule_id: str = Field(..., description="The id of the rule.")
84
+ rule_level: int = Field(..., description="The level of the rule.")
85
+ rule_description: str = Field(..., description="The description of the rule.")
86
+ timestamp: str = Field(..., description="The timestamp of the alert.")
87
+ timestamp_utc: Optional[str] = Field(
88
+ None,
89
+ description="The UTC timestamp of the alert.",
90
+ )
91
+
92
+ class Config:
93
+ extra = Extra.allow
94
+
95
+
96
+class GenericAlertModel(BaseModel):
97
+ _index: str
98
+ _id: str
99
+ _version: int
100
+ _source: GenericSourceModel
101
+ asset_type_id: Optional[int] = Field(
102
+ None,
103
+ description="The asset type id of the alert which is needed for when we add the asset to IRIS.",
104
+ )
105
+ ioc_value: Optional[str] = Field(
106
+ None,
107
+ description="The IoC value of the alert which is needed for when we add the IoC to IRIS.",
108
+ )
109
+ ioc_type: Optional[str] = Field(
110
+ None,
111
+ description="The IoC type of the alert which is needed for when we add the IoC to IRIS.",
112
+ )
113
+
114
+ class Config:
115
+ extra = Extra.allow
116
+
117
+
118
+# Sample data from `get_single_alert_details`
119
+sample_data = {
120
+ "_index": "some_index",
121
+ "_id": "some_id",
122
+ "_version": 1,
123
+ "_source": {
124
+ "agent_name": "some_agent_name",
125
+ "agent_id": "some_agent_id",
126
+ # ... other fields
127
+ },
128
+ # ... other fields
129
+}
130
+
131
+
132
+########### ! Create Alerts Schemas ! ###########
133
+class IrisAsset(BaseModel):
134
+ asset_name: Optional[str] = Field(
135
+ "Asset Not Found. Verify Wazuh Manager API is Running",
136
+ description="Name of the asset",
137
+ example="Server01",
138
+ )
139
+ asset_ip: Optional[str] = Field(
140
+ "Asset IP Not Found. Verify Wazuh Manager API is Running",
141
+ description="IP address of the asset",
142
+ example="192.168.1.1",
143
+ )
144
+ asset_description: Optional[str] = Field(
145
+ "Asset Not Found. Verify Wazuh Manager API is Running",
146
+ description="Description of the asset",
147
+ example="Windows Server",
148
+ )
149
+ asset_type_id: Optional[int] = Field(
150
+ 9,
151
+ description="Type ID of the asset",
152
+ example=1,
153
+ )
154
+
155
+
156
+class IrisIoc(BaseModel):
157
+ ioc_value: str = Field(
158
+ ...,
159
+ description="Value of the IoC",
160
+ example="www.google.com",
161
+ )
162
+ ioc_description: str = Field(
163
+ ...,
164
+ description="Description of the IoC",
165
+ example="Google",
166
+ )
167
+ ioc_tlp_id: int = Field(1, description="TLP ID of the IoC", example=1)
168
+ ioc_type_id: int = Field(20, description="Type ID of the IoC", example=20)
169
+
170
+
171
+class IrisAlertContext(BaseModel):
172
+ customer_iris_id: int = Field(
173
+ ...,
174
+ description="IRIS ID of the customer",
175
+ example=1,
176
+ )
177
+ customer_name: str = Field(
178
+ ...,
179
+ description="Name of the customer",
180
+ example="SOCFortress",
181
+ )
182
+ customer_cases_index: str = Field(
183
+ ...,
184
+ description="IRIS case index name in the Wazuh-Indexer",
185
+ example="dfir_iris_00001",
186
+ )
187
+ alert_id: str = Field(..., description="ID of the alert", example="123")
188
+ alert_name: str = Field(
189
+ ...,
190
+ description="Name of the alert",
191
+ example="Intrusion Detected",
192
+ )
193
+ alert_level: int = Field(..., description="Severity level of the alert", example=3)
194
+ rule_id: str = Field(
195
+ ...,
196
+ description="ID of the rule that triggered the alert",
197
+ example="2001",
198
+ )
199
+ asset_name: str = Field(
200
+ ...,
201
+ description="Name of the affected asset",
202
+ example="Server01",
203
+ )
204
+ asset_ip: str = Field(
205
+ ...,
206
+ description="IP address of the affected asset",
207
+ example="192.168.1.1",
208
+ )
209
+ asset_type: int = Field(..., description="Type ID of the affected asset", example=1)
210
+ process_id: Optional[str] = Field(
211
+ "No process ID found",
212
+ description="Process ID involved in the alert",
213
+ example="4567",
214
+ )
215
+ rule_mitre_id: Optional[str] = Field(
216
+ "n/a",
217
+ description="MITRE ATT&CK ID of the rule",
218
+ example="T1234",
219
+ )
220
+ rule_mitre_tactic: Optional[str] = Field(
221
+ "n/a",
222
+ description="MITRE ATT&CK Tactic",
223
+ example="Execution",
224
+ )
225
+ rule_mitre_technique: Optional[str] = Field(
226
+ "n/a",
227
+ description="MITRE ATT&CK Technique",
228
+ example="Scripting",
229
+ )
230
+
231
+
232
+class IrisAlertPayload(BaseModel):
233
+ alert_title: str = Field(
234
+ ...,
235
+ description="Title of the alert",
236
+ example="Intrusion Detected",
237
+ )
238
+ alert_description: str = Field(
239
+ ...,
240
+ description="Description of the alert",
241
+ example="Intrusion Detected by Firewall",
242
+ )
243
+ alert_source: str = Field(..., description="Source of the alert", example="Wazuh")
244
+ assets: List[IrisAsset] = Field(..., description="List of affected assets")
245
+ alert_source_link: str = Field(
246
+ ...,
247
+ description="Link to the alert within Grafana",
248
+ example="https://grafana.com",
249
+ )
250
+ alert_status_id: int = Field(..., description="Status ID of the alert", example=3)
251
+ alert_severity_id: int = Field(
252
+ ...,
253
+ description="Severity ID of the alert",
254
+ example=5,
255
+ )
256
+ alert_customer_id: int = Field(
257
+ ...,
258
+ description="Customer ID related to the alert",
259
+ example=1,
260
+ )
261
+ alert_source_content: Dict[str, Any] = Field(
262
+ ...,
263
+ description="Original content from the alert source",
264
+ )
265
+ alert_context: IrisAlertContext = Field(
266
+ ...,
267
+ description="Contextual information about the alert",
268
+ )
269
+ alert_iocs: Optional[List[IrisIoc]] = Field(
270
+ None,
271
+ description="List of IoCs related to the alert",
272
+ )
273
+ alert_source_event_time: str = Field(
274
+ ...,
275
+ description="Timestamp of the alert",
276
+ example="2021-01-01T00:00:00.000Z",
277
+ )
278
+
279
+ def to_dict(self):
280
+ return self.dict(exclude_none=True)
281
+
282
+
283
+########### ! Send to Shuffle Schema ! ###########
284
+class ShuffleAlertPayload(BaseModel):
285
+ alert_title: str = Field(
286
+ ...,
287
+ description="Title of the alert",
288
+ example="Intrusion Detected",
289
+ )
290
+ alert_source: str = Field(..., description="Source of the alert", example="Wazuh")
291
+ asset_name: Optional[str] = Field(None, description="Name of the affected asset")
292
+ alert_id: int = Field(..., description="Alert ID as created in IRIS")
293
+ customer: str = Field(..., description="Customer name")
294
+ alert_link: str = Field(..., description="Link to the alert in IRIS")
295
+
296
+ def to_dict(self):
297
+ return self.dict(exclude_none=True)
backend/app/integrations/alert_creation/general/services/alert.py
new
+223
@@ -0,0 +1,223 @@
1
+from typing import Optional
2
+from typing import Set
3
+
4
+from fastapi import HTTPException
5
+from loguru import logger
6
+from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+from app.agents.routes.agents import get_agent
9
+from app.agents.schema.agents import AgentsResponse
10
+from app.connectors.dfir_iris.utils.universal import fetch_and_validate_data
11
+from app.connectors.dfir_iris.utils.universal import initialize_client_and_alert
12
+from app.integrations.alert_creation.general.schema.alert import CreateAlertRequest
13
+from app.integrations.alert_creation.general.schema.alert import CreateAlertResponse
14
+from app.integrations.alert_creation.general.schema.alert import IrisAlertContext
15
+from app.integrations.alert_creation.general.schema.alert import IrisAlertPayload
16
+from app.integrations.alert_creation.general.schema.alert import IrisAsset
17
+from app.integrations.alert_creation.general.schema.alert import IrisIoc
18
+from app.integrations.alert_creation.general.schema.alert import ValidIocFields
19
+from app.integrations.alert_creation.utils.schema import ShufflePayload
20
+from app.integrations.alert_creation.utils.universal import get_asset_type_id
21
+from app.integrations.alert_creation.utils.universal import send_to_shuffle
22
+from app.integrations.alert_creation.utils.universal import validate_ioc_type
23
+from app.utils import get_customer_alert_settings
24
+
25
+
26
+def valid_ioc_fields() -> Set[str]:
27
+ """
28
+ Getter for the set of valid IoC fields.
29
+ Returns
30
+ -------
31
+ Set[str]
32
+ The set of valid IoC fields.
33
+ """
34
+ return {field.value for field in ValidIocFields}
35
+
36
+
37
+async def construct_alert_source_link(alert_details: CreateAlertRequest, session: AsyncSession) -> str:
38
+ """
39
+ Construct the alert source link for the alert details.
40
+ Parameters
41
+ ----------
42
+ alert_details: CreateAlertRequest
43
+ The alert details.
44
+ Returns
45
+ -------
46
+ str
47
+ The alert source link.
48
+ """
49
+ # Check if the alert has a process id and that it is not "No process ID found"
50
+ if hasattr(alert_details, "process_id") and alert_details.process_id != "No process ID found":
51
+ query_string = f"%22query%22:%22process_id:%5C%22{alert_details.process_id}%5C%22%20AND%20"
52
+ else:
53
+ query_string = f"%22query%22:%22_id:%5C%22{alert_details.id}%5C%22%20AND%20"
54
+
55
+ grafana_url = (await get_customer_alert_settings(customer_code=alert_details.agent_labels_customer, session=session)).grafana_url
56
+
57
+ return (
58
+ f"{grafana_url}/explore?left=%5B%22now-6h%22,%22now%22,%22WAZUH%22,%7B%22refId%22:%22A%22,"
59
+ f"{query_string}"
60
+ f"agent_name:%5C%22{alert_details.agent_name}%5C%22%22,"
61
+ "%22alias%22:%22%22,%22metrics%22:%5B%7B%22id%22:%221%22,%22type%22:%22logs%22,%22settings%22:%7B%22limit%22:%22500%22%7D%7D%5D,"
62
+ "%22bucketAggs%22:%5B%5D,%22timeField%22:%22timestamp%22%7D%5D"
63
+ )
64
+
65
+
66
+async def build_ioc_payload(alert_details: CreateAlertRequest) -> Optional[IrisIoc]:
67
+ for field in valid_ioc_fields():
68
+ if hasattr(alert_details, field):
69
+ ioc_value = getattr(alert_details, field)
70
+ ioc_type = await validate_ioc_type(ioc_value=ioc_value)
71
+ return IrisIoc(
72
+ ioc_value=ioc_value,
73
+ ioc_description="IoC found in alert",
74
+ ioc_tlp_id=1,
75
+ ioc_type_id=ioc_type,
76
+ )
77
+ return None
78
+
79
+
80
+async def build_asset_payload(agent_data: AgentsResponse, alert_details) -> IrisAsset:
81
+ if agent_data.success:
82
+ return IrisAsset(
83
+ asset_name=agent_data.agents[0].hostname,
84
+ asset_ip=agent_data.agents[0].ip_address,
85
+ asset_description=agent_data.agents[0].os,
86
+ asset_type_id=await get_asset_type_id(agent_data.agents[0].os),
87
+ )
88
+ return IrisAsset()
89
+
90
+
91
+async def build_alert_context_payload(
92
+ alert_details: CreateAlertRequest,
93
+ session: AsyncSession,
94
+) -> IrisAlertContext:
95
+ return IrisAlertContext(
96
+ customer_iris_id=(
97
+ await get_customer_alert_settings(customer_code=alert_details.agent_labels_customer, session=session)
98
+ ).iris_customer_id,
99
+ customer_name=(await get_customer_alert_settings(customer_code=alert_details.agent_labels_customer, session=session)).customer_name,
100
+ customer_cases_index=(
101
+ await get_customer_alert_settings(customer_code=alert_details.agent_labels_customer, session=session)
102
+ ).iris_index,
103
+ alert_id=alert_details.id,
104
+ alert_name=alert_details.rule_description,
105
+ alert_level=alert_details.rule_level,
106
+ rule_id=alert_details.rule_id,
107
+ asset_name=alert_details.agent_name,
108
+ asset_ip=alert_details.agent_ip,
109
+ asset_type=alert_details.asset_type_id,
110
+ process_id=getattr(alert_details, "process_id", "No process id found"),
111
+ rule_mitre_id=getattr(alert_details, "rule_mitre_id", "No rule mitre id found"),
112
+ rule_mitre_tactic=getattr(
113
+ alert_details,
114
+ "rule_mitre_tactic",
115
+ "No rule mitre tactic found",
116
+ ),
117
+ rule_mitre_technique=getattr(
118
+ alert_details,
119
+ "rule_mitre_technique",
120
+ "No rule mitre technique found",
121
+ ),
122
+ )
123
+
124
+
125
+async def build_alert_payload(
126
+ alert_details: CreateAlertRequest,
127
+ agent_data,
128
+ ioc_payload: Optional[IrisIoc],
129
+ session: AsyncSession,
130
+) -> IrisAlertPayload:
131
+ asset_payload = await build_asset_payload(agent_data, alert_details)
132
+ context_payload = await build_alert_context_payload(alert_details, session=session)
133
+ timefield = (await get_customer_alert_settings(customer_code=alert_details.agent_labels_customer, session=session)).timefield
134
+ # Get the timefield value from the alert_details
135
+ if hasattr(alert_details, timefield):
136
+ alert_details.time_field = getattr(alert_details, timefield)
137
+ logger.info(f"Alert has context: {context_payload}")
138
+ if ioc_payload:
139
+ logger.info(f"Alert has IoC: {ioc_payload}")
140
+ return IrisAlertPayload(
141
+ alert_title=alert_details.rule_description,
142
+ alert_source_link=await construct_alert_source_link(alert_details, session=session),
143
+ alert_description=alert_details.rule_description,
144
+ alert_source="SOCFORTRESS RULE",
145
+ assets=[asset_payload],
146
+ alert_status_id=3,
147
+ alert_severity_id=5,
148
+ alert_customer_id=(
149
+ await get_customer_alert_settings(customer_code=alert_details.agent_labels_customer, session=session)
150
+ ).iris_customer_id,
151
+ alert_source_content=alert_details.to_dict(),
152
+ alert_context=context_payload,
153
+ alert_iocs=[ioc_payload],
154
+ alert_source_event_time=alert_details.time_field,
155
+ )
156
+ else:
157
+ logger.info("Alert does not have IoC")
158
+ return IrisAlertPayload(
159
+ alert_title=alert_details.rule_description,
160
+ alert_source_link=construct_alert_source_link(alert_details),
161
+ alert_description=alert_details.rule_description,
162
+ alert_source="SOCFORTRESS RULE",
163
+ assets=[asset_payload],
164
+ alert_status_id=3,
165
+ alert_severity_id=5,
166
+ alert_customer_id=(
167
+ await get_customer_alert_settings(customer_code=alert_details.agent_labels_customer, session=session)
168
+ ).iris_customer_id,
169
+ alert_source_content=alert_details.to_dict(),
170
+ alert_context=context_payload,
171
+ alert_source_event_time=alert_details.time_field,
172
+ )
173
+
174
+
175
+async def create_alert(alert: CreateAlertRequest, session: AsyncSession) -> CreateAlertResponse:
176
+ logger.info(f"Creating alert {alert.id} in IRIS.")
177
+ # ! TODO: ALERT MULTI EXCLUSION ! #
178
+ # event_exclude_result = AlertDetailsService().collect_alert_timeline_process_id(
179
+ # agent_name=alert.agent_name,
180
+ # process_id=getattr(alert, "process_id", "n/a"),
181
+ # index=alert.index,
182
+ # )
183
+ # logger.info(f"Event exclude result: {event_exclude_result}")
184
+ # if event_exclude_result is not None:
185
+ # if event_exclude_result["excluded"]:
186
+ # raise HTTPException(
187
+ # status_code=400,
188
+ # detail="Alert excluded due to multi exclusion as set in the config.ini file.",
189
+ # )
190
+ logger.info(f"Getting agent data for {alert.agent_name}")
191
+ agent_details = await get_agent(agent_id=alert.agent_id, db=session)
192
+ ioc_payload = await build_ioc_payload(alert_details=alert)
193
+ iris_alert_payload = await build_alert_payload(
194
+ alert_details=alert,
195
+ agent_data=agent_details,
196
+ ioc_payload=ioc_payload,
197
+ session=session,
198
+ )
199
+ client, alert_client = await initialize_client_and_alert("DFIR-IRIS")
200
+ result = await fetch_and_validate_data(
201
+ client,
202
+ alert_client.add_alert,
203
+ iris_alert_payload.to_dict(),
204
+ )
205
+ alert_id = result["data"]["alert_id"]
206
+ logger.info(f"Successfully created alert {alert_id} in IRIS.")
207
+ send_to_shuffle(
208
+ ShufflePayload(
209
+ alert_id=alert_id,
210
+ customer=(await get_customer_alert_settings(customer_code=alert.agent_labels_customer, session=session)).customer_name,
211
+ customer_code=alert.agent_labels_customer,
212
+ alert_source_link=await construct_alert_source_link(alert, session=session),
213
+ rule_description=alert.rule_description,
214
+ hostname=alert.agent_name,
215
+ ),
216
+ )
217
+ return CreateAlertResponse(
218
+ alert_id=alert_id,
219
+ customer=(await get_customer_alert_settings(customer_code=alert.agent_labels_customer, session=session)).customer_name,
220
+ alert_source_link=await construct_alert_source_link(alert, session=session),
221
+ success=True,
222
+ message=f"Successfully created alert {alert_id} in IRIS.",
223
+ )
backend/app/integrations/alert_creation/general/services/alert_multi_exclude.py
new
+238
@@ -0,0 +1,238 @@
1
+from typing import Any
2
+from typing import Dict
3
+from typing import List
4
+from typing import Tuple
5
+
6
+from elasticsearch7 import NotFoundError
7
+from loguru import logger
8
+
9
+from app.connectors.wazuh_indexer.utils.universal import create_wazuh_indexer_client
10
+
11
+
12
+class AlertDetailsService:
13
+ """
14
+ Service for handling alert lookup tasks in the Wazuh Indexer.
15
+
16
+ Attributes:
17
+ es: Elasticsearch object for accessing the Wazuh Indexer.
18
+ config_manager: ConfigManager object for accessing the configuration file.
19
+ """
20
+
21
+ def __init__(self):
22
+ """
23
+ Initialize the AlertDetailsService class.
24
+
25
+ Establish a session with the Wazuh indexer and a ConfigManager instance for reading the configuration file.
26
+ """
27
+ self.es = create_wazuh_indexer_client("Wazuh-Indexer")
28
+
29
+ def _collect_indices(self) -> Dict[str, object]:
30
+ """
31
+ Collect the indices from the Elasticsearch cluster.
32
+
33
+ Returns:
34
+ dict: A dictionary containing the indices and their properties.
35
+ """
36
+ try:
37
+ logger.info("Collecting indices but only return the index name.")
38
+ indices = self.es.cat.indices(format="json")
39
+ return [index["index"] for index in indices]
40
+ except Exception as e:
41
+ logger.error(f"Error collecting indices: {e}")
42
+ return {}
43
+
44
+ def search_alerts_with_syslog_level(self) -> List[Tuple[str, str]]:
45
+ """
46
+ Search all indexes and build a list of (index, id) pairs where the syslog_level field has a value of 'ALERT'.
47
+
48
+ Returns:
49
+ List[Tuple[str, str]]: A list of (index, id) pairs where the syslog_level field has a value of 'ALERT' within the last 1 hour.
50
+ """
51
+ try:
52
+ logger.info("Searching for alerts with syslog_level of 'ALERT'.")
53
+
54
+ # Build the query to search for syslog_level of 'ALERT' within the last 1 hour
55
+ query = self.build_query(terms={"syslog_level": "ALERT"}, hours=1)
56
+
57
+ # Search across all indexes
58
+ result = self.es.search(index="_all", body=query)
59
+
60
+ # Extract (index, id) pairs from the result
61
+ index_id_pairs = [(hit["_index"], hit["_id"]) for hit in result["hits"]["hits"]]
62
+ logger.info(
63
+ f"Found {len(index_id_pairs)} alerts with syslog_level of 'ALERT' within the last 1 hour.",
64
+ )
65
+ return index_id_pairs
66
+ except Exception as e:
67
+ logger.error(
68
+ f"Error searching for alerts with syslog_level of 'ALERT': {e}",
69
+ )
70
+ return []
71
+
72
+ # collect the alert details via the index and id
73
+ def alert_details(self, index: str, id: str) -> Dict[str, Any]:
74
+ """
75
+ Collect the alert details via the index and id.
76
+
77
+ Args:
78
+ index (str): The name of the Elasticsearch index to retrieve data from.
79
+ id (str): The ID of the alert in the Elasticsearch index.
80
+
81
+ Returns:
82
+ dict: The Elasticsearch document matching the index and ID, or None if an error occurred.
83
+ """
84
+ try:
85
+ logger.info("Collecting alert details.")
86
+ return self.es.get(index=index, id=id)
87
+ except Exception as e:
88
+ logger.error(f"Error collecting alert details: {e}")
89
+ return None
90
+
91
+ def alert_details_wildcard(self, index: str, id: str) -> Dict[str, Any]:
92
+ """
93
+ Collect the alert details via a wildcard index search and provided id. I.E `mimecast_test*`.
94
+
95
+ Args:
96
+ index (str): The name of the Elasticsearch index to retrieve data from.
97
+ id (str): The ID of the alert in the Elasticsearch index.
98
+
99
+ Returns:
100
+ dict: The Elasticsearch document matching the index and ID, or None if an error occurred.
101
+ """
102
+ # Collect the indices
103
+ indices = self._collect_indices()
104
+
105
+ # Loop through the indices that match the beginning of the index
106
+ try:
107
+ for index_name in indices:
108
+ if index_name.startswith(index):
109
+ try:
110
+ logger.info(
111
+ f"Collecting alert details from index: {index_name}",
112
+ )
113
+ # If a document is found, return it, otherwise continue to the next index
114
+ return self.es.get(index=index_name, id=id)
115
+ except NotFoundError:
116
+ continue
117
+ return None
118
+ except Exception as e:
119
+ logger.error(f"Error collecting alert details: {e}")
120
+ return None
121
+
122
+ def build_query(self, terms: Dict[str, str], hours: int = 1):
123
+ """
124
+ Build the query for alert timeline events.
125
+
126
+ Args:
127
+ terms (dict): A dictionary of field-value pairs to search for.
128
+ hours (int): The time range in hours for the search.
129
+
130
+ Returns:
131
+ dict: An Elasticsearch query that searches for documents matching the terms within the specified time range.
132
+ """
133
+ must_terms = [{"term": {field: value}} for field, value in terms.items()]
134
+ must_terms.append(
135
+ {
136
+ "range": {
137
+ "timestamp": {
138
+ "gte": f"now-{hours}h",
139
+ },
140
+ },
141
+ },
142
+ )
143
+
144
+ return {
145
+ "size": 10000,
146
+ "query": {"bool": {"must": must_terms}},
147
+ }
148
+
149
+ def process_events(self, events: list, order_key: str):
150
+ """
151
+ Process the events and check for exclusions.
152
+ For every event in the `config.ini` file there is a field and value to check for.
153
+ If the first event is found, the second event is checked for.
154
+ If the second event is found, the alert is excluded.
155
+
156
+ Args:
157
+ events (list): A list of events to process.
158
+ order_key (str): The key in the configuration file that specifies the order of event processing.
159
+
160
+ Returns:
161
+ dict: A dictionary with a single key 'excluded' indicating whether the events match the exclusion criteria.
162
+ """
163
+ event_order = self.config_manager.get("Order", order_key).split(",")
164
+ # strip the event order of whitespace
165
+ event_order = [event.strip() for event in event_order]
166
+
167
+ first_match_found = False
168
+
169
+ for index, event in enumerate(events):
170
+ event_id = event_order[0 if not first_match_found else 1]
171
+ logger.info(f"Checking for event_id: {event_id}")
172
+ event_config = self.config_manager.get_section(event_id)
173
+ field = event_config["field"]
174
+ value = event_config["value"]
175
+ logger.info(f"Checking for {field} containing {value}")
176
+
177
+ if value == event.get(field, ""):
178
+ logger.info(f"Event with {field} containing {value} found.")
179
+ if not first_match_found:
180
+ first_match_found = True # We found the first match
181
+ elif first_match_found:
182
+ logger.info("Both matches found.")
183
+ return {"excluded": True} # Both matches found, so return early
184
+
185
+ # If we've checked all events and didn't find both matches, return {"excluded": False}
186
+ return {"excluded": False}
187
+
188
+ def collect_alert_timeline_process_id(
189
+ self,
190
+ agent_name: str,
191
+ process_id: str,
192
+ index: str,
193
+ ) -> Dict[str, Any]:
194
+ """
195
+ Collect the events where the process id and agent name match within a 24 hour window.
196
+ This function is used to exclude an event where correlating events are found within a 24 hour window.
197
+
198
+ Args:
199
+ agent_name (str): The name of the agent.
200
+ process_id (str): The ID of the process.
201
+ index (str): The name of the Elasticsearch index to retrieve data from.
202
+
203
+ Returns:
204
+ dict: A dictionary containing the results of the event processing for each order key, or None if an error occurred.
205
+ """
206
+ try:
207
+ logger.info(
208
+ f"Collecting alert timeline events for Agent name: {agent_name}, Process id: {process_id}, Index: {index}",
209
+ )
210
+
211
+ query = self.build_query(
212
+ {"agent_name": agent_name, "process_id": process_id},
213
+ )
214
+ alert_timeline_events = self.es.search(index=index, body=query)
215
+
216
+ total_hits = alert_timeline_events["hits"]["total"]["value"]
217
+ logger.info(f"Total alert timeline hits: {total_hits}")
218
+
219
+ # Build and sort the list of events
220
+ events = [event["_source"] for event in alert_timeline_events["hits"]["hits"]]
221
+ events.sort(key=lambda x: x["timestamp_utc"])
222
+
223
+ # return self.process_events(events)
224
+
225
+ # Get all order keys from the 'Order' section in config.ini
226
+ order_keys = self.config_manager.options("Order")
227
+
228
+ # Process events for each order key
229
+ results = {}
230
+ for order_key in order_keys:
231
+ results[order_key] = self.process_events(events, order_key)
232
+ if results[order_key]["excluded"] is True:
233
+ return {"excluded": True}
234
+
235
+ return {"excluded": False}
236
+ except Exception as e:
237
+ logger.error(f"Error collecting alert timeline events: {e}")
238
+ return None
backend/app/integrations/alert_creation/models/alert_settings.py
new
+37
@@ -0,0 +1,37 @@
1
+from typing import List
2
+from typing import Optional
3
+
4
+from sqlmodel import Field
5
+from sqlmodel import Relationship
6
+from sqlmodel import SQLModel
7
+
8
+
9
+class AlertCreationEventConfig(SQLModel, table=True):
10
+ id: Optional[int] = Field(default=None, primary_key=True)
11
+ alert_creation_settings_id: Optional[int] = Field(default=None, foreign_key="alertcreationsettings.id")
12
+ event_id: str = Field(max_length=255)
13
+ field: str = Field(max_length=1024)
14
+ value: str = Field(max_length=1024)
15
+ alert_creation_settings: "AlertCreationSettings" = Relationship(back_populates="event_configs")
16
+
17
+
18
+class AlertCreationSettings(SQLModel, table=True):
19
+ id: Optional[int] = Field(primary_key=True)
20
+ customer_code: str = Field(max_length=11, nullable=False)
21
+ customer_name: str = Field(max_length=50, nullable=False)
22
+ excluded_wazuh_rules: Optional[str] = Field(max_length=1024)
23
+ excluded_suricata_rules: Optional[str] = Field(max_length=1024)
24
+ timefield: Optional[str] = Field(max_length=1024)
25
+ office365_organization_id: Optional[str] = Field(max_length=1024)
26
+ iris_customer_id: Optional[int] = Field()
27
+ iris_customer_name: Optional[str] = Field(max_length=1024)
28
+ iris_index: Optional[str] = Field(max_length=1024)
29
+ grafana_url: Optional[str] = Field(max_length=1024)
30
+ misp_url: Optional[str] = Field(max_length=1024)
31
+ opencti_url: Optional[str] = Field(max_length=1024)
32
+ custom_message: Optional[str] = Field(max_length=1024)
33
+ shuffle_endpoint: Optional[str] = Field(max_length=1024)
34
+ nvd_url: Optional[str] = Field(default="https://services.nvd.nist.gov/rest/json/cves/2.0?cveId", max_length=1024)
35
+ event_order: Optional[str] = Field(max_length=1024)
36
+ event_order2: Optional[str] = Field(max_length=1024)
37
+ event_configs: List[AlertCreationEventConfig] = Relationship(back_populates="alert_creation_settings")
backend/app/integrations/alert_creation/utils/schema.py
new
+197
@@ -0,0 +1,197 @@
1
+from typing import List
2
+from typing import Optional
3
+
4
+from pydantic import BaseModel
5
+from pydantic import Extra
6
+from pydantic import Field
7
+
8
+
9
+class WazuhOSInfo(BaseModel):
10
+ arch: Optional[str] = Field(
11
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
12
+ description="The architecture of the Wazuh Agent.",
13
+ )
14
+ codename: Optional[str] = Field(
15
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
16
+ description="The codename of the Wazuh Agent.",
17
+ )
18
+ major: Optional[str] = Field(
19
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
20
+ description="The major version of the Wazuh Agent.",
21
+ )
22
+ minor: Optional[str] = Field(
23
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
24
+ description="The minor version of the Wazuh Agent.",
25
+ )
26
+ name: Optional[str] = Field(
27
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
28
+ description="The name of the Wazuh Agent.",
29
+ )
30
+ platform: Optional[str] = Field(
31
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
32
+ description="The platform of the Wazuh Agent.",
33
+ )
34
+ uname: Optional[str] = Field(
35
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
36
+ description="The uname of the Wazuh Agent.",
37
+ )
38
+ version: Optional[str] = Field(
39
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
40
+ description="The version of the Wazuh Agent.",
41
+ )
42
+
43
+
44
+class WazuhAgent(BaseModel):
45
+ os: Optional[WazuhOSInfo] = Field(
46
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
47
+ description="The OS info of the Wazuh Agent.",
48
+ )
49
+ lastKeepAlive: Optional[str] = Field(
50
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
51
+ description="The last keep alive of the Wazuh Agent.",
52
+ )
53
+ id: str
54
+ dateAdd: str
55
+ configSum: Optional[str] = Field(
56
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
57
+ description="The config sum of the Wazuh Agent.",
58
+ )
59
+ manager: Optional[str] = Field(
60
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
61
+ description="The manager of the Wazuh Agent.",
62
+ )
63
+ group: Optional[List[str]] = Field(
64
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
65
+ description="The group of the Wazuh Agent.",
66
+ )
67
+ registerIP: str
68
+ ip: str
69
+ name: str
70
+ status: str
71
+ mergedSum: Optional[str] = Field(
72
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
73
+ description="The merged sum of the Wazuh Agent.",
74
+ )
75
+ version: Optional[str] = Field(
76
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
77
+ description="The version of the Wazuh Agent.",
78
+ )
79
+ node_name: str
80
+ group_config_status: str
81
+
82
+
83
+class WazuhAffectedItems(BaseModel):
84
+ affected_items: List[WazuhAgent]
85
+ total_affected_items: int
86
+ total_failed_items: int
87
+ failed_items: List
88
+
89
+
90
+class WazuhResponseData(BaseModel):
91
+ data: WazuhAffectedItems
92
+
93
+
94
+class WazuhAgentResponse(BaseModel):
95
+ data: Optional[WazuhResponseData] = Field(
96
+ None,
97
+ description="The Wazuh API response data.",
98
+ )
99
+ message: Optional[str] = Field(
100
+ "Not Available - Make sure the Wazuh Agent is connected to the Manager.",
101
+ description="The Wazuh API response message.",
102
+ )
103
+ success: Optional[bool] = Field(
104
+ False,
105
+ description="The Wazuh API response success.",
106
+ )
107
+
108
+
109
+class WazuhSocketPayload(BaseModel):
110
+ integration: str = Field(
111
+ ...,
112
+ description="The integration name.",
113
+ examples="sublime",
114
+ )
115
+
116
+ class Config:
117
+ extra = Extra.allow
118
+
119
+ def to_dict(self):
120
+ return self.dict(exclude_none=True)
121
+
122
+
123
+############################### ! Sublime ! ###############################
124
+class WazuhSublimeSocketPayload(WazuhSocketPayload):
125
+ sender: str = Field(
126
+ ...,
127
+ description="The sender's email address.",
128
+ examples="info@socfortress.co",
129
+ )
130
+ display_name: str = Field(
131
+ ...,
132
+ description="The sender's display name.",
133
+ examples="SOCFortress",
134
+ )
135
+ subject: str = Field(
136
+ ...,
137
+ description="The subject of the email.",
138
+ examples="Test Email",
139
+ )
140
+ canonical_id: str = Field(
141
+ ...,
142
+ description="The canonical ID of the email.",
143
+ examples="123456789",
144
+ )
145
+ rule_names: str = Field(
146
+ ...,
147
+ description="The rule names that were triggered.",
148
+ examples="test rule, test rule 2",
149
+ )
150
+ recipients: str = Field(
151
+ ...,
152
+ description="The recipients of the email.",
153
+ examples="info@socfortress.co",
154
+ )
155
+
156
+ def to_dict(self):
157
+ # If `display_name` is an empty string, set it to `None`.
158
+ if self.display_name == "":
159
+ self.display_name = None
160
+ return self.dict(exclude_none=True)
161
+
162
+
163
+######### ! SEND TO SHUFFLE PAYLOAD ! #########
164
+class ShufflePayload(BaseModel):
165
+ alert_id: str = Field(
166
+ ...,
167
+ description="The alert ID.",
168
+ examples="123456789",
169
+ )
170
+ customer: str = Field(
171
+ ...,
172
+ description="The customer name.",
173
+ examples="SOCFortress",
174
+ )
175
+ customer_code: str = Field(
176
+ ...,
177
+ description="The customer code.",
178
+ examples="socfortress",
179
+ )
180
+ alert_source_link: str = Field(
181
+ ...,
182
+ description="The alert source link.",
183
+ examples="https://app.socfortress.co/alerts/123456789",
184
+ )
185
+ rule_description: str = Field(
186
+ ...,
187
+ description="The rule description.",
188
+ examples="Test rule",
189
+ )
190
+ hostname: str = Field(
191
+ ...,
192
+ description="The hostname of the affected asset.",
193
+ examples="test-hostname",
194
+ )
195
+
196
+ def to_dict(self):
197
+ return self.dict(exclude_none=True)
backend/app/integrations/alert_creation/utils/universal.py
new
+410
@@ -0,0 +1,410 @@
1
+import ipaddress
2
+import json
3
+import re
4
+from abc import ABC
5
+from typing import Any
6
+from typing import Callable
7
+from typing import Dict
8
+from typing import Optional
9
+from typing import Tuple
10
+from typing import Union
11
+
12
+import httpx
13
+import regex
14
+import requests
15
+from fastapi import HTTPException
16
+from loguru import logger
17
+
18
+from app.integrations.alert_creation.utils.schema import ShufflePayload
19
+from app.integrations.alert_creation.utils.schema import WazuhAgentResponse
20
+from app.utils import get_customer_alert_settings
21
+
22
+
23
+#################### ! DFIR IRIS ASSET VALIDATOR ! ####################
24
+class AssetValidator(ABC):
25
+ """
26
+ Base class for asset validators.
27
+
28
+ Attributes:
29
+ os (str): The OS to be validated.
30
+ """
31
+
32
+ ASSET_TYPE_ID: int = 1
33
+
34
+ def __init__(self, os: str) -> None:
35
+ """
36
+ Initialize a Validator.
37
+
38
+ Args:
39
+ os (str): The OS to be validated.
40
+ """
41
+ self.os = os.lower()
42
+
43
+ def validate(self) -> Dict[str, Union[bool, str, int]]:
44
+ """
45
+ Validate the OS.
46
+
47
+ If the OS matches the type of this validator,
48
+ the method returns a dictionary indicating success, the matching message, and the asset type id.
49
+
50
+ Returns:
51
+ Dict[str, Union[bool, str, int]]: The validation result.
52
+ """
53
+ raise NotImplementedError
54
+
55
+
56
+class WindowsAssetValidator(AssetValidator):
57
+ """
58
+ Class to check if an OS is Windows.
59
+ """
60
+
61
+ ASSET_TYPE_ID = 9
62
+
63
+ def validate(self) -> Dict[str, Union[bool, str, int]]:
64
+ if "windows" in self.os:
65
+ return {
66
+ "success": True,
67
+ "message": f"{self.os} is a valid Windows OS.",
68
+ "asset_type_id": self.ASSET_TYPE_ID,
69
+ }
70
+ else:
71
+ return {
72
+ "success": False,
73
+ "message": f"{self.os} is not a Windows OS.",
74
+ "asset_type_id": self.ASSET_TYPE_ID,
75
+ }
76
+
77
+
78
+class LinuxAssetValidator(AssetValidator):
79
+ """
80
+ Class to check if an OS is Linux.
81
+ """
82
+
83
+ ASSET_TYPE_ID = 4
84
+
85
+ def validate(self) -> Dict[str, Union[bool, str, int]]:
86
+ if "linux" in self.os:
87
+ return {
88
+ "success": True,
89
+ "message": f"{self.os} is a valid Linux OS.",
90
+ "asset_type_id": self.ASSET_TYPE_ID,
91
+ }
92
+ else:
93
+ return {
94
+ "success": False,
95
+ "message": f"{self.os} is not a Linux OS.",
96
+ "asset_type_id": self.ASSET_TYPE_ID,
97
+ }
98
+
99
+
100
+class FirewallAssetValidator(AssetValidator):
101
+ """
102
+ Class to check if an OS is Firewall.
103
+ """
104
+
105
+ ASSET_TYPE_ID = 2
106
+
107
+ def validate(self) -> Dict[str, Union[bool, str, int]]:
108
+ if "firewall" in self.os:
109
+ return {
110
+ "success": True,
111
+ "message": f"{self.os} is a valid Firewall OS.",
112
+ "asset_type_id": self.ASSET_TYPE_ID,
113
+ }
114
+ else:
115
+ return {
116
+ "success": False,
117
+ "message": f"{self.os} is not a Firewall OS.",
118
+ "asset_type_id": self.ASSET_TYPE_ID,
119
+ }
120
+
121
+
122
+class UbuntuAssetValidator(AssetValidator):
123
+ """
124
+ Class to check if an OS is Ubuntu.
125
+ """
126
+
127
+ ASSET_TYPE_ID = 4
128
+
129
+ def validate(self) -> Dict[str, Union[bool, str, int]]:
130
+ if "ubuntu" in self.os:
131
+ return {
132
+ "success": True,
133
+ "message": f"{self.os} is a valid Ubuntu OS.",
134
+ "asset_type_id": self.ASSET_TYPE_ID,
135
+ }
136
+ else:
137
+ return {
138
+ "success": False,
139
+ "message": f"{self.os} is not an Ubuntu OS.",
140
+ "asset_type_id": self.ASSET_TYPE_ID,
141
+ }
142
+
143
+
144
+class AssetTypeResolver:
145
+ """
146
+ Class to iterate over asset validators and return the successful validator's asset type id.
147
+ """
148
+
149
+ def __init__(self, os: str):
150
+ """
151
+ Initialize AssetTypeResolver.
152
+
153
+ Args:
154
+ os (str): The OS to be validated.
155
+ """
156
+ self.os = os
157
+ self.validators = [
158
+ WindowsAssetValidator,
159
+ LinuxAssetValidator,
160
+ FirewallAssetValidator,
161
+ UbuntuAssetValidator,
162
+ ]
163
+
164
+ def get_asset_type_id(self) -> int:
165
+ """
166
+ Iterate over validators and return the successful validator's asset type id.
167
+
168
+ Returns:
169
+ int: The asset type id.
170
+ """
171
+ for Validator in self.validators:
172
+ validator = Validator(self.os)
173
+ result = validator.validate()
174
+ if result["success"] is True:
175
+ return result["asset_type_id"]
176
+
177
+ # Return default asset type id (1) if no validators succeed
178
+ return 1
179
+
180
+
181
+#################### ! DFIR IRIS ASSET VALIDATOR END ! ####################
182
+
183
+
184
+#################### ! DFIR IRIS IOC VALIDATOR ! ##########################
185
+
186
+
187
+class IoCValidator(ABC):
188
+ """
189
+ Base class for validators.
190
+
191
+ Attributes:
192
+ value (str): The value to be validated.
193
+ """
194
+
195
+ PATTERN: Optional[str] = None # type: ignore
196
+ IOC_TYPE: Optional[int] = None # type: ignore
197
+
198
+ def __init__(self, value: str) -> None:
199
+ """
200
+ Initialize a Validator.
201
+
202
+ Args:
203
+ value (str): The value to be validated.
204
+ """
205
+ self.value = value
206
+
207
+ def validate(self) -> Dict[str, Union[bool, str, int]]:
208
+ """
209
+ Validate the value.
210
+
211
+ If the value matches the pattern,
212
+ the method returns a dictionary indicating success, the matching message, and the IOC type.
213
+
214
+ Returns:
215
+ Dict[str, Union[bool, str, int]]: The validation result.
216
+ """
217
+ logger.info(f"Validating {self.value} against {self.PATTERN}.")
218
+ if self.PATTERN and regex.match(self.PATTERN, self.value, re.IGNORECASE):
219
+ return {
220
+ "success": True,
221
+ "message": f"{self.value} matches the pattern.",
222
+ "ioc_type": self.IOC_TYPE,
223
+ }
224
+ else:
225
+ return {
226
+ "success": False,
227
+ "message": f"{self.value} does not match the pattern.",
228
+ "ioc_type": self.IOC_TYPE,
229
+ }
230
+
231
+
232
+class IPv4AddressValidator(IoCValidator):
233
+ """
234
+ Class to check if a string is a valid IPv4 address.
235
+ """
236
+
237
+ IOC_TYPE = 76
238
+
239
+ def validate(self) -> Dict[str, Union[bool, str, int]]:
240
+ """
241
+ Validate if the given value is a valid IPv4 address.
242
+
243
+ Returns:
244
+ dict: A dictionary containing success status, message, and the associated IoC type.
245
+ """
246
+ try:
247
+ # if the value is like this `162.159.133.233|443` strip the port
248
+ if "|" in self.value:
249
+ self.value = self.value.split("|")[0]
250
+ logger.info(f"Validating {self.value} as an IPv4 address.")
251
+ ipaddress.IPv4Address(self.value)
252
+ return {
253
+ "success": True,
254
+ "message": f"{self.value} is a valid IPv4 address.",
255
+ "ioc_type": self.IOC_TYPE,
256
+ }
257
+ except ValueError:
258
+ return {
259
+ "success": False,
260
+ "message": f"{self.value} is not a valid IPv4 address.",
261
+ "ioc_type": self.IOC_TYPE,
262
+ }
263
+
264
+
265
+class HashValidator(IoCValidator):
266
+ """
267
+ Class to check if a string is a valid SHA256 hash.
268
+ """
269
+
270
+ PATTERN = r"^[a-fA-F\d]{64}$"
271
+ IOC_TYPE = 113
272
+
273
+
274
+class DomainValidator(IoCValidator):
275
+ """
276
+ Class to check if a string is a valid domain name.
277
+ """
278
+
279
+ PATTERN = r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$"
280
+ IOC_TYPE = 20
281
+
282
+
283
+#################### ! DFIR IRIS IOC VALIDATOR END ! ##########################
284
+
285
+
286
+async def get_asset_type_id(os: str) -> int:
287
+ """
288
+ Use AssetTypeResolver to determine the asset type ID to set within DFIR-IRIS.
289
+
290
+ Parameters
291
+ ----------
292
+ os : str
293
+ The operating system (OS) string used to resolve the asset type ID.
294
+
295
+ Returns
296
+ -------
297
+ int
298
+ The ID corresponding to the asset type.
299
+ """
300
+ asset_resolver = AssetTypeResolver(os)
301
+ return asset_resolver.get_asset_type_id()
302
+
303
+
304
+async def validate_ioc_type(ioc_value: str) -> str:
305
+ """
306
+ Validate IoC type using validators.
307
+
308
+ Parameters
309
+ ----------
310
+ ioc_value : str
311
+ The value to validate the IoC type.
312
+
313
+ Returns
314
+ -------
315
+ str
316
+ The type of the IoC. Returns None if validation fails.
317
+ """
318
+ validators = [IPv4AddressValidator, HashValidator, DomainValidator]
319
+ ioc_type = None
320
+
321
+ for Validator in validators:
322
+ validator = Validator(ioc_value)
323
+ result = validator.validate()
324
+
325
+ if result["success"]:
326
+ ioc_type = result["ioc_type"]
327
+ break
328
+
329
+ if ioc_type is None:
330
+ logger.error("Failed to validate IoC value.")
331
+ return ioc_type
332
+
333
+
334
+############## ! SEND TO OTHER TOOLS ! ##############
335
+async def send_to_shuffle(payload: ShufflePayload) -> bool:
336
+ """
337
+ Sends payload to Shuffle listening Webhook asynchronously using httpx.
338
+ """
339
+ logger.info(f"Sending {payload} to Shuffle Webhook.")
340
+ try:
341
+ shuffle_endpoint = (await get_customer_alert_settings(customer_code=payload.customer_code)).shuffle_endpoint
342
+ async with httpx.AsyncClient() as client:
343
+ response = await client.post(
344
+ shuffle_endpoint,
345
+ json=payload.to_dict(),
346
+ verify=False, # Be cautious with verify=False in production
347
+ )
348
+
349
+ return response.status_code == 200
350
+
351
+ except Exception as e:
352
+ logger.error(f"Error: {e}")
353
+ raise HTTPException(
354
+ status_code=500,
355
+ detail=f"Error: {e}",
356
+ )
357
+
358
+
359
+# def send_to_wazuh(msg) -> None:
360
+# # Uncomment when doing dev work
361
+# # logger.info(f"Sending {msg} to Wazuh Socket.")
362
+# # return
363
+# socketAddr = "/var/ossec/queue/sockets/queue"
364
+# from socket import AF_UNIX
365
+# from socket import SOCK_DGRAM
366
+# from socket import socket
367
+
368
+# if isinstance(msg, str):
369
+# try:
370
+# msg = json.loads(msg)
371
+# except json.JSONDecodeError as e:
372
+# logger.error(f"Invalid JSON string: {e}")
373
+# raise HTTPException(
374
+# status_code=400,
375
+# detail="Invalid JSON string.",
376
+# )
377
+# elif not isinstance(msg, dict):
378
+# logger.error("Invalid message type. Expected str or dict.")
379
+# raise HTTPException(
380
+# status_code=400,
381
+# detail="Invalid message type. Expected str or dict.",
382
+# )
383
+
384
+# try:
385
+# integration = msg["integration"]
386
+# except KeyError as e:
387
+# logger.error(f"KeyError: {e}")
388
+# raise HTTPException(
389
+# status_code=400,
390
+# detail="Invalid message format. Could not extract 'integration'.",
391
+# )
392
+
393
+# socketAddr = "/var/ossec/queue/sockets/queue"
394
+
395
+# try:
396
+# msg_str = json.dumps(msg)
397
+# logger.info(f"Sending {msg_str} to {socketAddr} socket.")
398
+# message = f"1:{integration}:{msg_str}"
399
+# sock = socket(AF_UNIX, SOCK_DGRAM)
400
+# sock.connect(socketAddr)
401
+# sock.send(message.encode())
402
+# sock.close()
403
+# logger.info(f"Message sent to {socketAddr} socket.")
404
+# return {"success": True, "message": "Message sent to Wazuh Socket."}
405
+# except Exception as e:
406
+# logger.error(f"Error: {e}")
407
+# raise HTTPException(
408
+# status_code=500,
409
+# detail=f"Error: {e}",
410
+# )
backend/app/integrations/alert_escalation/schema/general_alert.py
+1
-1
@@ -17,7 +17,7 @@ class ValidIocFields(Enum):
17
18
class CreateAlertRequest(BaseModel):
19
index_name: str = Field(..., description="The name of the index to search alerts for.")
20
- alert_id: str = Field(..., description="The alert id to create.")
20
+ alert_id: str = Field(..., description="The alert id.")
21
22
23
class CreateAlertResponse(BaseModel):
backend/app/integrations/alert_escalation/services/general_alert.py
+17
-1
@@ -139,7 +139,23 @@ async def add_alert_to_document(es_client, alert: CreateAlertRequest, soc_alert_
139
return full_url
140
except Exception as e:
141
logger.error(f"Failed to add alert ID {soc_alert_id} to alert {alert.alert_id} in index {alert.index_name}: {e}")
142
- return None
142
+ # Attempt to remove read-only block
143
+ try:
144
+ es_client.indices.put_settings(index=alert.index_name, body={"index.blocks.write": None})
145
+ logger.info(f"Removed read-only block from index {alert.index_name}. Retrying update.")
146
+
147
+ # Retry the update operation
148
+ es_client.update(index=alert.index_name, id=alert.alert_id, body={"doc": {"alert_url": full_url}})
149
+ logger.info(
150
+ f"Added alert ID {soc_alert_id} to alert {alert.alert_id} in index {alert.index_name} after removing read-only block",
151
+ )
152
+
153
+ # Reenable the write block
154
+ es_client.indices.put_settings(index=alert.index_name, body={"index.blocks.write": True})
155
+ return full_url
156
+ except Exception as e2:
157
+ logger.error(f"Failed to remove read-only block from index {alert.index_name}: {e2}")
158
+ return False
159
160
161
async def create_alert(alert: CreateAlertRequest, session: AsyncSession) -> CreateAlertResponse:
backend/app/integrations/ask_socfortress/routes/ask_socfortress.py
new
+68
@@ -0,0 +1,68 @@
1
+from fastapi import APIRouter
2
+from fastapi import Body
3
+from fastapi import Depends
4
+from fastapi import HTTPException
5
+from fastapi import Security
6
+from loguru import logger
7
+from sqlalchemy.ext.asyncio import AsyncSession
8
+from sqlalchemy.future import select
9
+
10
+from app.auth.utils import AuthHandler
11
+from app.db.db_session import get_session
12
+from app.db.universal_models import Customers
13
+from app.db.universal_models import CustomersMeta
14
+from app.integrations.ask_socfortress.schema.ask_socfortress import (
15
+ AskSocfortressRequest,
16
+)
17
+from app.integrations.ask_socfortress.schema.ask_socfortress import (
18
+ AskSocfortressSigmaRequest,
19
+)
20
+from app.integrations.ask_socfortress.schema.ask_socfortress import (
21
+ AskSocfortressSigmaResponse,
22
+)
23
+from app.integrations.ask_socfortress.services.ask_socfortress import (
24
+ ask_socfortress_lookup,
25
+)
26
+from app.utils import get_connector_attribute
27
+
28
+# App specific imports
29
+
30
+ask_socfortress_router = APIRouter()
31
+
32
+
33
+async def ensure_api_key_exists(session: AsyncSession = Depends(get_session)) -> bool:
34
+ """
35
+ Ensures that the Ask SocFortress API key exists in the database.
36
+
37
+ Args:
38
+ session (AsyncSession): The database session.
39
+
40
+ Raises:
41
+ HTTPException: Raised if the SocFortress API key is not found.
42
+
43
+ Returns:
44
+ bool: True if the API key exists, otherwise raises HTTPException.
45
+ """
46
+ api_key = await get_connector_attribute(connector_id=10, column_name="connector_api_key", session=session)
47
+ # Close the session
48
+ await session.close()
49
+ if not api_key:
50
+ raise HTTPException(status_code=500, detail="Ask SocFortress API key not found in the database.")
51
+ return True
52
+
53
+
54
+@ask_socfortress_router.post(
55
+ "/sigma",
56
+ response_model=AskSocfortressSigmaResponse,
57
+ description="Ask SOCFortress for a Sigma rule.",
58
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
59
+)
60
+async def ask_socfortress_sigma(
61
+ alert: AskSocfortressRequest,
62
+ session: AsyncSession = Depends(get_session),
63
+ _key_exists: bool = Depends(ensure_api_key_exists),
64
+):
65
+ logger.info("Running Ask SOCFortress Sigma lookup.")
66
+
67
+ ask_socfortress_result = await ask_socfortress_lookup(alert, session=session)
68
+ return ask_socfortress_result
backend/app/integrations/ask_socfortress/schema/ask_socfortress.py
new
+19
@@ -0,0 +1,19 @@
1
+from typing import List
2
+from typing import Optional
3
+
4
+from pydantic import BaseModel
5
+from pydantic import Field
6
+
7
+
8
+class AskSocfortressRequest(BaseModel):
9
+ index_name: str = Field(..., description="The name of the index to search alerts for.")
10
+ alert_id: str = Field(..., description="The alert id.")
11
+
12
+
13
+class AskSocfortressSigmaRequest(BaseModel):
14
+ sigma_rule_name: str = Field(..., title="Sigma rule name")
15
+
16
+
17
+class AskSocfortressSigmaResponse(BaseModel):
18
+ message: str = Field(..., title="Message")
19
+ success: bool = Field(..., title="Success")
backend/app/integrations/ask_socfortress/services/ask_socfortress.py
new
+168
@@ -0,0 +1,168 @@
1
+from typing import Optional
2
+
3
+import httpx
4
+from fastapi import APIRouter
5
+from fastapi import Body
6
+from fastapi import Depends
7
+from fastapi import HTTPException
8
+from fastapi import Security
9
+from loguru import logger
10
+from sqlalchemy.ext.asyncio import AsyncSession
11
+from sqlalchemy.future import select
12
+
13
+from app.connectors.wazuh_indexer.utils.universal import create_wazuh_indexer_client
14
+from app.integrations.alert_escalation.schema.general_alert import CreateAlertRequest
15
+from app.integrations.alert_escalation.schema.general_alert import CreateAlertResponse
16
+from app.integrations.alert_escalation.schema.general_alert import GenericAlertModel
17
+from app.integrations.alert_escalation.schema.general_alert import GenericSourceModel
18
+from app.integrations.ask_socfortress.schema.ask_socfortress import (
19
+ AskSocfortressRequest,
20
+)
21
+from app.integrations.ask_socfortress.schema.ask_socfortress import (
22
+ AskSocfortressSigmaRequest,
23
+)
24
+from app.integrations.ask_socfortress.schema.ask_socfortress import (
25
+ AskSocfortressSigmaResponse,
26
+)
27
+from app.utils import get_connector_attribute
28
+
29
+
30
+async def get_single_alert_details(alert_details: CreateAlertRequest) -> GenericAlertModel:
31
+ logger.info(f"Fetching alert details for alert {alert_details.alert_id} in index {alert_details.index_name}")
32
+ es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
33
+ try:
34
+ alert = es_client.get(index=alert_details.index_name, id=alert_details.alert_id)
35
+ source_model = GenericSourceModel(**alert["_source"])
36
+ return GenericAlertModel(_source=source_model, _id=alert["_id"], _index=alert["_index"], _version=alert["_version"])
37
+ except Exception as e:
38
+ logger.debug(f"Failed to collect alert details: {e}")
39
+ raise HTTPException(status_code=400, detail=f"Failed to collect alert details: {e}")
40
+
41
+
42
+async def get_ask_socfortress_attributes(column_name: str, session: AsyncSession) -> str:
43
+ """
44
+ Gets the Ask SocFortress attribute from the database.
45
+
46
+ Args:
47
+ column_name (str): The column name of the Ask SocFortress attribute.
48
+ session (AsyncSession): The database session.
49
+
50
+ Raises:
51
+ HTTPException: Raised if the Ask SocFortress Attribute is not found.
52
+
53
+ Returns:
54
+ str: The Ask SocFortress Attribute.
55
+
56
+ """
57
+ attribute_value = await get_connector_attribute(connector_id=10, column_name=column_name, session=session)
58
+ # Close the session
59
+ await session.close()
60
+ if not attribute_value:
61
+ raise HTTPException(status_code=500, detail="Ask Socfortress attributes not found in the database.")
62
+ return attribute_value
63
+
64
+
65
+async def invoke_ask_socfortress_api(api_key: str, url: str, request: AskSocfortressSigmaRequest) -> dict:
66
+ """
67
+ Invokes the Socfortress Threat Intel API with the provided API key, URL, and request parameters.
68
+
69
+ Args:
70
+ api_key (str): The API key for authentication.
71
+ url (str): The URL of the Socfortress Threat Intel API.
72
+ request (SocfortressThreatIntelRequest): The request object containing the IOC value and customer code.
73
+
74
+ Returns:
75
+ dict: The JSON response from the Socfortress Threat Intel API.
76
+
77
+ Raises:
78
+ httpx.HTTPStatusError: If the API request fails with a non-successful status code.
79
+ """
80
+ headers = {"module-version": "your_module_version", "x-api-key": api_key, "Content-Type": "application/json"}
81
+ data = {"sigma_rule_name": request.sigma_rule_name}
82
+ async with httpx.AsyncClient(timeout=60) as client:
83
+ response = await client.post(url=f"{url}/v1/sigma", headers=headers, json=data)
84
+ return response.json()
85
+
86
+
87
+async def get_ask_socfortress_response(request: AskSocfortressSigmaRequest, session: AsyncSession) -> AskSocfortressSigmaResponse:
88
+ """
89
+ Retrieves IoC response from Socfortress Threat Intel API.
90
+
91
+ Args:
92
+ request (SocfortressThreatIntelRequest): The request object containing the IoC data.
93
+ session (AsyncSession): The async session object for making HTTP requests.
94
+
95
+ Returns:
96
+ IoCResponse: The response object containing the IoC data and success status.
97
+ """
98
+ api_key = await get_ask_socfortress_attributes("connector_api_key", session)
99
+ url = await get_ask_socfortress_attributes("connector_url", session)
100
+ response_data = await invoke_ask_socfortress_api(api_key, url, request)
101
+
102
+ # Using .get() with default values
103
+ success = response_data.get("success", False)
104
+ message = response_data.get("message", "No message provided")
105
+
106
+ return AskSocfortressSigmaResponse(success=success, message=message)
107
+
108
+
109
+async def add_alert_to_document(es_client, alert: CreateAlertRequest, result: str, session: AsyncSession) -> Optional[str]:
110
+ """
111
+ Update the alert document in Elasticsearch with the provided SOC alert ID URL.
112
+
113
+ Parameters:
114
+ - es_client: The Elasticsearch client instance to use for the update.
115
+ - alert: The alert request object containing alert_id and index_name.
116
+ - soc_alert_id: The alert ID as it exists within IRIS.
117
+ - session: The database session for retrieving connector information.
118
+
119
+ Returns:
120
+ - True if the update is successful, False otherwise.
121
+ """
122
+ try:
123
+ es_client.update(index=alert.index_name, id=alert.alert_id, body={"doc": {"ask_socfortress_message": result}})
124
+ logger.info(f"Added Ask SOCFortress Message to alert {alert.alert_id} in index {alert.index_name}")
125
+ return None
126
+ except Exception as e:
127
+ logger.error(f"Failed to add Ask SOCFortress Message to alert {alert.alert_id} in index {alert.index_name}: {e}")
128
+
129
+ # Attempt to remove read-only block
130
+ try:
131
+ es_client.indices.put_settings(index=alert.index_name, body={"index.blocks.write": None})
132
+ logger.info(f"Removed read-only block from index {alert.index_name}. Retrying update.")
133
+
134
+ # Retry the update operation
135
+ es_client.update(index=alert.index_name, id=alert.alert_id, body={"doc": {"ask_socfortress": result}})
136
+ logger.info(
137
+ f"Added Ask SOCFortress Message to alert {alert.alert_id} in index {alert.index_name} after removing read-only block",
138
+ )
139
+
140
+ # Reenable the write block
141
+ es_client.indices.put_settings(index=alert.index_name, body={"index.blocks.write": True})
142
+ return True
143
+ except Exception as e2:
144
+ logger.error(f"Failed to remove read-only block from index {alert.index_name}: {e2}")
145
+ return False
146
+
147
+
148
+async def ask_socfortress_lookup(alert: AskSocfortressRequest, session: AsyncSession) -> AskSocfortressSigmaResponse:
149
+ """
150
+ Performs a threat intelligence lookup using the Socfortress service.
151
+
152
+ Args:
153
+ request (SocfortressThreatIntelRequest): The request object containing the IoC to lookup.
154
+ session (AsyncSession): The async session object for making HTTP requests.
155
+
156
+ Returns:
157
+ IoCResponse: The response object containing the threat intelligence information.
158
+ """
159
+ alert_details = await get_single_alert_details(alert_details=alert)
160
+ logger.info(f"Alert details: {alert_details}")
161
+ if alert_details._source.rule_group3 != "sigma":
162
+ raise HTTPException(status_code=400, detail="Alert is not a Sigma alert.")
163
+ sigma_rule_name = AskSocfortressSigmaRequest(sigma_rule_name=alert_details._source.data_name)
164
+ ask_socfortress_response = await get_ask_socfortress_response(sigma_rule_name, session)
165
+ result = ask_socfortress_response.message
166
+ es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
167
+ await add_alert_to_document(es_client, alert, result, session=session)
168
+ return ask_socfortress_response
backend/app/routers/alert_creation.py
new
+9
@@ -0,0 +1,9 @@
1
+from fastapi import APIRouter
2
+
3
+from app.integrations.alert_creation.general.routes.alert import general_alerts_router
4
+
5
+# Instantiate the APIRouter
6
+router = APIRouter()
7
+
8
+# Include the Ask SocFortress related routes
9
+router.include_router(general_alerts_router, prefix="/api/v1/alerts/general", tags=["Alert Creation"])
backend/app/routers/ask_socfortress.py
new
+11
@@ -0,0 +1,11 @@
1
+from fastapi import APIRouter
2
+
3
+from app.integrations.ask_socfortress.routes.ask_socfortress import (
4
+ ask_socfortress_router,
5
+)
6
+
7
+# Instantiate the APIRouter
8
+router = APIRouter()
9
+
10
+# Include the Ask SocFortress related routes
11
+router.include_router(ask_socfortress_router, prefix="/ask_socfortress", tags=["Ask SocFortress Integration"])
backend/app/utils.py
+10
@@ -30,6 +30,7 @@ from app.db.db_session import engine
30
from app.db.db_session import get_db_session
31
from app.db.db_session import get_session
32
from app.db.universal_models import LogEntry
33
+from app.integrations.alert_creation.models.alert_settings import AlertCreationSettings
34
35
36
################## ! 422 VALIDATION ERROR TYPES FOR PYDANTIC VALUE ERROR RESPONSE ! ##################
@@ -470,6 +471,15 @@ async def get_connector_attribute(connector_id: int, column_name: str, session:
471
return None
472
473
474
+async def get_customer_alert_settings(customer_code: str, session: AsyncSession) -> Optional[AlertCreationSettings]:
475
+ result = await session.execute(select(AlertCreationSettings).filter(AlertCreationSettings.customer_code == customer_code))
476
+ settings = result.scalars().first()
477
+
478
+ if settings:
479
+ return settings
480
+ return None
481
+
482
+
483
################## ! Wazuh Worker Provisioning App ! ##################
484
################## ! https://github.com/socfortress/Customer-Provisioning-Worker ! ##################
485
async def verify_wazuh_worker_provisioning_healtcheck(attributes: Dict[str, Any]) -> Dict[str, Any]:
backend/copilot.py
+4
@@ -18,6 +18,8 @@ from app.middleware.exception_handlers import validation_exception_handler
18
from app.middleware.exception_handlers import value_error_handler
19
from app.middleware.logger import log_requests
20
from app.routers import agents
21
+from app.routers import alert_creation
22
+from app.routers import ask_socfortress
23
from app.routers import auth
24
from app.routers import connectors
25
from app.routers import cortex
@@ -86,6 +88,8 @@ app.include_router(influxdb.router)
88
app.include_router(grafana.router)
89
app.include_router(customer_provisioning.router)
90
app.include_router(threat_intel.router)
91
+app.include_router(ask_socfortress.router)
92
+app.include_router(alert_creation.router)
93
94
95
@app.on_event("startup")
src/api/askSocfortress.ts
new
+12
@@ -0,0 +1,12 @@
1
+import { HttpClient } from "./httpClient"
2
+import type { FlaskBaseResponse } from "@/types/flask.d"
3
+
4
+export default {
5
+ create(indexName: string, alertId: string) {
6
+ const body = {
7
+ index_name: indexName,
8
+ alert_id: alertId
9
+ }
10
+ return HttpClient.post<FlaskBaseResponse>(`/ask_socfortress/sigma`, body)
11
+ }
12
+}
src/api/index.ts
+3
-1
@@ -8,6 +8,7 @@ import indices from "./indices"
8
import soc from "./soc"
9
import healthchecks from "./healthchecks"
10
import threatIntel from "./threatIntel"
11
+import askSocfortress from "./askSocfortress"
12
13
export default {
14
agents,
@@ -19,5 +20,6 @@ export default {
20
indices,
21
soc,
22
healthchecks,
22
- threatIntel
23
+ threatIntel,
24
+ askSocfortress
25
}
src/components/alerts/Alert.vue
+124
-65
@@ -92,85 +92,163 @@
92
</Badge>
93
</div>
94
</div>
95
- <div class="actions-box flex flex-col justify-end" v-if="!hideActions">
96
- <n-button type="primary" secondary v-if="alertUrl" tag="a" :href="alertUrl" target="_blank">
97
- <template #icon><Icon :name="ViewIcon"></Icon></template>
98
- View Alert
99
- </n-button>
100
- <n-button :loading="loading" type="warning" secondary @click="createAlert()" v-else>
101
- <template #icon><Icon :name="DangerIcon"></Icon></template>
102
- Create SOC Alert
103
- </n-button>
104
- </div>
95
+ <AlertActions
96
+ v-if="!hideActions"
97
+ class="actions-box"
98
+ :alert="alert"
99
+ @start-loading="loading = true"
100
+ @stop-loading="loading = false"
101
+ @updated-url="alert._source.alert_url = $event"
102
+ @updated-ask-message="alert._source.ask_socfortress_message = $event"
103
+ />
104
</div>
105
<div class="footer-box flex justify-between items-center gap-4">
107
- <div class="actions-box flex flex-col justify-end" v-if="!hideActions">
108
- <n-button
109
- type="primary"
110
- secondary
111
- size="small"
112
- v-if="alertUrl"
113
- tag="a"
114
- :href="alertUrl"
115
- target="_blank"
116
- >
117
- <template #icon><Icon :name="ViewIcon"></Icon></template>
118
- View Alert
119
- </n-button>
120
- <n-button :loading="loading" type="warning" secondary size="small" @click="createAlert()" v-else>
121
- <template #icon><Icon :name="DangerIcon"></Icon></template>
122
- Create SOC Alert
123
- </n-button>
124
- </div>
125
-
106
+ <AlertActions
107
+ v-if="!hideActions"
108
+ class="actions-box"
109
+ :alert="alert"
110
+ :size="'small'"
111
+ @start-loading="loading = true"
112
+ @stop-loading="loading = false"
113
+ @updated-url="alert._source.alert_url = $event"
114
+ @updated-ask-message="alert._source.ask_socfortress_message = $event"
115
+ />
116
<div class="time">{{ formatDate(alert._source.timestamp_utc) }}</div>
117
</div>
118
119
<n-modal
120
v-model:show="showDetails"
121
preset="card"
132
- :style="{ maxWidth: 'min(800px, 90vw)', overflow: 'hidden' }"
122
+ content-style="padding:0px"
123
+ :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
124
:title="`Alert: ${alert._id}`"
125
:bordered="false"
126
segmented
127
>
137
- <SimpleJsonViewer class="vuesjv-override" :model-value="alert._source" :initialExpandedDepth="2" />
128
+ <n-tabs type="line" animated justify-content="space-evenly">
129
+ <n-tab-pane name="Agent" tab="Agent" display-directive="show">
130
+ <div class="grid gap-2 alert-context-grid p-7 pt-4" v-if="agentProperties">
131
+ <KVCard v-for="(value, key) of agentProperties" :key="key">
132
+ <template #key>{{ key }}</template>
133
+ <template #value>
134
+ <template v-if="key === 'agent_id'">
135
+ <code class="cursor-pointer text-primary-color" @click="gotoAgentPage(value + '')">
136
+ {{ value }}
137
+ <Icon :name="LinkIcon" :size="13" class="relative top-0.5" />
138
+ </code>
139
+ </template>
140
+ <template v-else>
141
+ {{ value || "-" }}
142
+ </template>
143
+ </template>
144
+ </KVCard>
145
+ </div>
146
+ </n-tab-pane>
147
+ <n-tab-pane
148
+ name="SOCFortress Response"
149
+ tab="SOCFortress Response"
150
+ v-if="alert._source.ask_socfortress_message"
151
+ display-directive="show"
152
+ >
153
+ <div class="p-7 pt-4">
154
+ <n-input
155
+ :value="alert._source.ask_socfortress_message"
156
+ type="textarea"
157
+ readonly
158
+ placeholder="Empty"
159
+ :autosize="{
160
+ minRows: 3
161
+ }"
162
+ />
163
+ </div>
164
+ </n-tab-pane>
165
+ <n-tab-pane name="Message" tab="Message" v-if="alert._source.message" display-directive="show">
166
+ <div class="p-7 pt-4">
167
+ <n-input
168
+ :value="alert._source.message"
169
+ type="textarea"
170
+ readonly
171
+ placeholder="Empty"
172
+ :autosize="{
173
+ minRows: 3
174
+ }"
175
+ />
176
+ </div>
177
+ </n-tab-pane>
178
+ <n-tab-pane
179
+ name="Data document"
180
+ tab="Data document"
181
+ v-if="alert._source.data_document"
182
+ display-directive="show"
183
+ >
184
+ <div class="p-7 pt-4">
185
+ <n-input
186
+ :value="alert._source.data_document"
187
+ type="textarea"
188
+ readonly
189
+ placeholder="Empty"
190
+ :autosize="{
191
+ minRows: 3
192
+ }"
193
+ />
194
+ </div>
195
+ </n-tab-pane>
196
+ <n-tab-pane name="Details" tab="Details" display-directive="show:lazy">
197
+ <div class="p-7 pt-4">
198
+ <SimpleJsonViewer
199
+ class="vuesjv-override"
200
+ :model-value="alert._source"
201
+ :initialExpandedDepth="2"
202
+ />
203
+ </div>
204
+ </n-tab-pane>
205
+ </n-tabs>
206
</n-modal>
207
</div>
208
</template>
209
210
<script setup lang="ts">
143
-import { NButton, NPopover, NModal } from "naive-ui"
211
+import { NPopover, NModal, NTabs, NTabPane, NInput } from "naive-ui"
212
import { useSettingsStore } from "@/stores/settings"
213
import dayjs from "@/utils/dayjs"
214
import Icon from "@/components/common/Icon.vue"
215
import Badge from "@/components/common/Badge.vue"
216
+import AlertActions from "./AlertActions.vue"
217
import type { Alert } from "@/types/alerts.d"
218
import { SimpleJsonViewer } from "vue-sjv"
219
import "@/assets/scss/vuesjv-override.scss"
220
import { useRouter } from "vue-router"
152
-import Api from "@/api"
153
-import { onBeforeMount, ref } from "vue"
154
-import { useMessage } from "naive-ui/lib"
221
+import { computed, ref, toRefs } from "vue"
222
+import _pick from "lodash/pick"
223
+import KVCard from "@/components/common/KVCard.vue"
224
156
-const { alert, hideActions } = defineProps<{ alert: Alert; hideActions?: boolean }>()
225
+const props = defineProps<{ alert: Alert; hideActions?: boolean }>()
226
+const { alert, hideActions } = toRefs(props)
227
228
const InfoIcon = "carbon:information"
229
const TargetIcon = "zondicons:target"
160
-const DangerIcon = "majesticons:exclamation-line"
230
const DisabledIcon = "ph:minus-bold"
231
const MailIcon = "carbon:email"
232
const AgentIcon = "carbon:police"
164
-const ViewIcon = "iconoir:eye-alt"
233
const LinkIcon = "carbon:launch"
234
167
-const message = useMessage()
235
const router = useRouter()
236
const loading = ref(false)
237
const showDetails = ref(false)
238
const dFormats = useSettingsStore().dateFormat
239
173
-const alertUrl = ref("")
240
+const agentProperties = computed(() => {
241
+ return _pick(alert.value._source, [
242
+ "agent_id",
243
+ "agent_ip_city_name",
244
+ "agent_ip_country_code",
245
+ "agent_ip_geolocation",
246
+ "agent_ip_reserved_ip",
247
+ "agent_ip",
248
+ "agent_labels_customer",
249
+ "agent_name"
250
+ ])
251
+})
252
253
function formatDate(timestamp: string): string {
254
return dayjs(timestamp).format(dFormats.datetimesec)
@@ -179,31 +257,6 @@ function formatDate(timestamp: string): string {
257
function gotoAgentPage(agentId: string) {
258
router.push(`/agent/${agentId}`).catch(() => {})
259
}
182
-
183
-function createAlert() {
184
- loading.value = true
185
-
186
- Api.alerts
187
- .create(alert._index, alert._id)
188
- .then(res => {
189
- if (res.data.success) {
190
- res.data.alert_url && (alertUrl.value = res.data.alert_url)
191
- message.success(res.data?.message || "SOC Alert created.")
192
- } else {
193
- message.warning(res.data?.message || "An error occurred. Please try again later.")
194
- }
195
- })
196
- .catch(err => {
197
- message.error(err.response?.data?.message || "An error occurred. Please try again later.")
198
- })
199
- .finally(() => {
200
- loading.value = false
201
- })
202
-}
203
-
204
-onBeforeMount(() => {
205
- alert._source.alert_url && (alertUrl.value = alert._source.alert_url)
206
-})
260
</script>
261
262
<style lang="scss" scoped>
@@ -281,3 +334,9 @@ onBeforeMount(() => {
334
}
335
}
336
</style>
337
+<style lang="scss">
338
+.alert-context-grid {
339
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
340
+ grid-auto-flow: row dense;
341
+}
342
+</style>
src/components/alerts/AlertActions.vue
new
+150
@@ -0,0 +1,150 @@
1
+<template>
2
+ <div class="alert-actions flex flex-col gap-2 justify-end">
3
+ <n-button type="primary" secondary :size="size" v-if="alertUrl" tag="a" :href="alertUrl" target="_blank">
4
+ <template #icon><Icon :name="ViewIcon"></Icon></template>
5
+ View SOC Alert
6
+ </n-button>
7
+ <n-button
8
+ :loading="loadingSocAlert"
9
+ type="warning"
10
+ secondary
11
+ :size="size"
12
+ @click="createAlert()"
13
+ v-if="!alertUrl"
14
+ >
15
+ <template #icon><Icon :name="DangerIcon"></Icon></template>
16
+ Create SOC Alert
17
+ </n-button>
18
+ <n-button type="primary" secondary :size="size" v-if="alertAskMessage" @click="showSocResponse = true">
19
+ <template #icon><Icon :name="ViewIcon"></Icon></template>
20
+ View SOCFortress Response
21
+ </n-button>
22
+ <n-button
23
+ :loading="loadingAskSoc"
24
+ type="warning"
25
+ secondary
26
+ :size="size"
27
+ @click="askSOCFortress()"
28
+ v-if="isAskVisible"
29
+ >
30
+ <template #icon><Icon :name="AskIcon"></Icon></template>
31
+ Ask SOCFortress
32
+ </n-button>
33
+
34
+ <n-modal
35
+ v-model:show="showSocResponse"
36
+ preset="card"
37
+ :style="{ maxWidth: 'min(800px, 90vw)', overflow: 'hidden' }"
38
+ title="SOCFortress Response"
39
+ :bordered="false"
40
+ segmented
41
+ >
42
+ <n-input
43
+ :value="alertAskMessage"
44
+ type="textarea"
45
+ readonly
46
+ placeholder="SOCFortress Response"
47
+ :autosize="{
48
+ minRows: 3
49
+ }"
50
+ />
51
+ </n-modal>
52
+ </div>
53
+</template>
54
+
55
+<script setup lang="ts">
56
+import { NButton, NInput, NModal } from "naive-ui"
57
+import Icon from "@/components/common/Icon.vue"
58
+import type { Alert } from "@/types/alerts.d"
59
+import Api from "@/api"
60
+import { computed, onBeforeMount, ref } from "vue"
61
+import { useMessage } from "naive-ui/lib"
62
+import { watch } from "vue"
63
+
64
+const emit = defineEmits<{
65
+ (e: "startLoading"): void
66
+ (e: "stopLoading"): void
67
+ (e: "updatedUrl", value: string): void
68
+ (e: "updatedAskMessage", value: string): void
69
+}>()
70
+
71
+const { alert, size } = defineProps<{ alert: Alert; size?: "tiny" | "small" | "medium" | "large" }>()
72
+
73
+const DangerIcon = "majesticons:exclamation-line"
74
+const AskIcon = "majesticons:question-mark-circle-line"
75
+const ViewIcon = "iconoir:eye-alt"
76
+
77
+const message = useMessage()
78
+const showSocResponse = ref(false)
79
+const loadingSocAlert = ref(false)
80
+const loadingAskSoc = ref(false)
81
+const loading = computed(() => loadingSocAlert.value || loadingAskSoc.value)
82
+
83
+const alertUrl = ref("")
84
+const alertAskMessage = ref("")
85
+
86
+const isAskVisible = computed(() => alert._source?.rule_group3 === "sigma" && !alertAskMessage.value)
87
+
88
+watch(loading, val => {
89
+ emit(val ? "startLoading" : "startLoading")
90
+})
91
+
92
+watch(alertUrl, val => {
93
+ if (val) {
94
+ emit("updatedUrl", val)
95
+ }
96
+})
97
+
98
+watch(alertAskMessage, val => {
99
+ if (val) {
100
+ emit("updatedAskMessage", val)
101
+ }
102
+})
103
+
104
+function askSOCFortress() {
105
+ loadingAskSoc.value = true
106
+
107
+ Api.askSocfortress
108
+ .create(alert._index, alert._id)
109
+ .then(res => {
110
+ if (res.data.success) {
111
+ res.data.message && (alertAskMessage.value = res.data.message)
112
+ message.success("Asked SOCFortress Sigma.")
113
+ } else {
114
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
115
+ }
116
+ })
117
+ .catch(err => {
118
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
119
+ })
120
+ .finally(() => {
121
+ loadingAskSoc.value = false
122
+ })
123
+}
124
+
125
+function createAlert() {
126
+ loadingSocAlert.value = true
127
+
128
+ Api.alerts
129
+ .create(alert._index, alert._id)
130
+ .then(res => {
131
+ if (res.data.success) {
132
+ res.data.alert_url && (alertUrl.value = res.data.alert_url)
133
+ message.success(res.data?.message || "SOC Alert created.")
134
+ } else {
135
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
136
+ }
137
+ })
138
+ .catch(err => {
139
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
140
+ })
141
+ .finally(() => {
142
+ loadingSocAlert.value = false
143
+ })
144
+}
145
+
146
+onBeforeMount(() => {
147
+ alertUrl.value = alert._source.alert_url || ""
148
+ alertAskMessage.value = alert._source.ask_socfortress_message || ""
149
+})
150
+</script>
src/components/alerts/AlertsList.vue
+5
-2
@@ -142,6 +142,10 @@
142
</template>
143
144
<script setup lang="ts">
145
+// MOCK
146
+// import { alerts_summary } from "./mock"
147
+// import type { AlertsSummary } from "@/types/alerts"
148
+
149
import { ref, onBeforeMount, toRefs, computed, nextTick, onMounted } from "vue"
150
import { useMessage, NSpin, NPopover, NButton, NEmpty, NDrawer, NDrawerContent, NSelect } from "naive-ui"
151
import Api from "@/api"
@@ -151,8 +155,6 @@ import AlertsFilters from "./AlertsFilters.vue"
155
import AlertsSummaryItem, { type AlertsSummaryExt } from "./AlertsSummary.vue"
156
import Icon from "@/components/common/Icon.vue"
157
import type { AlertsSummaryQuery } from "@/api/alerts"
154
-// import { alerts_summary } from "./mock"
155
-// import type { AlertsSummary } from "@/types/alerts"
158
import type { IndexStats } from "@/types/indices.d"
159
import axios from "axios"
160
import type { Agent } from "@/types/agents.d"
@@ -353,6 +355,7 @@ onBeforeMount(() => {
355
getIndices()
356
}
357
358
+ // MOCK
359
// alertsSummaryList.value = alerts_summary as AlertsSummary[]
360
startSearch()
361
})
src/components/common/Notifications/List.vue
renamed
+61
-21
@@ -9,6 +9,12 @@
9
>
10
<div class="icon-box">
11
<Icon :name="AlertIcon" :size="21" v-if="item.category === 'alert'"></Icon>
12
+ <n-tooltip trigger="hover" style="padding: 0" placement="right" v-if="!item.read">
13
+ <template #trigger>
14
+ <div class="read-badge" @click.stop="setRead(item.id)"></div>
15
+ </template>
16
+ Set as read
17
+ </n-tooltip>
18
</div>
19
<div class="content grow">
20
<div class="title">{{ item.title }}</div>
@@ -18,19 +24,22 @@
24
<div class="action-text" v-if="!!item.action">{{ item.actionTitle || "Details" }}</div>
25
</div>
26
</div>
21
- <div class="read-badge" v-if="!item.read" @click.stop="setRead(item.id)"></div>
27
+ <div class="delete-btn" @click.stop="deleteOne(item.id)">
28
+ <Icon :name="DeleteIcon" :size="18"></Icon>
29
+ </div>
30
</div>
31
<slot name="last"></slot>
32
</n-scrollbar>
33
</template>
34
35
<script lang="ts" setup>
28
-import { NScrollbar } from "naive-ui"
36
+import { NScrollbar, NTooltip } from "naive-ui"
37
import Icon from "@/components/common/Icon.vue"
38
import { useNotifications } from "@/composables/useNotifications"
39
import { computed } from "vue"
40
import _take from "lodash/take"
41
42
+const DeleteIcon = "carbon:close"
43
const AlertIcon = "mdi:alert-outline"
44
45
const props = defineProps<{
@@ -55,6 +64,10 @@ function setRead(id: string | number) {
64
useNotifications().setRead(id)
65
}
66
67
+function deleteOne(id: string | number) {
68
+ useNotifications().deleteOne(id)
69
+}
70
+
71
function formatDatetime(date: Date | string) {
72
return useNotifications().formatDatetime(date)
73
}
@@ -65,11 +78,13 @@ function formatDatetime(date: Date | string) {
78
.item {
79
position: relative;
80
padding: 14px 0;
81
+
82
.icon-box {
83
width: 70px;
84
min-width: 70px;
85
display: flex;
86
justify-content: center;
87
+ position: relative;
88
89
.n-icon {
90
display: flex;
@@ -80,7 +95,19 @@ function formatDatetime(date: Date | string) {
95
height: 42px;
96
margin-top: 2px;
97
}
98
+
99
+ .read-badge {
100
+ position: absolute;
101
+ top: 5px;
102
+ left: 14px;
103
+ width: 10px;
104
+ height: 10px;
105
+ border-radius: 50%;
106
+ background-color: var(--primary-color);
107
+ cursor: pointer;
108
+ }
109
}
110
+
111
.content {
112
max-width: 250px;
113
padding-right: 20px;
@@ -103,48 +130,53 @@ function formatDatetime(date: Date | string) {
130
}
131
}
132
106
- .read-badge {
133
+ .delete-btn {
134
position: absolute;
108
- top: 14px;
109
- right: 14px;
110
- width: 10px;
111
- height: 10px;
112
- border-radius: 50%;
113
- background-color: var(--primary-color);
135
+ top: 8px;
136
+ right: 8px;
137
cursor: pointer;
138
+ opacity: 0;
139
}
140
141
&.success {
118
- .n-icon {
119
- background-color: var(--primary-005-color);
120
- color: var(--success-color);
142
+ .icon-box {
143
+ .n-icon {
144
+ background-color: var(--primary-005-color);
145
+ color: var(--success-color);
146
+ }
147
}
148
.action-text {
149
color: var(--success-color);
150
}
151
}
152
&.info {
127
- .n-icon {
128
- background-color: var(--secondary1-opacity-010-color);
129
- color: var(--info-color);
153
+ .icon-box {
154
+ .n-icon {
155
+ background-color: var(--secondary1-opacity-010-color);
156
+ color: var(--info-color);
157
+ }
158
}
159
.action-text {
160
color: var(--info-color);
161
}
162
}
163
&.warning {
136
- .n-icon {
137
- background-color: var(--secondary3-opacity-010-color);
138
- color: var(--warning-color);
164
+ .icon-box {
165
+ .n-icon {
166
+ background-color: var(--secondary3-opacity-010-color);
167
+ color: var(--warning-color);
168
+ }
169
}
170
.action-text {
171
color: var(--warning-color);
172
}
173
}
174
&.error {
145
- .n-icon {
146
- background-color: var(--secondary4-opacity-010-color);
147
- color: var(--error-color);
175
+ .icon-box {
176
+ .n-icon {
177
+ background-color: var(--secondary4-opacity-010-color);
178
+ color: var(--error-color);
179
+ }
180
}
181
.action-text {
182
color: var(--error-color);
@@ -161,6 +193,14 @@ function formatDatetime(date: Date | string) {
193
194
&:hover {
195
background-color: var(--hover-005-color);
196
+
197
+ .delete-btn {
198
+ opacity: 0.5;
199
+
200
+ &:hover {
201
+ opacity: 1;
202
+ }
203
+ }
204
}
205
}
206
}
src/components/common/Notifications/Toolbar.vue
new
+27
@@ -0,0 +1,27 @@
1
+<template>
2
+ <div
3
+ class="notifications-toolbar flex"
4
+ :class="{ 'justify-between': hasNotifications, 'justify-end': !hasNotifications }"
5
+ >
6
+ <n-button quaternary @click="deleteAll()" v-if="hasNotifications">Clear</n-button>
7
+ <n-button strong secondary type="primary" :disabled="!hasUnread" @click="setAllRead()">
8
+ Mark all as read
9
+ </n-button>
10
+ </div>
11
+</template>
12
+
13
+<script lang="ts" setup>
14
+import { NButton } from "naive-ui"
15
+import { useNotifications } from "@/composables/useNotifications"
16
+
17
+const hasUnread = useNotifications().hasUnread
18
+const hasNotifications = useNotifications().hasNotifications
19
+
20
+function setAllRead() {
21
+ useNotifications().setAllRead()
22
+}
23
+
24
+function deleteAll() {
25
+ useNotifications().deleteAll()
26
+}
27
+</script>
src/composables/useNotifications.ts
+9
-1
@@ -23,7 +23,8 @@ export interface Notification {
23
const list = useStorage<Notification[]>("notifications-list", [], localStorage)
24
25
export function useNotifications() {
26
- const hasNotifications = computed(() => list.value.filter(o => !o.read).length !== 0)
26
+ const hasUnread = computed(() => list.value.filter(o => !o.read).length !== 0)
27
+ const hasNotifications = computed(() => list.value.length !== 0)
28
const dFormats = useSettingsStore().dateFormat
29
30
function formatDatetime(date: Date | string) {
@@ -38,6 +39,7 @@ export function useNotifications() {
39
40
return {
41
list,
42
+ hasUnread,
43
hasNotifications,
44
formatDatetime,
45
setRead: (id: string | number) => {
@@ -51,6 +53,12 @@ export function useNotifications() {
53
item.read = true
54
}
55
},
56
+ deleteOne: (id: string | number) => {
57
+ list.value = list.value.filter(o => o.id !== id)
58
+ },
59
+ deleteAll: () => {
60
+ list.value = []
61
+ },
62
prepend: (newItem: Notification, sendNotify: boolean = true) => {
63
if (sendNotify) {
64
const notify: NotificationObject = {
src/layouts/common/Toolbar/Notifications.vue
+9
-20
@@ -1,7 +1,7 @@
1
<template>
2
<n-popover :show-arrow="false" placement="bottom" content-style="padding:0" style="width: 280px">
3
<template #trigger>
4
- <n-badge :show="hasNotifications" dot :color="primaryColor">
4
+ <n-badge :show="hasUnread" dot :color="primaryColor">
5
<Icon :name="BellIcon" :size="21" class="trigger-icon"></Icon>
6
</n-badge>
7
</template>
@@ -9,32 +9,24 @@
9
<n-text strong depth="1">Notifications</n-text>
10
</template>
11
<template #default>
12
- <Notifications :max-items="MAX_ITEMS" style="max-height: 50vh">
12
+ <NotificationsList :max-items="MAX_ITEMS" style="max-height: 50vh">
13
<template #last>
14
<div class="p-4 flex justify-center" v-if="list.length > MAX_ITEMS">
15
<n-button text @click="showDrawer = true">View all</n-button>
16
</div>
17
</template>
18
- </Notifications>
18
+ </NotificationsList>
19
</template>
20
<template #footer>
21
- <div class="flex justify-end">
22
- <n-button strong secondary type="primary" :disabled="!hasNotifications" @click="setAllRead()">
23
- Mark all as read
24
- </n-button>
25
- </div>
21
+ <NotificationsToolbar />
22
</template>
23
</n-popover>
24
25
<n-drawer v-model:show="showDrawer" :width="400" style="max-width: 90vw" :trap-focus="false">
26
<n-drawer-content title="Notifications" closable body-content-style="padding:0">
31
- <Notifications />
27
+ <NotificationsList />
28
<template #footer>
33
- <div class="flex justify-end">
34
- <n-button strong secondary type="primary" :disabled="!hasNotifications" @click="setAllRead()">
35
- Mark all as read
36
- </n-button>
37
- </div>
29
+ <NotificationsToolbar />
30
</template>
31
</n-drawer-content>
32
</n-drawer>
@@ -45,24 +37,21 @@ import { NButton, NText, NPopover, NBadge, NDrawer, NDrawerContent } from "naive
37
import { computed, ref, onBeforeMount } from "vue"
38
import { useThemeStore } from "@/stores/theme"
39
import Icon from "@/components/common/Icon.vue"
48
-import Notifications from "@/components/common/Notifications.vue"
40
+import NotificationsList from "@/components/common/Notifications/List.vue"
41
+import NotificationsToolbar from "@/components/common/Notifications/Toolbar.vue"
42
import { useNotifications } from "@/composables/useNotifications"
43
import { useHealthchecksNotify } from "@/composables/useHealthchecksNotify"
44
45
const BellIcon = "ph:bell"
46
47
const primaryColor = computed(() => useThemeStore().primaryColor)
55
-const hasNotifications = useNotifications().hasNotifications
48
+const hasUnread = useNotifications().hasUnread
49
50
const showDrawer = ref(false)
51
const list = useNotifications().list
52
53
const MAX_ITEMS = 7
54
62
-function setAllRead() {
63
- useNotifications().setAllRead()
64
-}
65
-
55
onBeforeMount(() => {
56
useHealthchecksNotify().init()
57
})