Skip to content

Commit f9fc388

Browse files
committed
feat(incidents)!: rebuild the module on uuids and stored words, and notify what it raises
1 parent 333cdb8 commit f9fc388

108 files changed

Lines changed: 1992 additions & 1490 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/database/migrations.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ func Models() []any {
6161
compliance_domain.UtmComplianceControlNote{},
6262
opensearch_domain.UtmIndexPattern{},
6363
integrations_domain.UtmModule{},
64-
incidents_domain.UtmIncident{},
65-
incidents_domain.UtmIncidentAlert{},
66-
incidents_domain.UtmIncidentNote{},
67-
incidents_domain.UtmIncidentHistory{},
64+
incidents_domain.Incident{},
65+
incidents_domain.IncidentAlert{},
66+
incidents_domain.IncidentNote{},
67+
incidents_domain.IncidentHistory{},
6868
notifications_domain.UtmNotification{},
6969
datasources_domain.UtmAssetGroup{},
7070
datasources_domain.Datasource{},

‎backend/migrations/000001_init.up.sql‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -376,16 +376,23 @@ BEGIN
376376
END IF;
377377
END $$;
378378

379-
-- Global alert/incident email recipients. Both optional, comma-separated; with
380-
-- both empty the incident mailer falls back to every active user. The appconfig
381-
-- usecase only ever UPDATEs pre-seeded rows, so these have to exist for
382-
-- GET/PUT /config/<key> to answer.
379+
-- Email recipients, kept apart for alerts and for incidents because they are
380+
-- different audiences at different volumes: an incident is raised by a person
381+
-- and there are few, while alerts arrive on their own and at whatever rate the
382+
-- environment produces. One list for both means whoever wants incident mail
383+
-- also gets every alert.
384+
--
385+
-- All four optional and comma-separated. The appconfig usecase only ever
386+
-- UPDATEs pre-seeded rows, so they have to exist here for GET/PUT
387+
-- /config/<key> to answer.
383388
INSERT INTO app_config
384389
(tenant_id, conf_param_short, conf_param_large, conf_param_description, conf_param_value, conf_param_required, conf_param_datatype, conf_param_option)
385390
SELECT 'ce66672c-e36d-4761-a8c8-90058fee1a24', v.conf_param_short, v.conf_param_large, v.conf_param_description, v.conf_param_value, v.conf_param_required, v.conf_param_datatype, v.conf_param_option
386391
FROM (VALUES
387-
('utmstack.alerts.notification_to', 'Alerts notification To', 'Comma-separated addresses that receive alert/incident notifications. Empty falls back to every activated user.', '', false, 'text', NULL),
388-
('utmstack.alerts.notification_cc', 'Alerts notification Cc', 'Comma-separated addresses copied on alert/incident notifications.', '', false, 'text', NULL)
392+
('utmstack.alerts.notification_to', 'Alerts notification To', 'Comma-separated addresses that receive new-alert notifications. Empty means alerts are not emailed.', '', false, 'text', NULL),
393+
('utmstack.alerts.notification_cc', 'Alerts notification Cc', 'Comma-separated addresses copied on new-alert notifications.', '', false, 'text', NULL),
394+
('utmstack.incidents.notification_to', 'Incidents notification To', 'Comma-separated addresses that receive incident notifications. Empty means incidents are not emailed.', '', false, 'text', NULL),
395+
('utmstack.incidents.notification_cc', 'Incidents notification Cc', 'Comma-separated addresses copied on incident notifications.', '', false, 'text', NULL)
389396
) AS v(conf_param_short, conf_param_large, conf_param_description, conf_param_value, conf_param_required, conf_param_datatype, conf_param_option)
390397
WHERE NOT EXISTS (
391398
SELECT 1 FROM app_config c WHERE c.conf_param_short = v.conf_param_short

‎backend/modules.go‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
197197
_ = catcher.Error("opensearch SDK connect failed", err, nil)
198198
}
199199

200-
alertsMod := alerts.NewModule(db, events)
200+
alertsMod := alerts.NewModule(db, events, alerts.NewAlertMailer(mailMod.Service(), configMod.Store()))
201201

202202
agentClient, agentErr := agentmanager.NewClient()
203203
if agentErr != nil {
@@ -270,9 +270,8 @@ func initModules(db *gorm.DB, cfg *config) *modules {
270270
env.String("UPDATES_DIR", "/updates", false), aiQuota, joblease.New(db))
271271
incidentsMod := incidents.NewModule(
272272
db,
273-
incidents.NewIncidentMailer(mailMod.Service(), configMod.Store(), userRepo),
273+
incidents.NewIncidentMailer(mailMod.Service(), configMod.Store()),
274274
incidents.NewAlertsGatewayFromUsecase(alertsMod.GetAlertUsecase()),
275-
incidents.NewIAMGatewayFromRepo(userRepo),
276275
auditMod.Logger(),
277276
)
278277
adauditMod := adaudit.NewModule(db)

‎backend/modules/alerts/connectors/repository.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type AlertRepository interface {
1616
UpdateNotes(ctx context.Context, alertID, notes string, history []HistoryEntry) error
1717
UpdateAssignee(ctx context.Context, alertID, assignee string, history []HistoryEntry) error
1818
UpdateTags(ctx context.Context, alertIDs []string, tags []string, history []HistoryEntry) error
19-
ConvertToIncident(ctx context.Context, alertIDs []string, name string, id int, createdAt time.Time, createdBy, source string, history []HistoryEntry) error
19+
ConvertToIncident(ctx context.Context, alertIDs []string, name, id string, createdAt time.Time, createdBy, source string, history []HistoryEntry) error
2020
CountOpenAlerts(ctx context.Context) (int64, error)
2121
CountByStatus(ctx context.Context, status domain.AlertStatus) (int64, error)
2222
SearchByIDs(ctx context.Context, alertIDs []string) ([]domain.UtmAlert, error)

‎backend/modules/alerts/connectors/usecase.go‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@ type AdversaryUsecase interface {
1818
FetchAdversaryAlerts(ctx context.Context, filters []common_models.FilterType) ([]dto.AdversaryResponse, error)
1919
}
2020

21+
type AlertMailer interface {
22+
SendAlertRaised(ctx context.Context, alert domain.UtmAlert) error
23+
}
24+
2125
type AlertUsecase interface {
22-
UpdateStatus(ctx context.Context, userLogin string, req dto.UpdateAlertStatusRequest) error
23-
UpdateNotes(ctx context.Context, userLogin string, alertID string, notes string) error
24-
UpdateAssignee(ctx context.Context, userLogin string, alertID string, assignee string) error
25-
UpdateTags(ctx context.Context, userLogin string, req dto.UpdateAlertTagsRequest) error
26-
ConvertToIncident(ctx context.Context, userLogin string, req dto.ConvertToIncidentRequest) error
26+
UpdateStatus(ctx context.Context, userEmail string, req dto.UpdateAlertStatusRequest) error
27+
NotifyRaised(ctx context.Context, alertID string) error
28+
UpdateNotes(ctx context.Context, userEmail string, alertID string, notes string) error
29+
UpdateAssignee(ctx context.Context, userEmail string, alertID string, assignee string) error
30+
UpdateTags(ctx context.Context, userEmail string, req dto.UpdateAlertTagsRequest) error
31+
ConvertToIncident(ctx context.Context, userEmail string, req dto.ConvertToIncidentRequest) error
2732
CountOpenAlerts(ctx context.Context) (*dto.CountOpenAlertsResponse, error)
2833
RelatedLogs(ctx context.Context, alertID string) (*dto.RelatedLogsResponse, error)
2934
ListEchoes(ctx context.Context, parentID string, page, size int, sortBy, sortOrder string) ([]domain.UtmAlert, int64, error)

‎backend/modules/alerts/domain/alert.go‎

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "encoding/json"
44

55
type IncidentDetail struct {
66
IncidentName string `json:"incidentName,omitempty"`
7-
IncidentID any `json:"incidentId,omitempty"`
7+
IncidentID string `json:"incidentId,omitempty"`
88
CreationDate string `json:"creationDate,omitempty"`
99
CreatedBy string `json:"createdBy,omitempty"`
1010
Source string `json:"source,omitempty"`
@@ -199,26 +199,6 @@ const (
199199
AlertStatusMerged AlertStatus = "Merged"
200200
)
201201

202-
// StatusFromCode maps the numeric status the HTTP API and the incidents module
203-
// still speak onto the stored value. It is a boundary shim and nothing below it
204-
// knows the codes: they are a contract with callers, not a model.
205-
func StatusFromCode(code int) AlertStatus {
206-
switch code {
207-
case 0:
208-
return AlertStatusMerged
209-
case 1:
210-
return AlertStatusAutomaticReview
211-
case 2:
212-
return AlertStatusOpen
213-
case 3:
214-
return AlertStatusInReview
215-
case 5:
216-
return AlertStatusCompleted
217-
default:
218-
return ""
219-
}
220-
}
221-
222202
func IsValid(s AlertStatus) bool {
223203
switch s {
224204
case AlertStatusAutomaticReview, AlertStatusOpen, AlertStatusInReview,

‎backend/modules/alerts/dto/alert.go‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package dto
22

3+
import "github.com/utmstack/utmstack/backend/modules/alerts/domain"
4+
35
type UpdateAlertStatusRequest struct {
4-
AlertIDs []string `json:"alertIds" binding:"required"`
5-
Status int `json:"status" binding:"required"`
6-
StatusObservation string `json:"statusObservation"`
7-
AddFalsePositiveTag bool `json:"addFalsePositiveTag"`
6+
AlertIDs []string `json:"alertIds" binding:"required"`
7+
Status domain.AlertStatus `json:"status" binding:"required"`
8+
StatusObservation string `json:"statusObservation"`
9+
AddFalsePositiveTag bool `json:"addFalsePositiveTag"`
810
}
911

1012
type UpdateAlertTagsRequest struct {
@@ -21,10 +23,14 @@ type UpdateAlertAssigneeRequest struct {
2123
type ConvertToIncidentRequest struct {
2224
AlertIDs []string `json:"eventIds" binding:"required"`
2325
IncidentName string `json:"incidentName" binding:"required"`
24-
IncidentID int `json:"incidentId" binding:"required"`
26+
IncidentID string `json:"incidentId" binding:"required"`
2527
IncidentSource string `json:"incidentSource"`
2628
}
2729

2830
type CountOpenAlertsResponse struct {
2931
Count int64 `json:"count"`
3032
}
33+
34+
type NotifyAlertRequest struct {
35+
AlertID string `json:"alertId" binding:"required"`
36+
}

‎backend/modules/alerts/handler/alerts.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
package handler
22

33
import (
4+
"context"
45
"net/http"
6+
"time"
57

68
"github.com/gin-gonic/gin"
9+
"github.com/threatwinds/go-sdk/catcher"
710
"github.com/utmstack/utmstack/backend/modules/alerts/connectors"
811
"github.com/utmstack/utmstack/backend/modules/alerts/dto"
912
"github.com/utmstack/utmstack/backend/modules/audit"
1013
audit_connectors "github.com/utmstack/utmstack/backend/modules/audit/connectors"
1114
audit_domain "github.com/utmstack/utmstack/backend/modules/audit/domain"
15+
"github.com/utmstack/utmstack/backend/pkg/authz"
1216
)
1317

18+
const notifyTimeout = 30 * time.Second
19+
1420
type AlertHandler struct {
1521
usecase connectors.AlertUsecase
1622
}
@@ -219,3 +225,37 @@ func (h *AlertHandler) ListEchoes(c *gin.Context) {
219225
}
220226
writePagedArray(c, items, total)
221227
}
228+
229+
// NotifyRaised godoc
230+
// @Summary Notify that an alert was raised (internal)
231+
// @Description Called by the alerts plugin after it writes an alert. Emails the
232+
// @Description configured recipients. Echoes of a grouped alert are skipped.
233+
// @Tags Alerts
234+
// @Security BearerAuth
235+
// @Accept json
236+
// @Produce json
237+
// @Param input body dto.NotifyAlertRequest true "Alert id"
238+
// @Success 202 "Accepted"
239+
// @Failure 400 {object} map[string]string
240+
// @Router /internal/alerts/notify [post]
241+
func (h *AlertHandler) NotifyRaised(c *gin.Context) {
242+
var req dto.NotifyAlertRequest
243+
if err := c.ShouldBindJSON(&req); err != nil {
244+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
245+
return
246+
}
247+
248+
tenant := authz.TenantIDFromContext(c.Request.Context())
249+
go func() {
250+
ctx, cancel := context.WithTimeout(authz.WithTenantID(context.Background(), tenant), notifyTimeout)
251+
defer cancel()
252+
if err := h.usecase.NotifyRaised(ctx, req.AlertID); err != nil {
253+
catcher.Warn("alerts: notification failed", map[string]any{
254+
"error": err.Error(),
255+
"alertId": req.AlertID,
256+
})
257+
}
258+
}()
259+
260+
c.Status(http.StatusAccepted)
261+
}

‎backend/modules/alerts/mailer.go‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package alerts
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"html"
7+
"strings"
8+
9+
mail_connectors "github.com/utmstack/utmstack/backend/internal/mail/connectors"
10+
"github.com/utmstack/utmstack/backend/modules/alerts/connectors"
11+
"github.com/utmstack/utmstack/backend/modules/alerts/domain"
12+
appconfig_connectors "github.com/utmstack/utmstack/backend/modules/appconfig/connectors"
13+
)
14+
15+
const (
16+
ConfigKeyNotificationTo = "utmstack.alerts.notification_to"
17+
ConfigKeyNotificationCc = "utmstack.alerts.notification_cc"
18+
)
19+
20+
const (
21+
ConfigKeyBaseURL = "utmstack.mail.baseUrl"
22+
ConfigKeyOrganization = "utmstack.mail.organization"
23+
)
24+
25+
type alertMailer struct {
26+
mail mail_connectors.MailService
27+
store appconfig_connectors.Store
28+
}
29+
30+
func NewAlertMailer(mail mail_connectors.MailService, store appconfig_connectors.Store) connectors.AlertMailer {
31+
return &alertMailer{mail: mail, store: store}
32+
}
33+
34+
func (m *alertMailer) SendAlertRaised(ctx context.Context, alert domain.UtmAlert) error {
35+
if m.mail == nil {
36+
return nil
37+
}
38+
to := readList(ctx, m.store, ConfigKeyNotificationTo)
39+
if len(to) == 0 {
40+
return nil
41+
}
42+
cc := readList(ctx, m.store, ConfigKeyNotificationCc)
43+
44+
subject, body := m.render(ctx, alert)
45+
return m.mail.SendMail(ctx, to, cc, subject, body, nil)
46+
}
47+
48+
func (m *alertMailer) render(ctx context.Context, a domain.UtmAlert) (subject, body string) {
49+
org := readString(ctx, m.store, ConfigKeyOrganization)
50+
subject = fmt.Sprintf("[%s] %s", shortID(a.ID), a.Name)
51+
if org != "" {
52+
subject = org + " " + subject
53+
}
54+
55+
rows := [][2]string{
56+
{"Severity", string(a.Severity)},
57+
{"Category", a.Category},
58+
{"Technique", a.Technique},
59+
{"Data source", a.DataSource},
60+
{"Data type", a.DataType},
61+
{"When", a.Timestamp},
62+
}
63+
if a.Adversary != nil && a.Adversary.Host != "" {
64+
rows = append(rows, [2]string{"Adversary", a.Adversary.Host})
65+
}
66+
if a.Target != nil && a.Target.Host != "" {
67+
rows = append(rows, [2]string{"Target", a.Target.Host})
68+
}
69+
70+
var b strings.Builder
71+
b.WriteString("<html><body>")
72+
fmt.Fprintf(&b, "<h2>%s</h2>", html.EscapeString(a.Name))
73+
if a.Description != "" {
74+
fmt.Fprintf(&b, "<p>%s</p>", html.EscapeString(a.Description))
75+
}
76+
b.WriteString("<table cellpadding=\"4\">")
77+
for _, r := range rows {
78+
if r[1] == "" {
79+
continue
80+
}
81+
fmt.Fprintf(&b, "<tr><td><strong>%s</strong></td><td>%s</td></tr>",
82+
html.EscapeString(r[0]), html.EscapeString(r[1]))
83+
}
84+
b.WriteString("</table>")
85+
86+
if base := strings.TrimRight(readString(ctx, m.store, ConfigKeyBaseURL), "/"); base != "" {
87+
link := fmt.Sprintf("%s/threat-management/alerts?alertId=%s", base, a.ID)
88+
fmt.Fprintf(&b, "<p><a href=\"%s\">Open in UTMStack</a></p>", html.EscapeString(link))
89+
}
90+
b.WriteString("</body></html>")
91+
92+
return subject, b.String()
93+
}
94+
95+
func shortID(id string) string {
96+
if len(id) > 8 {
97+
return id[:8]
98+
}
99+
return id
100+
}
101+
102+
func readString(ctx context.Context, store appconfig_connectors.Store, key string) string {
103+
if store == nil {
104+
return ""
105+
}
106+
v, ok, err := store.GetString(ctx, key)
107+
if err != nil || !ok {
108+
return ""
109+
}
110+
return strings.TrimSpace(v)
111+
}
112+
113+
func readList(ctx context.Context, store appconfig_connectors.Store, key string) []string {
114+
v := readString(ctx, store, key)
115+
if v == "" {
116+
return nil
117+
}
118+
parts := strings.Split(v, ",")
119+
out := make([]string, 0, len(parts))
120+
for _, p := range parts {
121+
if p = strings.TrimSpace(p); p != "" {
122+
out = append(out, p)
123+
}
124+
}
125+
return out
126+
}

‎backend/modules/alerts/module.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ type Module struct {
2020
adversaryUsecase connectors.AdversaryUsecase
2121
}
2222

23-
func NewModule(db *gorm.DB, events *eventstore.Store) *Module {
23+
func NewModule(db *gorm.DB, events *eventstore.Store, mailer connectors.AlertMailer) *Module {
2424
alertRepo := repository.NewCHAlertRepository(events, db)
2525

26-
alertUC := usecase.NewAlertUsecase(alertRepo)
26+
alertUC := usecase.NewAlertUsecase(alertRepo, mailer)
2727
alertH := handler.NewAlertHandler(alertUC)
2828

2929
alertTagRepo := repository.NewAlertTagRepository(db)

0 commit comments

Comments
 (0)