Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,15 @@ jobs:
# GOACORE_REQUIRE_DB_TESTS : transforme un DSN manquant en ÉCHEC au lieu d'un
# skip. Sans ce verrou, retirer le service MySQL ci-dessus repasserait la CI
# au vert avec zéro test exécuté — exactement l'angle mort qu'on répare ici.
# SOAR_TEST_DSN active les tests DB du SOAR (persistance anti-rejeu + store
# de triage, dans services/ et workers/) sur la même base jetable — sans lui
# ils restent skippés et la CI ne les exécuterait jamais.
- name: Migrations sur une base MySQL réelle
env:
GOACORE_TEST_DSN: root:goacore-ci@tcp(127.0.0.1:3306)/goacore_test?parseTime=true
SOAR_TEST_DSN: root:goacore-ci@tcp(127.0.0.1:3306)/goacore_test?parseTime=true
GOACORE_REQUIRE_DB_TESTS: "1"
run: go test ./internal/database/ -race -count=1 -v
run: go test ./internal/database/ ./internal/services/ ./internal/workers/ -race -count=1 -v

# The goabackup SSH helper is the AUTHORITATIVE security boundary on the Proxmox
# host (forced-command, closed op set, the readonly 9500-9599 sandbox VMID guard).
Expand Down
7 changes: 6 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,16 @@ func main() {
SameSite: http.SameSiteStrictMode,
}

// SOAR triage store: backs the alert triage buttons of every Discord bot
// (env seed here, hot-reloads via the registry) and the worker's suppression.
triageStore := services.NewTriageStore(db)

// Discord (env seed). The bot is published into the registry below; at shutdown we
// close the CURRENT registry bot (which may have been hot-reloaded), not this boot
// pointer — so the defer is registered after the registry is built (see below).
var discordBot *services.DiscordBot
if cfg.DiscordBotToken != "" && cfg.DiscordChannelID != "" {
bot, botErr := services.NewDiscordBot(cfg.DiscordBotToken, cfg.DiscordChannelID, cfg.DiscordAuthChannel, cfg.DiscordAnsibleChannel)
bot, botErr := services.NewDiscordBot(cfg.DiscordBotToken, cfg.DiscordChannelID, cfg.DiscordAuthChannel, cfg.DiscordAnsibleChannel, triageStore)
if botErr != nil {
slog.Error("Failed to init Discord Bot", "error", botErr)
} else {
Expand All @@ -226,6 +230,7 @@ func main() {
// worker starts), freezing each service's env snapshot for rollback. The DB rows
// then override the seeds via reloadServicesFromDB, exactly like Proxmox.
registry := services.NewServiceRegistry(cfg.SkipTLSVerify)
registry.SetTriageStore(triageStore)
registry.SeedWazuh(wazuhClient, cfg.WazuhAPIURL, cfg.WazuhUser, cfg.WazuhPassword)
registry.SeedIndexer(wazuhIndexer, cfg.WazuhIndexerURL, cfg.WazuhIndexerUser, cfg.WazuhIndexerPass)
registry.SeedAI(aiClient, cfg.AIProvider, cfg.AIURL, cfg.AIAPIKey, cfg.AIModel, cfg.OpenAIBaseURL)
Expand Down
16 changes: 16 additions & 0 deletions internal/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ func Migrate(db *sql.DB) error {
seen_at BIGINT NOT NULL,
INDEX idx_seen_at (seen_at)
)`,
`CREATE TABLE IF NOT EXISTS soar_triage (
fingerprint CHAR(64) PRIMARY KEY,
status ENUM('open','by_design','false_positive','investigating','resolved') NOT NULL DEFAULT 'open',
rule_id VARCHAR(32) NOT NULL DEFAULT '',
agent_name VARCHAR(191) NOT NULL DEFAULT '',
title VARCHAR(191) NOT NULL DEFAULT '',
sample_alert JSON NULL,
count_suppressed INT NOT NULL DEFAULT 0,
suppressed_since_digest INT NOT NULL DEFAULT 0,
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
decided_at DATETIME NULL,
decided_by VARCHAR(191) NOT NULL DEFAULT '',
decided_by_id VARCHAR(32) NOT NULL DEFAULT '',
INDEX idx_triage_status (status)
)`,
`CREATE TABLE IF NOT EXISTS audit_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
Expand Down
6 changes: 4 additions & 2 deletions internal/handlers/soar.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ func (h *Handler) sendEnrichedDiscordAlert(reqCtx context.Context, alertCtx serv
// Post first, enrich after: même contrat que le worker (l'alerte part tout
// de suite, l'analyse IA arrive par édition du message), sans le sémaphore
// pour les raisons documentées ci-dessus.
messageID, err := discord.SendSoarAlert(alertCtx.Title, msg, severity)
// Empreinte vide : une alerte de test ne doit pas entrer dans le triage
// apprenant (pas de boutons, pas de row soar_triage).
messageID, err := discord.SendSoarAlert(alertCtx.Title, msg, severity, "")
if err != nil {
slog.Error("Discord SOAR alert failed", "error", err)
return
Expand All @@ -190,7 +192,7 @@ func (h *Handler) sendEnrichedDiscordAlert(reqCtx context.Context, alertCtx serv
slog.Error("AI Enrichment Failed", "error", err)
return
}
if err := discord.EditSoarAlertAnalysis(messageID, alertCtx.Title, msg, severity, analysis); err != nil {
if err := discord.EditSoarAlertAnalysis(messageID, alertCtx.Title, msg, severity, analysis, ""); err != nil {
slog.Error("Discord SOAR alert enrichment edit failed", "error", err, "message_id", messageID)
}
}
Expand Down
115 changes: 83 additions & 32 deletions internal/services/discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,16 @@ type DiscordBot struct {
channelID string
authChannelID string
ansibleChannelID string
// triage is the SOAR triage store backing the alert buttons (nil = triage off:
// no inbound handler is registered and alerts ship without buttons).
triage *TriageStore
}

// NewDiscordBot creates and opens a new Discord bot session.
func NewDiscordBot(token, channelID, authChannelID, ansibleChannelID string) (*DiscordBot, error) {
// NewDiscordBot creates and opens a new Discord bot session. triage may be nil
// (tests, DB-less paths): the bot is then send-only, exactly the pre-triage
// behaviour. When non-nil, the SOAR triage InteractionCreate handler is attached
// BEFORE Open so no early click can slip past it.
func NewDiscordBot(token, channelID, authChannelID, ansibleChannelID string, triage *TriageStore) (*DiscordBot, error) {
if token == "" || channelID == "" {
return nil, fmt.Errorf("missing token or channel ID")
}
Expand All @@ -46,18 +52,24 @@ func NewDiscordBot(token, channelID, authChannelID, ansibleChannelID string) (*D
return nil, err
}

if err := session.Open(); err != nil {
return nil, fmt.Errorf("error opening connection: %v", err)
}

slog.Info("Discord Bot is now running", "channel", channelID, "auth_channel", authChannelID, "ansible_channel", ansibleChannelID)
return &DiscordBot{
bot := &DiscordBot{
session: session,
token: token,
channelID: channelID,
authChannelID: authChannelID,
ansibleChannelID: ansibleChannelID,
}, nil
triage: triage,
}
if triage != nil {
session.AddHandler(bot.handleTriageInteraction)
}

if err := session.Open(); err != nil {
return nil, fmt.Errorf("error opening connection: %v", err)
}

slog.Info("Discord Bot is now running", "channel", channelID, "auth_channel", authChannelID, "ansible_channel", ansibleChannelID)
return bot, nil
}

// Close closes the Discord session.
Expand Down Expand Up @@ -95,50 +107,91 @@ func truncateRunes(s string, max int) string {
return string(r[:max]) + "…"
}

// soarAlertView is the render input of a SOAR alert message: the alert text plus
// its triage state. Fingerprint == "" → alerte sans triage (ex. alerte de test),
// rendue exactement comme avant l'arrivée du triage.
type soarAlertView struct {
Title, Message, Severity, Analysis string
Fingerprint string
TriageStatus string // "" ou "open" → non classée
DecidedBy string
}

// soarAlertComponents builds the Components V2 payload of a SOAR alert.
// analysis == "" → alerte brute (post immédiat) ; non vide → version enrichie
// v.Analysis == "" → alerte brute (post immédiat) ; non vide → version enrichie
// (utilisée par l'édition du même message une fois l'analyse IA disponible).
// Les entrées doivent déjà être passées par neutralizeDiscord.
func soarAlertComponents(title, message, severity, analysis string) []discordgo.MessageComponent {
color := severityColor(severity)
// Title/Message/Analysis doivent déjà être passés par neutralizeDiscord.
func soarAlertComponents(v soarAlertView) []discordgo.MessageComponent {
color := severityColor(v.Severity)
if c, ok := triageStatusColor(v.TriageStatus); ok {
color = c
}
divider := true

inner := []discordgo.MessageComponent{
discordgo.TextDisplay{Content: "## 🛡️ SOAR Alert: " + truncateRunes(title, 150)},
discordgo.TextDisplay{Content: truncateRunes(message, 1500)},
discordgo.TextDisplay{Content: "## 🛡️ SOAR Alert: " + truncateRunes(v.Title, 150)},
discordgo.TextDisplay{Content: truncateRunes(v.Message, 1500)},
}
if analysis != "" {
if v.Analysis != "" {
inner = append(inner,
discordgo.Separator{Divider: &divider},
discordgo.TextDisplay{Content: "🤖 **Analyse IA :**\n" + truncateRunes(analysis, 1800)},
discordgo.TextDisplay{Content: "🤖 **Analyse IA :**\n" + truncateRunes(v.Analysis, 1800)},
)
}
inner = append(inner,
discordgo.Separator{Divider: &divider},
discordgo.TextDisplay{Content: "-# GoaCore Security"},
)
if v.Fingerprint != "" {
if label := triageStatusLabel(v.TriageStatus, v.DecidedBy); label != "" {
inner = append(inner, discordgo.TextDisplay{Content: label})
}
inner = append(inner, triageButtonsRow(v.Fingerprint, v.TriageStatus))
}

return []discordgo.MessageComponent{
discordgo.Container{AccentColor: &color, Components: inner},
}
}

// soarAlertViewFor assembles the render view of an alert: neutralizes the
// untrusted text and reads the live triage decision (label « classée par X » et
// couleur) — utile quand un clic de triage est arrivé entre le post et l'édition
// d'enrichissement, pour ne pas effacer le classement au re-rendu. Une erreur de
// lecture triage dégrade en « non classée », jamais en échec d'envoi.
func (d *DiscordBot) soarAlertViewFor(title, message, severity, analysis, fingerprint string) soarAlertView {
v := soarAlertView{
Title: neutralizeDiscord(title),
Message: neutralizeDiscord(message),
Severity: severity,
Analysis: neutralizeDiscord(analysis),
Fingerprint: fingerprint,
}
if fingerprint != "" && d.triage != nil {
status, decidedBy, err := d.triage.Decision(fingerprint)
if err != nil {
slog.Error("SOAR triage: lecture du classement impossible, rendu non classé", "error", err)
} else {
v.TriageStatus = status
v.DecidedBy = decidedBy
}
}
return v
}

// SendSoarAlert posts a SOAR alert (Components V2) to the main channel and
// returns the Discord message ID, so the caller can enrich the SAME message
// later via EditSoarAlertAnalysis. The alert is sent raw (no AI analysis):
// posting first guarantees the notification latency never depends on the LLM.
func (d *DiscordBot) SendSoarAlert(title, message, severity string) (string, error) {
// fingerprint est l'empreinte de triage ("" = pas de boutons de triage).
func (d *DiscordBot) SendSoarAlert(title, message, severity, fingerprint string) (string, error) {
if d == nil || d.session == nil {
return "", fmt.Errorf("discord session not initialized")
}
// title/message proviennent de Wazuh (non fiables) → neutraliser les
// mentions/markdown avant de les embarquer dans le message.
title = neutralizeDiscord(title)
message = neutralizeDiscord(message)

// title/message proviennent de Wazuh (non fiables) → neutralisés dans la vue.
m, err := d.session.ChannelMessageSendComplex(d.channelID, &discordgo.MessageSend{
Flags: discordgo.MessageFlagsIsComponentsV2,
Components: soarAlertComponents(title, message, severity, ""),
Components: soarAlertComponents(d.soarAlertViewFor(title, message, severity, "", fingerprint)),
})
if err != nil {
return "", err
Expand All @@ -147,17 +200,15 @@ func (d *DiscordBot) SendSoarAlert(title, message, severity string) (string, err
}

// EditSoarAlertAnalysis rewrites a previously posted SOAR alert to append the
// AI analysis section. title/message/severity must be the same values passed to
// SendSoarAlert (a Components V2 edit replaces the whole components tree).
func (d *DiscordBot) EditSoarAlertAnalysis(messageID, title, message, severity, analysis string) error {
// AI analysis section. title/message/severity/fingerprint must be the same values
// passed to SendSoarAlert (a Components V2 edit replaces the whole components
// tree) ; le statut de triage est relu au moment de l'édition pour préserver un
// classement cliqué pendant l'enrichissement.
func (d *DiscordBot) EditSoarAlertAnalysis(messageID, title, message, severity, analysis, fingerprint string) error {
if d == nil || d.session == nil {
return fmt.Errorf("discord session not initialized")
}
title = neutralizeDiscord(title)
message = neutralizeDiscord(message)
analysis = neutralizeDiscord(analysis)

components := soarAlertComponents(title, message, severity, analysis)
components := soarAlertComponents(d.soarAlertViewFor(title, message, severity, analysis, fingerprint))
_, err := d.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: d.channelID,
ID: messageID,
Expand Down
Loading