@cryptotaxi247 / CoPilot / commits / 63c9ce4d

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 })