From 554e3209b0f77c3c4c4e5d72eafd561d1403ef89 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 25 Aug 2026 09:05:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(soar):=20triage=20apprenant=20des=20alerte?= =?UTF-8?q?s=20=E2=80=94=20empreintes,=20boutons=20Discord,=20suppression,?= =?UTF-8?q?=20digest=20hebdo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classement Wiz-like des alertes SOAR depuis Discord : - Empreinte sha256(rule.id|agent|champ-clé) par famille de règle : FIM sur le chemin, auth sur le compte visé, sudo sur le compte qui élève (srcuser), repli sur la description normalisée. Table soar_triage (Migrate + schema.sql). - Quatre boutons Components V2 sur chaque alerte (By design / Faux positif / J'enquête / Résolu), custom_id triage:: validé côté serveur. Handler InteractionCreate idempotent (hot-reload double-session sans danger), réservé aux membres portant « Gérer les messages » sur le canal d'alertes, traçabilité par ID Discord immuable (decided_by_id). - Suppression AVANT post des empreintes by_design/false_positive — comptée, jamais silencieuse : digest hebdomadaire des suppressions (top 10 + agrégat + total vie-entière), ancre persistée en soar_state, retenté tant que Discord est indisponible, reset soustractif des compteurs (une suppression concurrente survit au reset). investigating/resolved continuent de poster ; une récidive après résolution rouvre l'empreinte. - Au clic : ack différé puis re-rendu depuis l'état frais du message et la dernière décision en DB — un clic concurrent ou une édition d'enrichissement IA ne sont jamais écrasés. Fail-open sur toute erreur DB : une alerte de sécurité part toujours. - CI : tests SOAR/triage MySQL (SOAR_TEST_DSN) exécutés dans le job db-tests. --- .github/workflows/ci-deploy.yml | 6 +- cmd/server/main.go | 7 +- internal/database/database.go | 16 + internal/handlers/soar.go | 6 +- internal/services/discord.go | 115 +++++-- internal/services/discord_triage.go | 369 ++++++++++++++++++++++ internal/services/discord_triage_test.go | 300 ++++++++++++++++++ internal/services/registry.go | 23 +- internal/services/triage.go | 292 +++++++++++++++++ internal/services/triage_store_test.go | 201 ++++++++++++ internal/services/triage_test.go | 226 +++++++++++++ internal/services/wazuh_indexer.go | 1 + internal/workers/soar.go | 216 +++++++++++-- internal/workers/soar_atleastonce_test.go | 4 +- internal/workers/soar_cursor_test.go | 1 + internal/workers/soar_triage_test.go | 206 ++++++++++++ schema.sql | 22 ++ 17 files changed, 1935 insertions(+), 76 deletions(-) create mode 100644 internal/services/discord_triage.go create mode 100644 internal/services/discord_triage_test.go create mode 100644 internal/services/triage.go create mode 100644 internal/services/triage_store_test.go create mode 100644 internal/services/triage_test.go create mode 100644 internal/workers/soar_triage_test.go diff --git a/.github/workflows/ci-deploy.yml b/.github/workflows/ci-deploy.yml index 9212261..76c2fb9 100644 --- a/.github/workflows/ci-deploy.yml +++ b/.github/workflows/ci-deploy.yml @@ -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). diff --git a/cmd/server/main.go b/cmd/server/main.go index 61e3af7..6fd3767 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 { @@ -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) diff --git a/internal/database/database.go b/internal/database/database.go index 633aa1a..c4f9d58 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -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, diff --git a/internal/handlers/soar.go b/internal/handlers/soar.go index a7b5650..47d9e3c 100644 --- a/internal/handlers/soar.go +++ b/internal/handlers/soar.go @@ -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 @@ -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) } } diff --git a/internal/services/discord.go b/internal/services/discord.go index f273900..2fc5dfb 100644 --- a/internal/services/discord.go +++ b/internal/services/discord.go @@ -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") } @@ -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. @@ -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: ÷r}, - 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: ÷r}, 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 @@ -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, diff --git a/internal/services/discord_triage.go b/internal/services/discord_triage.go new file mode 100644 index 0000000..07ccea7 --- /dev/null +++ b/internal/services/discord_triage.go @@ -0,0 +1,369 @@ +package services + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/bwmarrin/discordgo" +) + +// Triage SOAR côté Discord : chaque alerte porte quatre boutons (By design / +// Faux positif / J'enquête / Résolu). Un clic enregistre le verdict en DB +// (TriageStore.SetDecision) puis ré-édite le message cliqué via la réponse +// d'interaction UpdateMessage : Container recoloré, étiquette de statut, boutons +// reconstruits avec le choix marqué. L'arbre Components V2 étant remplacé en +// entier à chaque édition, la reconstruction part des composants du message reçu +// dans l'interaction — jamais d'état à persister côté serveur pour re-rendre. + +const ( + // triageCustomIDPrefix préfixe les custom_id des boutons : "triage::". + triageCustomIDPrefix = "triage:" + // triageLabelPrefix ouvre la ligne d'étiquette de statut dans le message ; il + // sert aussi de marqueur pour la retrouver (et la remplacer) au clic suivant. + triageLabelPrefix = "-# 🏷️ " +) + +// triageCustomID builds the custom_id of one triage button. 22 chars de préfixe + +// statut + 64 hex restent sous la limite Discord de 100. +func triageCustomID(status, fingerprint string) string { + return triageCustomIDPrefix + status + ":" + fingerprint +} + +// parseTriageCustomID splits "triage::". Le custom_id +// revient du client Discord (entrée non fiable) : tout id qui n'est pas +// exactement un verdict connu portant une empreinte plausible est rejeté. +func parseTriageCustomID(id string) (status, fingerprint string, ok bool) { + rest, found := strings.CutPrefix(id, triageCustomIDPrefix) + if !found { + return "", "", false + } + status, fingerprint, found = strings.Cut(rest, ":") + if !found || !validTriageDecision(status) || !isTriageFingerprint(fingerprint) { + return "", "", false + } + return status, fingerprint, true +} + +// triageStatusName is the operator-facing French name of a triage status. +func triageStatusName(status string) string { + switch status { + case TriageByDesign: + return "By design" + case TriageFalsePositive: + return "Faux positif" + case TriageInvestigating: + return "Enquête en cours" + case TriageResolved: + return "Résolue" + } + return "" +} + +// triageStatusLabel renders the status line shown under a classified alert +// ("" pour une alerte non classée). decidedBy est un username Discord (non +// fiable) → neutralisé ici, l'unique point de rendu. +func triageStatusLabel(status, decidedBy string) string { + name := triageStatusName(status) + if name == "" { + return "" + } + if decidedBy != "" { + return triageLabelPrefix + name + " — classée par " + truncateRunes(neutralizeDiscord(decidedBy), 80) + } + return triageLabelPrefix + name +} + +// triageStatusColor returns the Container accent color of a classified alert. +// ok=false pour "open"/"" : la couleur de sévérité d'origine reste. +func triageStatusColor(status string) (int, bool) { + switch status { + case TriageByDesign, TriageFalsePositive: + return 0x808080, true // gris : bruit classé, volontairement éteint + case TriageInvestigating: + return 0x3498db, true // bleu : pris en charge + case TriageResolved: + return 0x2ecc71, true // vert : traité + } + return 0, false +} + +// triageButtonsRow builds the four triage buttons of an alert. selected marque +// le verdict courant (style Primary) ; tous les boutons restent actifs pour +// permettre de corriger un clic. +func triageButtonsRow(fingerprint, selected string) discordgo.ActionsRow { + mk := func(label, status string) discordgo.Button { + style := discordgo.SecondaryButton + if status == selected { + style = discordgo.PrimaryButton + } + return discordgo.Button{Label: label, Style: style, CustomID: triageCustomID(status, fingerprint)} + } + return discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + mk("By design", TriageByDesign), + mk("Faux positif", TriageFalsePositive), + mk("J'enquête", TriageInvestigating), + mk("Résolu", TriageResolved), + }} +} + +// asContainer normalizes the two shapes a Container takes: value when built +// locally, pointer when unmarshaled from a gateway payload (discordgo produces +// pointers). Returns a copy, nil si le composant n'est pas un Container. +func asContainer(c discordgo.MessageComponent) *discordgo.Container { + switch v := c.(type) { + case discordgo.Container: + cc := v + return &cc + case *discordgo.Container: + cc := *v + return &cc + } + return nil +} + +// isActionsRow matches both the value and pointer shapes of an ActionsRow. +func isActionsRow(c discordgo.MessageComponent) bool { + switch c.(type) { + case discordgo.ActionsRow, *discordgo.ActionsRow: + return true + } + return false +} + +// isTriageLabel reports whether a component is the triage status line. +func isTriageLabel(c discordgo.MessageComponent) bool { + switch v := c.(type) { + case discordgo.TextDisplay: + return strings.HasPrefix(v.Content, triageLabelPrefix) + case *discordgo.TextDisplay: + return strings.HasPrefix(v.Content, triageLabelPrefix) + } + return false +} + +// retagSoarComponents rebuilds an alert's component tree after a triage click: +// Container recoloré, étiquette de statut remplacée, boutons reconstruits avec +// le verdict marqué. Tout le reste (titre, message, analyse IA, séparateurs) est +// conservé tel quel — c'est ce qui rend l'opération idempotente et sûre quel que +// soit l'état du message (brut ou déjà enrichi, déjà classé ou non). +func retagSoarComponents(existing []discordgo.MessageComponent, status, decidedBy, fingerprint string) []discordgo.MessageComponent { + out := make([]discordgo.MessageComponent, 0, len(existing)) + for _, c := range existing { + cont := asContainer(c) + if cont == nil { + out = append(out, c) + continue + } + inner := make([]discordgo.MessageComponent, 0, len(cont.Components)+2) + for _, ic := range cont.Components { + if isActionsRow(ic) || isTriageLabel(ic) { + continue + } + inner = append(inner, ic) + } + if label := triageStatusLabel(status, decidedBy); label != "" { + inner = append(inner, discordgo.TextDisplay{Content: label}) + } + inner = append(inner, triageButtonsRow(fingerprint, status)) + + color := cont.AccentColor + if c2, ok := triageStatusColor(status); ok { + color = &c2 + } + out = append(out, discordgo.Container{AccentColor: color, Spoiler: cont.Spoiler, Components: inner}) + } + return out +} + +// interactionUser extracts the clicking user (guild member en salon, user en +// DM). Le username est une entrée non fiable, neutralisée au rendu ; l'ID est +// l'identifiant Discord immuable, gardé pour la traçabilité (un username se +// change à volonté). +func interactionUser(i *discordgo.InteractionCreate) (username, id string) { + if i.Member != nil && i.Member.User != nil { + return i.Member.User.Username, i.Member.User.ID + } + if i.User != nil { + return i.User.Username, i.User.ID + } + return "", "" +} + +// canTriage dit si le cliqueur a le droit de classer des alertes. Un verdict +// « by design » ÉTEINT des alertes de sécurité : le simple accès en lecture au +// canal ne suffit pas. Barre retenue : la permission « Gérer les messages » sur +// le canal d'alertes (le payload d'interaction porte les permissions calculées +// du membre dans le canal) — le modérateur du canal est l'opérateur. En DM +// (pas de Member), refus. +func canTriage(i *discordgo.InteractionCreate) bool { + return i.Member != nil && i.Member.Permissions&discordgo.PermissionManageMessages != 0 +} + +// respondEphemeral répond au clic par un message visible du seul cliqueur. +func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + slog.Error("SOAR triage: réponse éphémère impossible", "error", err) + } +} + +// handleTriageInteraction is the InteractionCreate handler of the triage buttons. +// Il est IDEMPOTENT par construction : l'upsert DB ré-applique le même verdict et +// Discord n'accepte qu'UNE réponse par interaction — c'est ce qui rend inoffensive +// la double-session transitoire d'un hot-reload ApplyDiscord (les deux sessions +// reçoivent l'événement, une seule ack gagne, l'autre abandonne proprement). +// +// Après l'ack, le message est re-rendu depuis son état FRAIS (re-fetch) et la +// DERNIÈRE décision en DB — deux clics rapprochés ou une édition d'enrichissement +// concurrente convergent ainsi vers l'état réel au lieu de figer le snapshot du +// clic (l'analyse IA arrivée entre-temps est préservée). +func (d *DiscordBot) handleTriageInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Type != discordgo.InteractionMessageComponent || d == nil || d.triage == nil { + return + } + status, fingerprint, ok := parseTriageCustomID(i.MessageComponentData().CustomID) + if !ok { + return + } + // Défense en profondeur : les boutons ne vivent que sur le canal d'alertes. + if i.ChannelID != d.channelID { + return + } + user, userID := interactionUser(i) + if !canTriage(i) { + slog.Warn("SOAR triage: clic refusé (permission insuffisante)", "user_id", userID, "status", status) + respondEphemeral(s, i, "⛔ Classement réservé aux opérateurs (permission « Gérer les messages » sur ce canal).") + return + } + + if err := d.triage.SetDecision(fingerprint, status, user, userID); err != nil { + slog.Error("SOAR triage: enregistrement du verdict impossible", "error", err, "status", status) + // Prévenir le cliqueur plutôt que d'échouer en silence : le bouton n'a + // PAS pris effet, la suppression n'aura pas lieu. + respondEphemeral(s, i, "⚠️ Classement non enregistré (erreur base de données), réessaie.") + return + } + slog.Info("SOAR triage: verdict enregistré", "status", status, "fingerprint", fingerprint, "user", user, "user_id", userID) + + if i.Message == nil { + return + } + // Ack différé : si elle échoue, l'autre session du hot-reload a déjà pris + // l'interaction (le verdict EST enregistré) — l'édition lui revient. + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredMessageUpdate, + }); err != nil { + slog.Warn("SOAR triage: interaction déjà prise en charge (hot-reload ?)", "error", err) + return + } + + // Converger vers la dernière décision (un clic concurrent a pu committer + // après le nôtre) et l'état frais du message (une édition d'enrichissement + // a pu remplacer l'arbre depuis le snapshot du clic). + renderStatus, renderBy := status, user + if st, by, err := d.triage.Decision(fingerprint); err == nil && st != "" { + renderStatus, renderBy = st, by + } + components := i.Message.Components + if fresh, err := s.ChannelMessage(i.ChannelID, i.Message.ID); err == nil && fresh != nil { + components = fresh.Components + } else if err != nil { + slog.Warn("SOAR triage: re-fetch du message impossible, rendu depuis le snapshot du clic", "error", err) + } + updated := retagSoarComponents(components, renderStatus, renderBy, fingerprint) + if _, err := s.ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: i.ChannelID, + ID: i.Message.ID, + Flags: discordgo.MessageFlagsIsComponentsV2, + Components: &updated, + }); err != nil { + slog.Warn("SOAR triage: édition du message d'alerte non appliquée", "error", err) + } +} + +// triageDigestComponents builds the weekly digest message (Components V2) from +// the suppression counters. rows est trié le plus supprimé d'abord ; les 10 +// premières empreintes sont détaillées, le reste est agrégé. Fonction pure, +// testable sans session. +func triageDigestComponents(rows []TriageDigestRow) []discordgo.MessageComponent { + const maxDetailed = 10 + divider := true + color := 0x808080 + + total := 0 + for _, r := range rows { + total += r.SinceDigest + } + + inner := []discordgo.MessageComponent{ + discordgo.TextDisplay{Content: "## 🔕 Digest triage SOAR — 7 derniers jours"}, + discordgo.TextDisplay{Content: fmt.Sprintf( + "**%d** alerte(s) supprimée(s) sur **%d** empreinte(s) classée(s). Rien n'est perdu : chaque ligne reste réactivable en reclassant l'alerte d'origine.", + total, len(rows))}, + discordgo.Separator{Divider: ÷r}, + } + + var b strings.Builder + for idx, r := range rows { + if idx >= maxDetailed { + break + } + title := r.Title + if title == "" { + title = "(sans titre)" + } + // Une row peut avoir été reclassée (voire rouverte par récidive) APRÈS + // ses suppressions : le compteur est un historique, le statut affiché + // est l'état courant. + statusName := triageStatusName(r.Status) + if statusName == "" { + statusName = "réouverte" + } + fmt.Fprintf(&b, "**%d×** %s — %s · règle %s · %s · %d au total\n", + r.SinceDigest, + truncateRunes(neutralizeDiscord(title), 80), + truncateRunes(neutralizeDiscord(r.AgentName), 40), + neutralizeDiscord(r.RuleID), + statusName, + r.TotalSuppressed) + } + if len(rows) > maxDetailed { + rest := 0 + for _, r := range rows[maxDetailed:] { + rest += r.SinceDigest + } + fmt.Fprintf(&b, "-# … et %d autre(s) empreinte(s), %d suppression(s)\n", len(rows)-maxDetailed, rest) + } + inner = append(inner, + discordgo.TextDisplay{Content: truncateRunes(strings.TrimRight(b.String(), "\n"), 1800)}, + discordgo.Separator{Divider: ÷r}, + discordgo.TextDisplay{Content: "-# GoaCore Security — triage apprenant"}, + ) + + return []discordgo.MessageComponent{ + discordgo.Container{AccentColor: &color, Components: inner}, + } +} + +// SendTriageDigest posts the weekly suppression digest to the main channel — +// le contrat « jamais de silence aveugle » : tout ce que le triage a éteint +// depuis le dernier digest redevient visible ici. +func (d *DiscordBot) SendTriageDigest(rows []TriageDigestRow) error { + if d == nil || d.session == nil { + return fmt.Errorf("discord session not initialized") + } + if len(rows) == 0 { + return nil + } + _, err := d.session.ChannelMessageSendComplex(d.channelID, &discordgo.MessageSend{ + Flags: discordgo.MessageFlagsIsComponentsV2, + Components: triageDigestComponents(rows), + }) + return err +} diff --git a/internal/services/discord_triage_test.go b/internal/services/discord_triage_test.go new file mode 100644 index 0000000..30c65e5 --- /dev/null +++ b/internal/services/discord_triage_test.go @@ -0,0 +1,300 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/bwmarrin/discordgo" +) + +const testFP = "abababababababababababababababababababababababababababababababab" + +// TestTriageCustomIDRoundTrip : le custom_id est le seul lien entre un clic et +// une empreinte — il doit survivre à l'aller-retour et rester sous la limite +// Discord de 100 caractères pour chacun des quatre verdicts. +func TestTriageCustomIDRoundTrip(t *testing.T) { + for _, status := range []string{TriageByDesign, TriageFalsePositive, TriageInvestigating, TriageResolved} { + id := triageCustomID(status, testFP) + if len(id) > 100 { + t.Errorf("custom_id %q dépasse la limite Discord de 100 caractères (%d)", id, len(id)) + } + gotStatus, gotFP, ok := parseTriageCustomID(id) + if !ok || gotStatus != status || gotFP != testFP { + t.Errorf("aller-retour cassé pour %q : (%q, %q, %v)", status, gotStatus, gotFP, ok) + } + } +} + +// TestParseTriageCustomIDRejects : le custom_id revient du client Discord +// (entrée non fiable) — tout id forgé ou étranger doit être ignoré sans effet. +func TestParseTriageCustomIDRejects(t *testing.T) { + for _, id := range []string{ + "", + "autre:by_design:" + testFP, // pas notre préfixe + "triage:" + testFP, // pas de statut + "triage:open:" + testFP, // open n'est pas un verdict + "triage:by_design:", // empreinte vide + "triage:by_design:" + testFP[:63], // empreinte trop courte + "triage:by_design:" + testFP + "a", // trop longue + "triage:BY_DESIGN:" + testFP, // statut inconnu (casse) + "triage:by_design:" + strings.ToUpper(testFP), // hex majuscule + } { + if _, _, ok := parseTriageCustomID(id); ok { + t.Errorf("custom_id forgé accepté : %q", id) + } + } +} + +// containerOf extrait le Container unique d'un arbre de composants d'alerte. +func containerOf(t *testing.T, comps []discordgo.MessageComponent) *discordgo.Container { + t.Helper() + if len(comps) != 1 { + t.Fatalf("attendu 1 composant racine, reçu %d", len(comps)) + } + c := asContainer(comps[0]) + if c == nil { + t.Fatalf("racine inattendue %T, attendu Container", comps[0]) + } + return c +} + +// buttonsOf extrait les boutons de l'unique ActionsRow d'un Container ("" si absente). +func buttonsOf(t *testing.T, cont *discordgo.Container) []discordgo.Button { + t.Helper() + var rows []discordgo.ActionsRow + for _, ic := range cont.Components { + switch v := ic.(type) { + case discordgo.ActionsRow: + rows = append(rows, v) + case *discordgo.ActionsRow: + rows = append(rows, *v) + } + } + if len(rows) == 0 { + return nil + } + if len(rows) > 1 { + t.Fatalf("attendu au plus 1 ActionsRow, reçu %d", len(rows)) + } + var out []discordgo.Button + for _, bc := range rows[0].Components { + switch b := bc.(type) { + case discordgo.Button: + out = append(out, b) + case *discordgo.Button: + out = append(out, *b) + } + } + return out +} + +// textContents liste les contenus des TextDisplay d'un Container. +func textContents(cont *discordgo.Container) []string { + var out []string + for _, ic := range cont.Components { + switch v := ic.(type) { + case discordgo.TextDisplay: + out = append(out, v.Content) + case *discordgo.TextDisplay: + out = append(out, v.Content) + } + } + return out +} + +// TestSoarAlertComponentsTriageButtons : une alerte avec empreinte porte les +// quatre boutons de triage aux custom_id attendus ; sans empreinte (alerte de +// test), l'arbre reste celui d'avant le triage — aucun bouton. +func TestSoarAlertComponentsTriageButtons(t *testing.T) { + withFP := soarAlertComponents(soarAlertView{ + Title: "Alerte", Message: "msg", Severity: "high", Fingerprint: testFP, + }) + buttons := buttonsOf(t, containerOf(t, withFP)) + if len(buttons) != 4 { + t.Fatalf("attendu 4 boutons, reçu %d", len(buttons)) + } + wantIDs := []string{ + "triage:by_design:" + testFP, + "triage:false_positive:" + testFP, + "triage:investigating:" + testFP, + "triage:resolved:" + testFP, + } + for i, b := range buttons { + if b.CustomID != wantIDs[i] { + t.Errorf("bouton %d : custom_id %q, attendu %q", i, b.CustomID, wantIDs[i]) + } + if b.Style != discordgo.SecondaryButton { + t.Errorf("bouton %d : style %v, attendu Secondary pour une alerte non classée", i, b.Style) + } + } + + sans := soarAlertComponents(soarAlertView{Title: "Alerte", Message: "msg", Severity: "high"}) + if got := buttonsOf(t, containerOf(t, sans)); got != nil { + t.Fatalf("une alerte sans empreinte porte %d bouton(s)", len(got)) + } +} + +// TestSoarAlertComponentsClassified : une alerte déjà classée au rendu (post ou +// ré-édition d'enrichissement) porte l'étiquette, la couleur du statut, et le +// bouton du verdict marqué — le classement ne doit pas s'effacer à l'édition IA. +func TestSoarAlertComponentsClassified(t *testing.T) { + comps := soarAlertComponents(soarAlertView{ + Title: "Alerte", Message: "msg", Severity: "critical", Analysis: "analyse", + Fingerprint: testFP, TriageStatus: TriageByDesign, DecidedBy: "abraham", + }) + cont := containerOf(t, comps) + + if cont.AccentColor == nil || *cont.AccentColor != 0x808080 { + t.Errorf("couleur %v, attendu le gris by_design (0x808080)", cont.AccentColor) + } + var label string + for _, txt := range textContents(cont) { + if strings.HasPrefix(txt, triageLabelPrefix) { + label = txt + } + } + if !strings.Contains(label, "By design") || !strings.Contains(label, "abraham") { + t.Errorf("étiquette %q : attendu le verdict et le décideur", label) + } + var selected int + for _, b := range buttonsOf(t, cont) { + if b.Style == discordgo.PrimaryButton { + selected++ + if wantID := "triage:by_design:" + testFP; b.CustomID != wantID { + t.Errorf("bouton marqué %q, attendu %q", b.CustomID, wantID) + } + } + } + if selected != 1 { + t.Errorf("%d bouton(s) marqué(s), attendu exactement 1", selected) + } +} + +// gatewayRoundTrip simule la livraison d'un arbre par le gateway Discord : +// marshal JSON puis désérialisation discordgo — qui produit des POINTEURS +// (*Container, *TextDisplay…) là où notre construction produit des valeurs. +func gatewayRoundTrip(t *testing.T, comps []discordgo.MessageComponent) []discordgo.MessageComponent { + t.Helper() + out := make([]discordgo.MessageComponent, 0, len(comps)) + for _, c := range comps { + raw, err := json.Marshal(c) + if err != nil { + t.Fatalf("marshal: %v", err) + } + mc, err := discordgo.MessageComponentFromJSON(raw) + if err != nil { + t.Fatalf("unmarshal gateway: %v", err) + } + out = append(out, mc) + } + return out +} + +// TestRetagSoarComponentsGatewayRoundTrip : au clic, l'arbre reçu du gateway +// (pointeurs) est reconstruit — étiquette posée, boutons remplacés avec le +// verdict marqué, Container recoloré, textes d'origine intacts. C'est le chemin +// réel du handler d'interaction. +func TestRetagSoarComponentsGatewayRoundTrip(t *testing.T) { + original := soarAlertComponents(soarAlertView{ + Title: "SSH brute force", Message: "**Machine:** srv", Severity: "high", + Analysis: "analyse IA", Fingerprint: testFP, + }) + received := gatewayRoundTrip(t, original) + + retagged := retagSoarComponents(received, TriageFalsePositive, "abraham", testFP) + cont := containerOf(t, retagged) + + if cont.AccentColor == nil || *cont.AccentColor != 0x808080 { + t.Errorf("couleur %v après classement faux positif, attendu 0x808080", cont.AccentColor) + } + + texts := textContents(cont) + joined := strings.Join(texts, "\n") + for _, want := range []string{"SSH brute force", "**Machine:** srv", "analyse IA"} { + if !strings.Contains(joined, want) { + t.Errorf("texte d'origine %q perdu au retag", want) + } + } + var labels int + for _, txt := range texts { + if strings.HasPrefix(txt, triageLabelPrefix) { + labels++ + if !strings.Contains(txt, "Faux positif") { + t.Errorf("étiquette %q sans le verdict", txt) + } + } + } + if labels != 1 { + t.Fatalf("%d étiquette(s) de statut, attendu 1", labels) + } + + var selected string + for _, b := range buttonsOf(t, cont) { + if b.Style == discordgo.PrimaryButton { + selected = b.CustomID + } + } + if want := "triage:false_positive:" + testFP; selected != want { + t.Errorf("bouton marqué %q, attendu %q", selected, want) + } + + // Reclassement (correction d'un mauvais clic) : le retag doit être + // idempotent — une seule étiquette, une seule rangée de boutons. + reretagged := retagSoarComponents(gatewayRoundTrip(t, retagged), TriageInvestigating, "abraham", testFP) + cont2 := containerOf(t, reretagged) + labels = 0 + for _, txt := range textContents(cont2) { + if strings.HasPrefix(txt, triageLabelPrefix) { + labels++ + if !strings.Contains(txt, "Enquête en cours") { + t.Errorf("étiquette après reclassement : %q", txt) + } + } + } + if labels != 1 { + t.Fatalf("%d étiquette(s) après reclassement, attendu 1 (accumulation)", labels) + } + if got := len(buttonsOf(t, cont2)); got != 4 { + t.Fatalf("%d bouton(s) après reclassement, attendu 4", got) + } + if cont2.AccentColor == nil || *cont2.AccentColor != 0x3498db { + t.Errorf("couleur %v après « j'enquête », attendu 0x3498db", cont2.AccentColor) + } +} + +// TestTriageDigestComponents : le digest détaille au plus 10 empreintes, agrège +// le reste, et neutralise les textes issus de Wazuh (titre/agent non fiables). +func TestTriageDigestComponents(t *testing.T) { + rows := make([]TriageDigestRow, 0, 12) + for i := 0; i < 12; i++ { + status := TriageByDesign + if i == 0 { + status = TriageOpen // rouverte par récidive APRÈS ses suppressions + } + rows = append(rows, TriageDigestRow{ + Fingerprint: testFP, RuleID: "5710", AgentName: "srv", + Title: "@everyone alerte", Status: status, + SinceDigest: 12 - i, TotalSuppressed: 20, + }) + } + cont := containerOf(t, triageDigestComponents(rows)) + joined := strings.Join(textContents(cont), "\n") + + // total = 12+11+…+1 = 78 ; reste agrégé = 2+1 = 3 sur 2 empreintes. + if !strings.Contains(joined, "**78**") || !strings.Contains(joined, "**12**") { + t.Errorf("totaux absents du digest :\n%s", joined) + } + if !strings.Contains(joined, "2 autre(s) empreinte(s), 3 suppression(s)") { + t.Errorf("ligne d'agrégat absente :\n%s", joined) + } + if !strings.Contains(joined, "20 au total") { + t.Errorf("total vie-entière absent :\n%s", joined) + } + if !strings.Contains(joined, "réouverte") { + t.Errorf("une row rouverte (open) doit être étiquetée « réouverte », pas vide :\n%s", joined) + } + if strings.Contains(joined, "@everyone") { + t.Error("mention @everyone non neutralisée dans le digest") + } +} diff --git a/internal/services/registry.go b/internal/services/registry.go index 9b10e57..a08cbc6 100644 --- a/internal/services/registry.go +++ b/internal/services/registry.go @@ -53,6 +53,12 @@ type ServiceRegistry struct { // skipTLS is the boot TLS-verification policy, reused to rebuild Wazuh/Indexer // clients on Apply* (the onboarding form does not re-ask it). skipTLS bool + + // triage is the SOAR triage store handed to every Discord bot built by + // ApplyDiscord (the alert buttons need it). Set once at boot via + // SetTriageStore, before workers start — never mutated afterwards. nil keeps + // hot-reloaded bots send-only (le comportement pré-triage). + triage *TriageStore } // aiHolder wraps the AIClient interface so it can be stored in an atomic.Pointer. @@ -106,6 +112,13 @@ func NewServiceRegistry(skipTLS bool) *ServiceRegistry { return &ServiceRegistry{skipTLS: skipTLS} } +// SetTriageStore wires the SOAR triage store used by every Discord bot the +// registry builds (ApplyDiscord). Boot only, BEFORE reloadServicesFromDB and the +// workers — jamais après (le champ n'est pas protégé pour une écriture tardive). +func (r *ServiceRegistry) SetTriageStore(t *TriageStore) { + r.triage = t +} + // --- Accessors (lock-free; any may return nil — all consumers nil-guard) --- // Wazuh returns the live Wazuh Manager API client, or nil when unconfigured. @@ -236,8 +249,12 @@ const discordCloseTimeout = 10 * time.Second // the second filter.) // 4. Atomically publish the new bot (Swap). // 5. Close the OLD session AFTER the swap, in a bounded goroutine. The transient -// few-ms double session is harmless: the bot registers no inbound handlers (it -// only sends), so nothing is double-processed. +// few-ms double session is harmless: the bot's ONLY inbound handler (the SOAR +// triage buttons) is idempotent by construction — a double-delivered +// InteractionCreate re-applies the same DB upsert and Discord accepts a single +// response per interaction (the second acknowledgement fails cleanly, logged +// as a warning in handleTriageInteraction) — so nothing is double-processed +// into a wrong state. // // It returns an error ONLY when building/opening the new session fails (the live bot // is untouched in that case). A nil error covers no-op, disable, and successful swap. @@ -267,7 +284,7 @@ func (r *ServiceRegistry) ApplyDiscord(token, channelID, authChannel, ansibleCha } // (3) Build + Open the NEW session first. On failure, keep the old one live. - bot, err := NewDiscordBot(token, channelID, authChannel, ansibleChannel) + bot, err := NewDiscordBot(token, channelID, authChannel, ansibleChannel, r.triage) if err != nil { return err } diff --git a/internal/services/triage.go b/internal/services/triage.go new file mode 100644 index 0000000..8dfc517 --- /dev/null +++ b/internal/services/triage.go @@ -0,0 +1,292 @@ +package services + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +// SOAR triage — classement apprenant des alertes (Wiz-like). +// +// Chaque alerte est réduite à une EMPREINTE stable (sha256 de ses champs +// discriminants) ; l'opérateur classe l'empreinte depuis les boutons Discord, et le +// worker SOAR supprime les occurrences futures des empreintes classées "by design" +// ou "faux positif" — sans jamais les rendre invisibles : chaque suppression est +// comptée et resurfacée par le digest hebdomadaire. + +// Triage statuses. TriageOpen is the implicit state of any alert never classified. +const ( + TriageOpen = "open" + TriageByDesign = "by_design" + TriageFalsePositive = "false_positive" + TriageInvestigating = "investigating" + TriageResolved = "resolved" +) + +// TriageStatusSuppresses reports whether alerts fingerprinted under this status +// must be suppressed before posting. Only the two "cette alerte est du bruit" +// verdicts suppress: "investigating" and "resolved" keep posting — une alerte qui +// récidive pendant ou après traitement est une information, pas du bruit. +func TriageStatusSuppresses(status string) bool { + return status == TriageByDesign || status == TriageFalsePositive +} + +// triageDecisions are the statuses an operator can assign from the Discord +// buttons. "open" is deliberately absent: it is the default state, never a verdict. +var triageDecisions = map[string]bool{ + TriageByDesign: true, + TriageFalsePositive: true, + TriageInvestigating: true, + TriageResolved: true, +} + +// validTriageDecision reports whether status is an operator-assignable verdict. +func validTriageDecision(status string) bool { + return triageDecisions[status] +} + +// TriageFingerprint computes the stable triage fingerprint of an indexer alert: +// sha256(rule.id | agent.name | champ-clé) en hex. +// +// Le champ-clé dépend de la famille de règle, pour que le classement colle au +// geste de l'opérateur : un « by design » sur une alerte FIM vise CE fichier +// (path), un « by design » sur une alerte d'authentification vise CE compte +// (dstuser) — jamais l'IP source, qui varie légitimement. Les familles reprennent +// defaultAlertRuleIDs (wazuh_indexer.go) ; toute règle hors familles (ou un +// champ-clé absent sur un vieux mapping) retombe sur la description de la règle, +// stable pour une règle donnée. +func TriageFingerprint(a WazuhAlert) string { + var key string + switch a.Rule.ID { + case "550", "553", "554": // FIM : le chemin surveillé identifie le constat + key = a.Syscheck.Path + case "5716", "5710", "5712", "5503": // auth : le compte visé + key = a.Data.DstUser + case "5402": // sudo : le compte QUI élève (dstuser vaudrait "root" partout — + // une seule empreinte muterait toutes les élévations de l'agent) + key = a.Data.SrcUser + } + if strings.TrimSpace(key) == "" { + key = a.Rule.Description + } + return triageHash(a.Rule.ID, a.Agent.Name, key) +} + +// TriageAgentStatusFingerprint fingerprints an agent status-change alert +// (Agent Perdu / Agent Retrouvé). Chaque direction a sa propre empreinte : une +// machine volontairement éteinte se classe « by design » sur "disconnected" ET +// sur "active", explicitement — jamais de suppression implicite de l'une par +// l'autre. +func TriageAgentStatusFingerprint(agentName, newStatus string) string { + return triageHash("agent-status", agentName, newStatus) +} + +// triageHash joins the normalized parts and hashes them. Normalization is +// whitespace-only (trim + collapse) : PAS de casse-folding, deux chemins FIM qui +// ne diffèrent que par la casse sont deux fichiers distincts sous Linux et ne +// doivent jamais partager une empreinte (une suppression ne doit jamais avaler +// une alerte réellement distincte). +func triageHash(parts ...string) string { + norm := make([]string, len(parts)) + for i, p := range parts { + norm[i] = strings.Join(strings.Fields(p), " ") + } + sum := sha256.Sum256([]byte(strings.Join(norm, "|"))) + return hex.EncodeToString(sum[:]) +} + +// isTriageFingerprint reports whether s looks like a fingerprint we produced +// (64 lowercase hex chars). Used to validate the fingerprint carried inside a +// Discord custom_id, which is attacker-visible client input. +func isTriageFingerprint(s string) bool { + if len(s) != 64 { + return false + } + for _, r := range s { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true +} + +// TriageDigestRow is one fingerprint's line in the weekly suppression digest. +type TriageDigestRow struct { + Fingerprint string + RuleID string + AgentName string + Title string + Status string + SinceDigest int + TotalSuppressed int +} + +// TriageStore persists triage verdicts and suppression counters (table +// soar_triage). Same conventions as the other stores: *sql.DB direct, nil-safe +// (a nil store or nil db degrades to "aucun classement" so the SOAR worker and +// the tests keep working without MySQL), errors wrapped for the caller to log. +type TriageStore struct { + db *sql.DB +} + +// NewTriageStore builds a TriageStore. db may be nil (tests, DB-less boot paths): +// every method then no-ops. +func NewTriageStore(db *sql.DB) *TriageStore { + return &TriageStore{db: db} +} + +// ready reports whether the store can reach a database. +func (s *TriageStore) ready() bool { + return s != nil && s.db != nil +} + +// Decision returns the status AND who decided it (both "" when never classified, +// same contract as an absent row) — le statut pilote la suppression, le décideur +// rend l'étiquette « classée par X » au (re)rendu d'un message. +func (s *TriageStore) Decision(fingerprint string) (status, decidedBy string, err error) { + if !s.ready() { + return "", "", nil + } + err = s.db.QueryRow("SELECT status, decided_by FROM soar_triage WHERE fingerprint = ?", fingerprint). + Scan(&status, &decidedBy) + if errors.Is(err, sql.ErrNoRows) { + return "", "", nil + } + if err != nil { + return "", "", fmt.Errorf("triage decision %q: %w", fingerprint, err) + } + return status, decidedBy, nil +} + +// RecordSeen upserts the fingerprint row when its alert is POSTED (never called +// on a suppressed occurrence). A recurrence of a "resolved" fingerprint reopens +// it (une récidive après résolution est une régression) ; "investigating" est +// conservé. sample est la dernière alerte postée, en JSON (nil accepté), gardée +// pour la future similarité (phase 2 Qdrant) et la page dashboard (phase 3). +func (s *TriageStore) RecordSeen(fingerprint, ruleID, agentName, title string, sample []byte) error { + if !s.ready() { + return nil + } + var sampleVal interface{} + if len(sample) > 0 { + sampleVal = string(sample) + } + _, err := s.db.Exec(` + INSERT INTO soar_triage (fingerprint, status, rule_id, agent_name, title, sample_alert, first_seen, last_seen) + VALUES (?, 'open', ?, ?, ?, ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + last_seen = NOW(), + rule_id = VALUES(rule_id), + agent_name = VALUES(agent_name), + title = VALUES(title), + sample_alert = COALESCE(VALUES(sample_alert), sample_alert), + status = IF(status = 'resolved', 'open', status)`, + fingerprint, ruleID, truncateRunes(agentName, 180), truncateRunes(title, 180), sampleVal) + if err != nil { + return fmt.Errorf("triage record seen %q: %w", fingerprint, err) + } + return nil +} + +// RecordSuppressed counts one suppressed occurrence (post évité). Les deux +// compteurs avancent : le total vie-entière et celui du digest en cours. +// Row absente = no-op silencieux (un classement ne peut venir que d'une row). +func (s *TriageStore) RecordSuppressed(fingerprint string) error { + if !s.ready() { + return nil + } + _, err := s.db.Exec(` + UPDATE soar_triage + SET count_suppressed = count_suppressed + 1, + suppressed_since_digest = suppressed_since_digest + 1, + last_seen = NOW() + WHERE fingerprint = ?`, fingerprint) + if err != nil { + return fmt.Errorf("triage record suppressed %q: %w", fingerprint, err) + } + return nil +} + +// SetDecision records an operator verdict from a Discord button. Upsert : la row +// peut manquer (DB dev reset alors que d'anciens messages Discord portent encore +// des boutons) — le verdict crée alors la row plutôt que d'échouer. +// decidedByID est l'ID Discord immuable (traçabilité : le username, mutable à +// volonté, ne sert qu'à l'affichage). +func (s *TriageStore) SetDecision(fingerprint, status, decidedBy, decidedByID string) error { + if !validTriageDecision(status) { + return fmt.Errorf("triage: statut invalide %q", status) + } + if !s.ready() { + return fmt.Errorf("triage: store sans base de données") + } + _, err := s.db.Exec(` + INSERT INTO soar_triage (fingerprint, status, decided_at, decided_by, decided_by_id, first_seen, last_seen) + VALUES (?, ?, NOW(), ?, ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + decided_at = NOW(), + decided_by = VALUES(decided_by), + decided_by_id = VALUES(decided_by_id)`, + fingerprint, status, truncateRunes(decidedBy, 100), truncateRunes(decidedByID, 32)) + if err != nil { + return fmt.Errorf("triage set decision %q: %w", fingerprint, err) + } + return nil +} + +// DigestRows returns every fingerprint with suppressions since the last digest, +// most-suppressed first. Toutes les rows sont retournées (pas seulement le top +// affiché) : le reset post-digest doit créditer chaque empreinte exactement de ce +// qui a été rapporté. +func (s *TriageStore) DigestRows() ([]TriageDigestRow, error) { + if !s.ready() { + return nil, nil + } + rows, err := s.db.Query(` + SELECT fingerprint, rule_id, agent_name, title, status, suppressed_since_digest, count_suppressed + FROM soar_triage + WHERE suppressed_since_digest > 0 + ORDER BY suppressed_since_digest DESC, fingerprint`) + if err != nil { + return nil, fmt.Errorf("triage digest rows: %w", err) + } + defer rows.Close() + var out []TriageDigestRow + for rows.Next() { + var r TriageDigestRow + if err := rows.Scan(&r.Fingerprint, &r.RuleID, &r.AgentName, &r.Title, &r.Status, &r.SinceDigest, &r.TotalSuppressed); err != nil { + return nil, fmt.Errorf("triage digest scan: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// ResetDigestCounts subtracts the REPORTED counts after a successful digest post, +// row by row — jamais un reset à zéro global : une suppression survenue entre la +// lecture du digest et ce reset doit survivre et compter au digest suivant. +func (s *TriageStore) ResetDigestCounts(rows []TriageDigestRow) error { + if !s.ready() || len(rows) == 0 { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("triage reset digest: %w", err) + } + defer tx.Rollback() //nolint:errcheck // no-op après un Commit réussi + for _, r := range rows { + if _, err := tx.Exec(` + UPDATE soar_triage + SET suppressed_since_digest = GREATEST(suppressed_since_digest - ?, 0) + WHERE fingerprint = ?`, r.SinceDigest, r.Fingerprint); err != nil { + return fmt.Errorf("triage reset digest %q: %w", r.Fingerprint, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("triage reset digest commit: %w", err) + } + return nil +} diff --git a/internal/services/triage_store_test.go b/internal/services/triage_store_test.go new file mode 100644 index 0000000..3389bae --- /dev/null +++ b/internal/services/triage_store_test.go @@ -0,0 +1,201 @@ +package services + +import ( + "database/sql" + "os" + "testing" + + _ "github.com/go-sql-driver/mysql" +) + +// Ces tests exercent le store de triage contre une vraie base MySQL, même +// convention que soar_persistence_test.go côté workers : skippés sans +// SOAR_TEST_DSN, table jetable recréée au schéma identique à Migrate. +func triageTestDB(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("SOAR_TEST_DSN") + if dsn == "" { + t.Skip("SOAR_TEST_DSN non défini — test d'intégration MySQL skippé") + } + db, err := sql.Open("mysql", dsn) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.Ping(); err != nil { + t.Fatalf("ping: %v", err) + } + for _, q := range []string{ + `DROP TABLE IF EXISTS soar_triage`, + `CREATE TABLE 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) + )`, + } { + if _, err := db.Exec(q); err != nil { + t.Fatalf("schema %q: %v", q, err) + } + } + return db +} + +// TestTriageStoreLifecycle : RecordSeen crée en « open », un verdict le +// remplace, une récidive rouvre « resolved » (régression) mais préserve +// « investigating » (déjà pris en charge) — le cœur du triage apprenant. +func TestTriageStoreLifecycle(t *testing.T) { + db := triageTestDB(t) + defer db.Close() + s := NewTriageStore(db) + + if err := s.RecordSeen(testFP, "5710", "srv", "SSH", []byte(`{"rule":{"id":"5710"}}`)); err != nil { + t.Fatalf("RecordSeen: %v", err) + } + if st, _, err := s.Decision(testFP); err != nil || st != TriageOpen { + t.Fatalf("statut après création = (%q, %v), attendu open", st, err) + } + + if err := s.SetDecision(testFP, TriageInvestigating, "abraham", "1234567890"); err != nil { + t.Fatalf("SetDecision: %v", err) + } + if err := s.RecordSeen(testFP, "5710", "srv", "SSH", nil); err != nil { + t.Fatalf("RecordSeen (récidive): %v", err) + } + if st, _, _ := s.Decision(testFP); st != TriageInvestigating { + t.Fatalf("une récidive a écrasé « investigating » (statut %q)", st) + } + // Le sample déjà stocké ne doit pas être effacé par une récidive sans sample. + var sample sql.NullString + if err := db.QueryRow("SELECT sample_alert FROM soar_triage WHERE fingerprint = ?", testFP).Scan(&sample); err != nil { + t.Fatalf("lecture sample: %v", err) + } + if !sample.Valid || sample.String == "" { + t.Fatal("le sample a été effacé par une récidive sans sample (COALESCE cassé)") + } + + if err := s.SetDecision(testFP, TriageResolved, "abraham", "1234567890"); err != nil { + t.Fatalf("SetDecision resolved: %v", err) + } + if err := s.RecordSeen(testFP, "5710", "srv", "SSH", nil); err != nil { + t.Fatalf("RecordSeen (récidive post-résolution): %v", err) + } + if st, _, _ := s.Decision(testFP); st != TriageOpen { + t.Fatalf("une récidive après résolution doit rouvrir (statut %q)", st) + } +} + +// TestTriageStoreDecisionUpsert : un verdict sur une empreinte SANS row (base +// dev reset, vieux message Discord aux boutons encore actifs) crée la row au +// lieu d'échouer — le clic de l'opérateur ne doit jamais se perdre. +func TestTriageStoreDecisionUpsert(t *testing.T) { + db := triageTestDB(t) + defer db.Close() + s := NewTriageStore(db) + + if err := s.SetDecision(testFP, TriageByDesign, "abraham", "1234567890"); err != nil { + t.Fatalf("SetDecision sur row absente: %v", err) + } + st, by, err := s.Decision(testFP) + if err != nil || st != TriageByDesign || by != "abraham" { + t.Fatalf("Decision = (%q, %q, %v), attendu (by_design, abraham)", st, by, err) + } + // L'ID Discord immuable est la traçabilité réelle (le username se change). + var byID string + if err := db.QueryRow("SELECT decided_by_id FROM soar_triage WHERE fingerprint = ?", testFP).Scan(&byID); err != nil || byID != "1234567890" { + t.Fatalf("decided_by_id = (%q, %v), attendu 1234567890", byID, err) + } + if err := s.SetDecision(testFP, "sudo_rm_rf", "x", "1"); err == nil { + t.Fatal("verdict invalide accepté") + } +} + +// TestTriageStoreDigestCounters : les compteurs de suppression alimentent le +// digest, et le reset SOUSTRAIT ce qui a été rapporté — une suppression arrivée +// entre la lecture et le reset survit pour le digest suivant (jamais de perte). +func TestTriageStoreDigestCounters(t *testing.T) { + db := triageTestDB(t) + defer db.Close() + s := NewTriageStore(db) + + if err := s.SetDecision(testFP, TriageByDesign, "abraham", "1234567890"); err != nil { + t.Fatalf("SetDecision: %v", err) + } + for i := 0; i < 2; i++ { + if err := s.RecordSuppressed(testFP); err != nil { + t.Fatalf("RecordSuppressed: %v", err) + } + } + + rows, err := s.DigestRows() + if err != nil { + t.Fatalf("DigestRows: %v", err) + } + if len(rows) != 1 || rows[0].SinceDigest != 2 || rows[0].TotalSuppressed != 2 { + t.Fatalf("digest inattendu: %+v", rows) + } + if rows[0].Status != TriageByDesign { + t.Fatalf("statut digest %q", rows[0].Status) + } + + // Suppression concurrente entre la lecture du digest et le reset. + if err := s.RecordSuppressed(testFP); err != nil { + t.Fatalf("RecordSuppressed concurrent: %v", err) + } + if err := s.ResetDigestCounts(rows); err != nil { + t.Fatalf("ResetDigestCounts: %v", err) + } + var since, total int + if err := db.QueryRow("SELECT suppressed_since_digest, count_suppressed FROM soar_triage WHERE fingerprint = ?", testFP). + Scan(&since, &total); err != nil { + t.Fatalf("lecture compteurs: %v", err) + } + if since != 1 { + t.Fatalf("suppressed_since_digest = %d après reset, attendu 1 (la suppression concurrente doit survivre)", since) + } + if total != 3 { + t.Fatalf("count_suppressed = %d, attendu 3 (le total vie-entière ne se reset jamais)", total) + } + // Plus rien à rapporter sauf la concurrente. + rows, _ = s.DigestRows() + if len(rows) != 1 || rows[0].SinceDigest != 1 { + t.Fatalf("digest suivant inattendu: %+v", rows) + } +} + +// TestTriageStoreNilSafe : le worker tourne en tests avec db=nil — chaque +// lecture dégrade en « aucun classement » et chaque écriture best-effort en +// no-op, sans panic. Seul SetDecision (clic opérateur) doit remonter une erreur. +func TestTriageStoreNilSafe(t *testing.T) { + for name, s := range map[string]*TriageStore{"store nil": nil, "db nil": NewTriageStore(nil)} { + t.Run(name, func(t *testing.T) { + if st, by, err := s.Decision(testFP); err != nil || st != "" || by != "" { + t.Fatalf("Decision = (%q, %q, %v)", st, by, err) + } + if err := s.RecordSeen(testFP, "r", "a", "t", nil); err != nil { + t.Fatalf("RecordSeen: %v", err) + } + if err := s.RecordSuppressed(testFP); err != nil { + t.Fatalf("RecordSuppressed: %v", err) + } + if rows, err := s.DigestRows(); err != nil || rows != nil { + t.Fatalf("DigestRows = (%v, %v)", rows, err) + } + if err := s.ResetDigestCounts(nil); err != nil { + t.Fatalf("ResetDigestCounts: %v", err) + } + if err := s.SetDecision(testFP, TriageByDesign, "x", "1"); err == nil { + t.Fatal("SetDecision sans DB doit échouer (le clic doit être signalé perdu)") + } + }) + } +} diff --git a/internal/services/triage_test.go b/internal/services/triage_test.go new file mode 100644 index 0000000..9597aab --- /dev/null +++ b/internal/services/triage_test.go @@ -0,0 +1,226 @@ +package services + +import ( + "strings" + "testing" +) + +// mkAlert fabrique une WazuhAlert de test (IP fictives RFC5737). +func mkAlert(ruleID, agentName, desc, path, dstUser string) WazuhAlert { + var a WazuhAlert + a.Rule.ID = ruleID + a.Rule.Description = desc + a.Agent.Name = agentName + a.Agent.IP = "192.0.2.10" + a.Syscheck.Path = path + a.Data.DstUser = dstUser + a.Data.SrcIP = "192.0.2.77" + return a +} + +// TestTriageFingerprintFormat verrouille le format de l'empreinte : 64 hex +// minuscules, déterministe. C'est le contrat des custom_id Discord (validation +// par isTriageFingerprint) et de la clé primaire soar_triage (CHAR(64)). +func TestTriageFingerprintFormat(t *testing.T) { + a := mkAlert("5710", "srv", "sshd auth failed", "", "") + fp := TriageFingerprint(a) + if len(fp) != 64 { + t.Fatalf("empreinte de longueur %d, attendu 64", len(fp)) + } + if !isTriageFingerprint(fp) { + t.Fatalf("l'empreinte produite %q n'est pas acceptée par isTriageFingerprint", fp) + } + if fp != TriageFingerprint(a) { + t.Fatal("empreinte non déterministe pour une même alerte") + } +} + +// TestTriageFingerprintFamilies verrouille le champ-clé par famille de règle : +// un « by design » doit viser exactement ce que l'opérateur regarde (le fichier +// pour la FIM, le compte pour l'auth) — jamais plus large (sur-suppression = +// alerte réelle avalée), jamais plus étroit (le classement ne supprime rien). +func TestTriageFingerprintFamilies(t *testing.T) { + cases := []struct { + name string + a, b WazuhAlert + wantEqual bool + }{ + { + name: "FIM : chemins différents = empreintes différentes", + a: mkAlert("550", "srv", "Integrity checksum changed.", "/etc/passwd", ""), + b: mkAlert("550", "srv", "Integrity checksum changed.", "/etc/shadow", ""), + wantEqual: false, + }, + { + name: "FIM : même chemin = même empreinte", + a: mkAlert("550", "srv", "Integrity checksum changed.", "/etc/passwd", ""), + b: mkAlert("550", "srv", "Integrity checksum changed.", "/etc/passwd", ""), + wantEqual: true, + }, + { + name: "FIM : la casse d'un chemin compte (fichiers distincts sous Linux)", + a: mkAlert("553", "srv", "File deleted.", "/opt/App/config", ""), + b: mkAlert("553", "srv", "File deleted.", "/opt/app/config", ""), + wantEqual: false, + }, + { + name: "auth : l'IP source ne discrimine PAS (elle varie légitimement)", + a: withSrcIP(mkAlert("5710", "srv", "sshd: attempt to login using a non-existent user", "", "backup"), "192.0.2.1"), + b: withSrcIP(mkAlert("5710", "srv", "sshd: attempt to login using a non-existent user", "", "backup"), "192.0.2.2"), + wantEqual: true, + }, + { + name: "auth : le compte visé discrimine", + a: mkAlert("5710", "srv", "sshd: attempt to login using a non-existent user", "", "backup"), + b: mkAlert("5710", "srv", "sshd: attempt to login using a non-existent user", "", "root"), + wantEqual: false, + }, + { + name: "sudo : le compte QUI élève discrimine (srcuser)", + a: withSrcUser(mkAlert("5402", "srv", "Successful sudo to ROOT executed.", "", "root"), "abraham"), + b: withSrcUser(mkAlert("5402", "srv", "Successful sudo to ROOT executed.", "", "root"), "www-data"), + wantEqual: false, + }, + { + name: "sudo : dstuser (toujours root) ne discrimine PAS — sinon un by_design muterait toutes les élévations de l'agent", + a: withSrcUser(mkAlert("5402", "srv", "Successful sudo to ROOT executed.", "", "root"), "abraham"), + b: withSrcUser(mkAlert("5402", "srv", "Successful sudo to ROOT executed.", "", "abraham"), "abraham"), + wantEqual: true, + }, + { + name: "agents différents = empreintes différentes (le classement est par machine)", + a: mkAlert("2902", "srv-a", "New dpkg (Debian Package) installed.", "", ""), + b: mkAlert("2902", "srv-b", "New dpkg (Debian Package) installed.", "", ""), + wantEqual: false, + }, + { + name: "règles différentes = empreintes différentes", + a: mkAlert("2902", "srv", "same description", "", ""), + b: mkAlert("2903", "srv", "same description", "", ""), + wantEqual: false, + }, + { + name: "famille auth sans dstuser (vieux mapping) : repli sur la description", + a: mkAlert("5710", "srv", "sshd auth failed", "", ""), + b: mkAlert("5710", "srv", "sshd auth failed", "", ""), + wantEqual: true, + }, + { + name: "hors familles : la description discrimine", + a: mkAlert("9999", "srv", "custom rule A", "", ""), + b: mkAlert("9999", "srv", "custom rule B", "", ""), + wantEqual: false, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + fa, fb := TriageFingerprint(tc.a), TriageFingerprint(tc.b) + if (fa == fb) != tc.wantEqual { + t.Errorf("empreintes a=%s b=%s, égalité attendue: %v", fa, fb, tc.wantEqual) + } + }) + } +} + +func withSrcIP(a WazuhAlert, ip string) WazuhAlert { + a.Data.SrcIP = ip + return a +} + +func withSrcUser(a WazuhAlert, u string) WazuhAlert { + a.Data.SrcUser = u + return a +} + +// TestTriageFingerprintWhitespace : la normalisation ne porte QUE sur les +// espaces (trim + réduction), pour qu'une variation d'espacement dans une +// description Wazuh ne fasse pas muter l'empreinte — sans casse-folding. +func TestTriageFingerprintWhitespace(t *testing.T) { + a := mkAlert("9999", "srv", " sshd: auth failed ", "", "") + b := mkAlert("9999", "srv", "sshd: auth failed", "", "") + if TriageFingerprint(a) != TriageFingerprint(b) { + t.Fatal("une variation d'espaces dans la description change l'empreinte") + } + c := mkAlert("9999", "srv", "SSHD: AUTH FAILED", "", "") + if TriageFingerprint(b) == TriageFingerprint(c) { + t.Fatal("le casse-folding ne doit pas être appliqué (deux textes distincts fusionnés)") + } +} + +// TestTriageAgentStatusFingerprint : chaque direction (perdu/retrouvé) a sa +// propre empreinte — classer « by design » la perte d'un agent ne doit jamais +// supprimer implicitement son retour. +func TestTriageAgentStatusFingerprint(t *testing.T) { + lost := TriageAgentStatusFingerprint("worker-ct", "disconnected") + back := TriageAgentStatusFingerprint("worker-ct", "active") + if lost == back { + t.Fatal("perte et retour d'agent partagent une empreinte") + } + if lost != TriageAgentStatusFingerprint("worker-ct", "disconnected") { + t.Fatal("empreinte de statut non déterministe") + } + if !isTriageFingerprint(lost) || !isTriageFingerprint(back) { + t.Fatal("empreinte de statut au mauvais format") + } + other := TriageAgentStatusFingerprint("autre-machine", "disconnected") + if lost == other { + t.Fatal("deux agents distincts partagent une empreinte de statut") + } +} + +// TestTriageStatusSuppresses verrouille QUELS verdicts suppriment : uniquement +// les deux « bruit ». « investigating » et « resolved » continuent de poster — +// une récidive pendant ou après traitement est une information. +func TestTriageStatusSuppresses(t *testing.T) { + for status, want := range map[string]bool{ + TriageByDesign: true, + TriageFalsePositive: true, + TriageInvestigating: false, + TriageResolved: false, + TriageOpen: false, + "": false, + "garbage": false, + } { + if got := TriageStatusSuppresses(status); got != want { + t.Errorf("TriageStatusSuppresses(%q) = %v, attendu %v", status, got, want) + } + } +} + +// TestValidTriageDecision : « open » est l'état par défaut, jamais un verdict +// assignable depuis un bouton (un custom_id forgé "triage:open:…" doit être rejeté). +func TestValidTriageDecision(t *testing.T) { + for _, s := range []string{TriageByDesign, TriageFalsePositive, TriageInvestigating, TriageResolved} { + if !validTriageDecision(s) { + t.Errorf("verdict légitime %q rejeté", s) + } + } + for _, s := range []string{TriageOpen, "", "root", "by-design"} { + if validTriageDecision(s) { + t.Errorf("%q accepté comme verdict", s) + } + } +} + +// TestIsTriageFingerprint : l'empreinte revient dans un custom_id contrôlé par +// le client Discord — tout ce qui n'est pas 64 hex minuscules est rejeté. +func TestIsTriageFingerprint(t *testing.T) { + valid := strings.Repeat("ab12", 16) + if !isTriageFingerprint(valid) { + t.Fatalf("%q rejeté à tort", valid) + } + for _, s := range []string{ + "", + strings.Repeat("a", 63), + strings.Repeat("a", 65), + strings.Repeat("A", 64), // hex majuscule : pas notre format + strings.Repeat("g", 64), // hors alphabet hex + strings.Repeat("a", 60) + "'--x", // tentative d'injection + strings.Repeat("a", 62) + "é", // multibyte + } { + if isTriageFingerprint(s) { + t.Errorf("%q accepté à tort", s) + } + } +} diff --git a/internal/services/wazuh_indexer.go b/internal/services/wazuh_indexer.go index 6174014..5bb6f88 100644 --- a/internal/services/wazuh_indexer.go +++ b/internal/services/wazuh_indexer.go @@ -168,6 +168,7 @@ type WazuhAlert struct { } `json:"agent"` Data struct { SrcIP string `json:"srcip"` + SrcUser string `json:"srcuser"` DstUser string `json:"dstuser"` } `json:"data"` Syscheck struct { diff --git a/internal/workers/soar.go b/internal/workers/soar.go index 8f5db06..7eef3e6 100644 --- a/internal/workers/soar.go +++ b/internal/workers/soar.go @@ -3,6 +3,7 @@ package workers import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "log/slog" @@ -69,6 +70,10 @@ func StartSoarWorker( } } + // Triage apprenant : verdicts + compteurs de suppression (table soar_triage). + // Le store est nil-safe, le worker fonctionne sans lui (tests db=nil). + triage := services.NewTriageStore(db) + ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() @@ -82,7 +87,10 @@ func StartSoarWorker( // proceed with the current snapshot rather than skipping the tick. _ = loadSoarConfig(db, soarConfig) // Snapshot ALL hot-reloadable clients (incl. Discord) for this tick. - checkSoarEvents(ctx, db, registry.Wazuh(), registry.Indexer(), registry.AI(), registry.Discord(), soarConfig, agentStatus, alertDedup, &lastAlertPoll) + checkSoarEvents(ctx, db, registry.Wazuh(), registry.Indexer(), registry.AI(), registry.Discord(), soarConfig, triage, agentStatus, alertDedup, &lastAlertPoll) + // Digest hebdo des suppressions de triage (no-op tant que la + // semaine n'est pas écoulée ou qu'il n'y a rien à rapporter). + maybeSendTriageDigest(db, triage, registry.Discord()) } } } @@ -305,6 +313,7 @@ func checkSoarEvents( aiClient services.AIClient, discord *services.DiscordBot, soarConfig *models.SoarConfigState, + triage *services.TriageStore, agentStatus *sync.Map, alertDedup *sync.Map, lastAlertPoll *time.Time, @@ -343,23 +352,38 @@ func checkSoarEvents( } } if agent.Status == "disconnected" { - alertInfo := services.AIAlertContext{ - Title: "🔴 Agent Perdu", - Description: fmt.Sprintf("L'agent **%s** ne répond plus.", agent.Name), - AgentName: agent.Name, - AgentIP: agent.IP, - RuleLevel: 10, + // Triage : une machine volontairement éteinte se classe + // « by design » → transition consommée sans post (et sans + // restaurer l'ancien statut : ce n'est pas un échec d'envoi). + fp := services.TriageAgentStatusFingerprint(agent.Name, "disconnected") + if triageSuppressesAlert(triage, fp) { + recordTriageSuppression(triage, fp) + } else { + recordTriageSeen(triage, fp, "agent-status", agent.Name, "🔴 Agent Perdu", nil) + alertInfo := services.AIAlertContext{ + Title: "🔴 Agent Perdu", + Description: fmt.Sprintf("L'agent **%s** ne répond plus.", agent.Name), + AgentName: agent.Name, + AgentIP: agent.IP, + RuleLevel: 10, + } + go sendEnrichedAlert(ctx, alertInfo, "critical", fp, aiClient, discord, retryOnFail) } - go sendEnrichedAlert(ctx, alertInfo, "critical", aiClient, discord, retryOnFail) } else if agent.Status == "active" && prevStatus == "disconnected" { - alertInfo := services.AIAlertContext{ - Title: "🟢 Agent Retrouvé", - Description: fmt.Sprintf("L'agent **%s** est de nouveau en ligne.", agent.Name), - AgentName: agent.Name, - AgentIP: agent.IP, - RuleLevel: 0, + fp := services.TriageAgentStatusFingerprint(agent.Name, "active") + if triageSuppressesAlert(triage, fp) { + recordTriageSuppression(triage, fp) + } else { + recordTriageSeen(triage, fp, "agent-status", agent.Name, "🟢 Agent Retrouvé", nil) + alertInfo := services.AIAlertContext{ + Title: "🟢 Agent Retrouvé", + Description: fmt.Sprintf("L'agent **%s** est de nouveau en ligne.", agent.Name), + AgentName: agent.Name, + AgentIP: agent.IP, + RuleLevel: 0, + } + go sendEnrichedAlert(ctx, alertInfo, "info", fp, aiClient, discord, retryOnFail) } - go sendEnrichedAlert(ctx, alertInfo, "info", aiClient, discord, retryOnFail) } } } @@ -450,25 +474,39 @@ func checkSoarEvents( } } + // Triage : une empreinte classée « by design » ou « faux positif » + // est supprimée AVANT le post — comptée pour le digest hebdo, + // jamais retentée (même contrat que catégorie désactivée). if shouldSend { - aiCtx := services.AIAlertContext{ - Title: title, - Description: msg, - AgentName: alert.Agent.Name, - AgentIP: alert.Agent.IP, - RuleID: alert.Rule.ID, - RuleLevel: alert.Rule.Level, - FullLog: alert.FullLog, - SourceIP: alert.Data.SrcIP, - } - key := alertKey - go sendEnrichedAlert(ctx, aiCtx, severity, aiClient, discord, func(sent bool) { - if sent { - persistAlertDedup(db, key) - } else { - alertDedup.Delete(key) + fp := services.TriageFingerprint(alert) + if triageSuppressesAlert(triage, fp) { + recordTriageSuppression(triage, fp) + persistAlertDedup(db, alertKey) + } else { + sample, merr := json.Marshal(alert) + if merr != nil { + sample = nil + } + recordTriageSeen(triage, fp, alert.Rule.ID, alert.Agent.Name, title, sample) + aiCtx := services.AIAlertContext{ + Title: title, + Description: msg, + AgentName: alert.Agent.Name, + AgentIP: alert.Agent.IP, + RuleID: alert.Rule.ID, + RuleLevel: alert.Rule.Level, + FullLog: alert.FullLog, + SourceIP: alert.Data.SrcIP, } - }) + key := alertKey + go sendEnrichedAlert(ctx, aiCtx, severity, fp, aiClient, discord, func(sent bool) { + if sent { + persistAlertDedup(db, key) + } else { + alertDedup.Delete(key) + } + }) + } } else { // Catégorie désactivée : on persiste quand même pour ne pas // retraiter la même alerte à chaque tick de la fenêtre. @@ -490,7 +528,8 @@ func checkSoarEvents( // rendre la clé / restaurer l'état sur échec pour retenter au tick suivant). // parentCtx borne l'enrichissement : à l'arrêt du worker, l'appel Ollama en // vol est annulé au lieu de survivre au process. -func sendEnrichedAlert(parentCtx context.Context, alertCtx services.AIAlertContext, severity string, aiClient services.AIClient, discord *services.DiscordBot, onSent func(sent bool)) { +// fingerprint est l'empreinte de triage de l'alerte ("" accepté : pas de boutons). +func sendEnrichedAlert(parentCtx context.Context, alertCtx services.AIAlertContext, severity, fingerprint string, aiClient services.AIClient, discord *services.DiscordBot, onSent func(sent bool)) { if onSent == nil { onSent = func(bool) {} } @@ -502,7 +541,7 @@ func sendEnrichedAlert(parentCtx context.Context, alertCtx services.AIAlertConte msg := alertCtx.Description // 1. Post first — the alert reaches the user within the tick, enriched or not. - messageID, err := discord.SendSoarAlert(alertCtx.Title, msg, severity) + messageID, err := discord.SendSoarAlert(alertCtx.Title, msg, severity, fingerprint) if err != nil { slog.Error("Discord SOAR alert failed", "error", err) onSent(false) @@ -551,7 +590,114 @@ func sendEnrichedAlert(parentCtx context.Context, alertCtx services.AIAlertConte // NB: si un hot-reload Discord intervient entre le post et l'édition, le // snapshot `discord` de ce tick peut pointer sur une session fermée ; // l'édition échoue alors proprement et l'alerte reste en version brute. - if err := discord.EditSoarAlertAnalysis(messageID, alertCtx.Title, msg, severity, analysis); err != nil { + if err := discord.EditSoarAlertAnalysis(messageID, alertCtx.Title, msg, severity, analysis, fingerprint); err != nil { slog.Error("Discord SOAR alert enrichment edit failed", "error", err, "message_id", messageID) } } + +// --- Triage apprenant (suppression avant post + digest hebdo) --- + +// triageSuppressesAlert dit si l'empreinte est classée « bruit » (by design / +// faux positif). FAIL-OPEN : sur erreur DB on n'étouffe JAMAIS une alerte de +// sécurité — elle part, l'erreur est loguée. Store nil-safe (tests db=nil). +func triageSuppressesAlert(triage *services.TriageStore, fingerprint string) bool { + st, _, err := triage.Decision(fingerprint) + if err != nil { + slog.Error("SOAR triage: lookup impossible, alerte envoyée par défaut", "error", err) + return false + } + return services.TriageStatusSuppresses(st) +} + +// recordTriageSuppression compte une occurrence supprimée (best effort : un échec +// de comptage ne change pas la décision de suppression, déjà prise). +func recordTriageSuppression(triage *services.TriageStore, fingerprint string) { + if err := triage.RecordSuppressed(fingerprint); err != nil { + slog.Error("SOAR triage: comptage de suppression impossible", "error", err) + } +} + +// recordTriageSeen upserte la row d'empreinte d'une alerte qui part sur Discord +// (best effort : l'alerte part même si la persistance triage échoue). +func recordTriageSeen(triage *services.TriageStore, fingerprint, ruleID, agentName, title string, sample []byte) { + if err := triage.RecordSeen(fingerprint, ruleID, agentName, title, sample); err != nil { + slog.Error("SOAR triage: enregistrement d'empreinte impossible", "error", err) + } +} + +const ( + // triageDigestStateKey est la clé soar_state du dernier digest envoyé (RFC3339). + triageDigestStateKey = "last_triage_digest" + // triageDigestInterval sépare deux digests de suppression. + triageDigestInterval = 7 * 24 * time.Hour +) + +// maybeSendTriageDigest posts the weekly suppression digest when due — le +// contrat « jamais de silence aveugle » du triage. Appelé à chaque tick : +// - premier passage : ancre la période courante sans rien envoyer ; +// - période non écoulée, ou écoulée sans aucune suppression : avance sans post ; +// - Discord indisponible ou envoi échoué : l'ancre n'avance PAS, on retente au +// tick suivant (un digest ne doit pas se perdre sur un hoquet) ; +// - envoi réussi : les compteurs rapportés sont décrémentés puis l'ancre avance. +func maybeSendTriageDigest(db *sql.DB, triage *services.TriageStore, discord *services.DiscordBot) { + if db == nil { + return + } + now := time.Now() + v, err := loadSoarState(db, triageDigestStateKey) + if err != nil { + slog.Error("SOAR triage digest: lecture de l'ancre impossible", "error", err) + return + } + if v == "" { + if serr := saveSoarState(db, triageDigestStateKey, now.Format(time.RFC3339)); serr != nil { + slog.Error("SOAR triage digest: ancrage initial impossible", "error", serr) + } + return + } + last, perr := time.Parse(time.RFC3339, v) + if perr != nil { + // Ancre corrompue : réancrer plutôt que de bloquer le digest pour toujours. + slog.Warn("SOAR triage digest: ancre illisible, réancrage", "value", v) + _ = saveSoarState(db, triageDigestStateKey, now.Format(time.RFC3339)) + return + } + if last.After(now) { + // Ancre dans le futur (horloge corrigée en arrière après un ancrage sous + // une horloge en avance) : sans réancrage, le digest resterait muet + // jusqu'à ce que le temps réel rattrape l'ancre — un silence aveugle. + slog.Warn("SOAR triage digest: ancre dans le futur, réancrage", "value", v) + _ = saveSoarState(db, triageDigestStateKey, now.Format(time.RFC3339)) + return + } + if now.Sub(last) < triageDigestInterval { + return + } + + rows, err := triage.DigestRows() + if err != nil { + slog.Error("SOAR triage digest: lecture des compteurs impossible", "error", err) + return + } + if len(rows) == 0 { + // Rien supprimé cette période : rien d'invisible à resurfacer, pas de bruit. + _ = saveSoarState(db, triageDigestStateKey, now.Format(time.RFC3339)) + return + } + if discord == nil || !discord.IsReady() { + return + } + if err := discord.SendTriageDigest(rows); err != nil { + slog.Error("SOAR triage digest: envoi Discord échoué, nouvel essai au tick suivant", "error", err) + return + } + if err := triage.ResetDigestCounts(rows); err != nil { + // Le digest est parti mais les compteurs n'ont pas été décrémentés : le + // prochain digest re-rapportera ces suppressions (doublon visible, jamais + // de perte). On avance l'ancre quand même. + slog.Error("SOAR triage digest: reset des compteurs échoué", "error", err) + } + if err := saveSoarState(db, triageDigestStateKey, now.Format(time.RFC3339)); err != nil { + slog.Error("SOAR triage digest: avancement de l'ancre impossible", "error", err) + } +} diff --git a/internal/workers/soar_atleastonce_test.go b/internal/workers/soar_atleastonce_test.go index f93e7c5..9ac5b9a 100644 --- a/internal/workers/soar_atleastonce_test.go +++ b/internal/workers/soar_atleastonce_test.go @@ -17,7 +17,7 @@ func TestSendEnrichedAlertOnSentFailure(t *testing.T) { onSent := func(sent bool) { got = &sent } // discord == nil → échec de livraison garanti, sans I/O réseau. - sendEnrichedAlert(context.Background(), services.AIAlertContext{Title: "x", Description: "y"}, "critical", nil, nil, onSent) + sendEnrichedAlert(context.Background(), services.AIAlertContext{Title: "x", Description: "y"}, "critical", "", nil, nil, onSent) if got == nil { t.Fatal("onSent n'a pas été appelé") @@ -40,7 +40,7 @@ func TestDedupReleaseOnFailure(t *testing.T) { dedup.Delete(key) } } - sendEnrichedAlert(context.Background(), services.AIAlertContext{Title: "x", Description: "y"}, "critical", nil, nil, onSent) + sendEnrichedAlert(context.Background(), services.AIAlertContext{Title: "x", Description: "y"}, "critical", "", nil, nil, onSent) if _, still := dedup.Load(key); still { t.Fatal("la clé est restée dedupée après échec de post — alerte perdue à jamais") diff --git a/internal/workers/soar_cursor_test.go b/internal/workers/soar_cursor_test.go index 4f1ad6d..6af9919 100644 --- a/internal/workers/soar_cursor_test.go +++ b/internal/workers/soar_cursor_test.go @@ -95,6 +95,7 @@ func runTick(t *testing.T, indexer *services.WazuhIndexerClient, cursor time.Tim indexer, nil, nil, cfg, + nil, // triage : store nil-safe, aucun classement → aucune suppression &sync.Map{}, &sync.Map{}, &cursor, ) diff --git a/internal/workers/soar_triage_test.go b/internal/workers/soar_triage_test.go new file mode 100644 index 0000000..247b1e2 --- /dev/null +++ b/internal/workers/soar_triage_test.go @@ -0,0 +1,206 @@ +package workers + +import ( + "context" + "database/sql" + "sync" + "testing" + "time" + + "goacore/internal/models" + "goacore/internal/services" +) + +// Ces tests couvrent l'intégration du triage apprenant dans le worker SOAR : +// suppression AVANT post des empreintes classées « bruit », comptage pour le +// digest hebdo, et innocuité totale sans base (db=nil, store nil). + +// TestTriageSuppressesAlertNilStore : sans store (tests, boot sans DB), aucune +// suppression — le comportement pré-triage, fail-open. +func TestTriageSuppressesAlertNilStore(t *testing.T) { + if triageSuppressesAlert(nil, "deadbeef") { + t.Fatal("un store nil supprime des alertes") + } + if triageSuppressesAlert(services.NewTriageStore(nil), "deadbeef") { + t.Fatal("un store sans DB supprime des alertes") + } +} + +// TestMaybeSendTriageDigestNilSafe : le digest ne doit jamais faire tomber le +// tick — db nil, store nil, discord nil, tout dégrade en no-op. +func TestMaybeSendTriageDigestNilSafe(t *testing.T) { + maybeSendTriageDigest(nil, nil, nil) + maybeSendTriageDigest(nil, services.NewTriageStore(nil), nil) +} + +// triageWorkerDB étend le harnais MySQL du worker avec la table soar_triage +// (schéma identique à Migrate), en plus de soar_state/soar_alert_dedup. +func triageWorkerDB(t *testing.T) *sql.DB { + t.Helper() + db := testDB(t) + for _, q := range []string{ + `DROP TABLE IF EXISTS soar_triage`, + `CREATE TABLE 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) + )`, + } { + if _, err := db.Exec(q); err != nil { + t.Fatalf("schema %q: %v", q, err) + } + } + return db +} + +// stubAlert reconstruit l'alerte servie par stubIndexer (soar_cursor_test.go) +// pour calculer son empreinte de triage côté test. +func stubAlert() services.WazuhAlert { + var a services.WazuhAlert + a.Rule.ID = "5710" + a.Rule.Description = "sshd auth failed" + a.Agent.ID = "001" + a.Agent.Name = "srv" + return a +} + +// TestCheckSoarEventsTriageSuppression : une empreinte classée « by design » +// éteint le post (aucun fan-out) mais JAMAIS en silence — chaque occurrence est +// comptée pour le digest et dedupée pour ne pas être retraitée au tick suivant. +func TestCheckSoarEventsTriageSuppression(t *testing.T) { + db := triageWorkerDB(t) + defer db.Close() + triage := services.NewTriageStore(db) + + fp := services.TriageFingerprint(stubAlert()) + if err := triage.SetDecision(fp, services.TriageByDesign, "abraham", "1234567890"); err != nil { + t.Fatalf("SetDecision: %v", err) + } + + indexer, _ := stubIndexer(t, 3) + cfg := &models.SoarConfigState{Loaded: true, Config: models.SoarConfig{AlertSSH: true}} + cursor := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) + checkSoarEvents( + context.Background(), + db, + &services.WazuhClient{}, + indexer, + nil, nil, // IA et Discord absents : une suppression n'en a pas besoin + cfg, + triage, + &sync.Map{}, &sync.Map{}, + &cursor, + ) + + var since, total int + if err := db.QueryRow( + "SELECT suppressed_since_digest, count_suppressed FROM soar_triage WHERE fingerprint = ?", fp). + Scan(&since, &total); err != nil { + t.Fatalf("lecture compteurs: %v", err) + } + if since != 3 || total != 3 { + t.Fatalf("compteurs (%d, %d), attendu (3, 3) — chaque occurrence supprimée doit être comptée", since, total) + } + var dedup int + if err := db.QueryRow("SELECT COUNT(*) FROM soar_alert_dedup").Scan(&dedup); err != nil { + t.Fatalf("lecture dedup: %v", err) + } + if dedup != 3 { + t.Fatalf("%d clés dedup persistées, attendu 3 — une alerte supprimée ne doit pas être retraitée au tick suivant", dedup) + } +} + +// TestCheckSoarEventsTriageRecordsOpen : une alerte NON classée part sur Discord +// (ici : échec propre, bot nil) et sa row d'empreinte est upsertée en « open » +// avec un sample — la matière du classement futur et de la phase 2 (similarité). +func TestCheckSoarEventsTriageRecordsOpen(t *testing.T) { + db := triageWorkerDB(t) + defer db.Close() + triage := services.NewTriageStore(db) + + indexer, _ := stubIndexer(t, 2) + cfg := &models.SoarConfigState{Loaded: true, Config: models.SoarConfig{AlertSSH: true}} + cursor := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) + checkSoarEvents( + context.Background(), + db, + &services.WazuhClient{}, + indexer, + nil, nil, + cfg, + triage, + &sync.Map{}, &sync.Map{}, + &cursor, + ) + + fp := services.TriageFingerprint(stubAlert()) + var status, ruleID, agentName string + var sample []byte + if err := db.QueryRow( + "SELECT status, rule_id, agent_name, sample_alert FROM soar_triage WHERE fingerprint = ?", fp). + Scan(&status, &ruleID, &agentName, &sample); err != nil { + t.Fatalf("row d'empreinte absente après une alerte postée: %v", err) + } + if status != services.TriageOpen || ruleID != "5710" || agentName != "srv" { + t.Fatalf("row (%q, %q, %q), attendu (open, 5710, srv)", status, ruleID, agentName) + } + if len(sample) == 0 { + t.Fatal("sample_alert vide — la phase 2 (similarité) n'aura pas de matière") + } +} + +// TestMaybeSendTriageDigestAnchor verrouille la machine à états de l'ancre : +// premier passage = ancrage sans envoi ; période écoulée sans suppression = +// avance silencieuse ; période écoulée AVEC suppressions mais Discord absent = +// l'ancre n'avance PAS (le digest sera retenté, jamais perdu). +func TestMaybeSendTriageDigestAnchor(t *testing.T) { + db := triageWorkerDB(t) + defer db.Close() + triage := services.NewTriageStore(db) + + // 1. Premier passage : ancre posée, rien envoyé. + maybeSendTriageDigest(db, triage, nil) + anchor, err := loadSoarState(db, triageDigestStateKey) + if err != nil || anchor == "" { + t.Fatalf("ancre absente après le premier passage (%q, %v)", anchor, err) + } + + // 2. Période écoulée, aucune suppression : l'ancre avance sans envoi. + old := time.Now().Add(-8 * 24 * time.Hour).Format(time.RFC3339) + if err := saveSoarState(db, triageDigestStateKey, old); err != nil { + t.Fatalf("saveSoarState: %v", err) + } + maybeSendTriageDigest(db, triage, nil) + anchor, _ = loadSoarState(db, triageDigestStateKey) + if anchor == old { + t.Fatal("période écoulée sans suppression : l'ancre doit avancer (sinon relecture à chaque tick)") + } + + // 3. Période écoulée AVEC suppressions, Discord indisponible : l'ancre reste. + if err := saveSoarState(db, triageDigestStateKey, old); err != nil { + t.Fatalf("saveSoarState: %v", err) + } + fp := services.TriageFingerprint(stubAlert()) + if err := triage.SetDecision(fp, services.TriageByDesign, "abraham", "1234567890"); err != nil { + t.Fatalf("SetDecision: %v", err) + } + if err := triage.RecordSuppressed(fp); err != nil { + t.Fatalf("RecordSuppressed: %v", err) + } + maybeSendTriageDigest(db, triage, nil) + anchor, _ = loadSoarState(db, triageDigestStateKey) + if anchor != old { + t.Fatal("Discord indisponible : l'ancre a avancé, le digest de suppressions est perdu") + } +} diff --git a/schema.sql b/schema.sql index ed093d1..63cfd36 100644 --- a/schema.sql +++ b/schema.sql @@ -106,6 +106,28 @@ CREATE TABLE IF NOT EXISTS soar_alert_dedup ( INDEX idx_seen_at (seen_at) ); +-- Triage apprenant des alertes SOAR : une row par empreinte d'alerte +-- (sha256 rule.id|agent|champ-clé). Les statuts by_design/false_positive +-- suppriment les occurrences futures AVANT le post Discord ; chaque suppression +-- est comptée (jamais de silence aveugle) et resurfacée par le digest hebdo, +-- qui décrémente suppressed_since_digest de ce qu'il a rapporté. +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) +); + -- Journal d'audit. idx_created_at porte la lecture antéchronologique et la -- purge par date (la rétention est appliquée hors de ce fichier). CREATE TABLE IF NOT EXISTS audit_logs (