From e79c90bc48a21ae0bcaf2c8c643bf78cb9bf69c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 27 Jul 2026 13:46:52 -0600 Subject: [PATCH 1/4] feat[frontend](alerting-rules): added individual export button --- .../alerting-rules/pages/AlertingRulesPage.tsx | 16 ++++++++++++++++ frontend/src/shared/i18n/locales/de.json | 1 + frontend/src/shared/i18n/locales/en.json | 1 + frontend/src/shared/i18n/locales/es.json | 1 + frontend/src/shared/i18n/locales/fr.json | 1 + frontend/src/shared/i18n/locales/it.json | 1 + frontend/src/shared/i18n/locales/pt.json | 1 + frontend/src/shared/i18n/locales/ru.json | 1 + 8 files changed, 23 insertions(+) diff --git a/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx b/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx index 8b4b49c4e..ab83af51b 100644 --- a/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx +++ b/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx @@ -7,6 +7,7 @@ import { CheckCircle2, Code2, Crosshair, + Download, FlaskConical, LayoutList, Loader2, @@ -506,6 +507,7 @@ function RuleDrawer({
{rule && !readOnly && !showForm && } + {rule && } {rule && !readOnly && onDelete && } {rule && onToggle && onToggle(rule, v)} />} @@ -769,6 +771,20 @@ function Center({ children }: { children: React.ReactNode }) { return
{children}
} +function downloadRuleYaml(rule: CorrelationRule): void { + const yaml = ruleFormToYaml(ruleToForm(rule)) + const base = rule.relPath.split('/').pop() || `${rule.name || 'rule'}.yaml` + const name = /\.ya?ml$/i.test(base) ? base : `${base}.yaml` + const url = URL.createObjectURL(new Blob([yaml], { type: 'text/yaml;charset=utf-8' })) + const a = document.createElement('a') + a.href = url + a.download = name + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + function hasItems(v: unknown): boolean { return Array.isArray(v) ? v.length > 0 : v != null && typeof v === 'object' && Object.keys(v).length > 0 } diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index 5f4da509a..d9d35874d 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -4178,6 +4178,7 @@ "createTitle": "Neue Regel", "edit": "Bearbeiten", "delete": "Löschen", + "export": "Exportieren", "cancel": "Abbrechen", "save": "Speichern", "metadata": "Metadaten", diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index ad96e1f3a..b11b6064e 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -4674,6 +4674,7 @@ "createTitle": "New rule", "edit": "Edit", "delete": "Delete", + "export": "Export", "cancel": "Cancel", "save": "Save", "test": "Test", diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index 993191d7a..90666439c 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -4379,6 +4379,7 @@ "createTitle": "Nueva regla", "edit": "Editar", "delete": "Eliminar", + "export": "Exportar", "cancel": "Cancelar", "save": "Guardar", "metadata": "Metadatos", diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index 67daa1e82..35125b8c2 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -4178,6 +4178,7 @@ "createTitle": "Nouvelle règle", "edit": "Modifier", "delete": "Supprimer", + "export": "Exporter", "cancel": "Annuler", "save": "Enregistrer", "metadata": "Métadonnées", diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index cad035bd8..1e38b3844 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -4178,6 +4178,7 @@ "createTitle": "Nuova regola", "edit": "Modifica", "delete": "Elimina", + "export": "Esporta", "cancel": "Annulla", "save": "Salva", "metadata": "Metadati", diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index cbdba19cd..b60848fa7 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -4379,6 +4379,7 @@ "createTitle": "Nova regra", "edit": "Editar", "delete": "Excluir", + "export": "Exportar", "cancel": "Cancelar", "save": "Salvar", "metadata": "Metadados", diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index fc0aa2f4c..5148a3ae6 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -3988,6 +3988,7 @@ "createTitle": "Новое правило", "edit": "Изменить", "delete": "Удалить", + "export": "Экспортировать", "cancel": "Отмена", "save": "Сохранить", "metadata": "Метаданные", From 3fa459d1282c3c10f8fc5daad108b1b263bb22e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 27 Jul 2026 14:12:41 -0600 Subject: [PATCH 2/4] feat[backend](alerting-rules): aded bulk export support --- .../eventprocessing/connectors/usecase.go | 1 + .../eventprocessing/dto/correlation_rule.go | 12 +++++ .../handler/correlation_rule.go | 54 +++++++++++++++++++ backend/modules/eventprocessing/routes.go | 1 + .../usecase/correlation_rule.go | 15 ++++++ .../eventprocessing/usecase/rule_store.go | 27 ++++++++++ 6 files changed, 110 insertions(+) diff --git a/backend/modules/eventprocessing/connectors/usecase.go b/backend/modules/eventprocessing/connectors/usecase.go index 9c6638ea9..8d35ef684 100644 --- a/backend/modules/eventprocessing/connectors/usecase.go +++ b/backend/modules/eventprocessing/connectors/usecase.go @@ -40,6 +40,7 @@ type CorrelationRuleUsecase interface { Delete(ctx context.Context, relPath string) error SetActive(ctx context.Context, relPath string, active bool) (bool, error) FindDistinctPropertyValues(ctx context.Context, prop, value string) ([]string, error) + ExportRules(ctx context.Context, relPaths []string) ([]dto.ExportedRuleFile, error) } type FilterUsecase interface { diff --git a/backend/modules/eventprocessing/dto/correlation_rule.go b/backend/modules/eventprocessing/dto/correlation_rule.go index 0d58af5a2..2b34a9e9b 100644 --- a/backend/modules/eventprocessing/dto/correlation_rule.go +++ b/backend/modules/eventprocessing/dto/correlation_rule.go @@ -153,3 +153,15 @@ type ImportCorrelationRulesResponse struct { Approved int `json:"approved"` Rejected int `json:"rejected"` } + +// ExportCorrelationRulesRequest carries the identifiers (relPaths) of the rules +// to bundle into a zip. Empty list means "all rules". +type ExportCorrelationRulesRequest struct { + RelPaths []string `json:"relPaths"` +} + +// ExportedRuleFile is one rule YAML resolved from a relPath. +type ExportedRuleFile struct { + Filename string + Content []byte +} diff --git a/backend/modules/eventprocessing/handler/correlation_rule.go b/backend/modules/eventprocessing/handler/correlation_rule.go index dbf68cbea..b63d14dc4 100644 --- a/backend/modules/eventprocessing/handler/correlation_rule.go +++ b/backend/modules/eventprocessing/handler/correlation_rule.go @@ -1,7 +1,10 @@ package handler import ( + "archive/zip" + "bytes" "net/http" + "path/filepath" "strconv" "github.com/gin-gonic/gin" @@ -86,6 +89,57 @@ func (h *CorrelationRuleHandler) Import(c *gin.Context) { }) } +// @Summary Export correlation rules as a zip +// @Description Bundles the requested rules (by relPath) into a single zip of their YAML files. +// @Tags Correlation Rules +// @Security BearerAuth +// @Accept json +// @Produce application/zip +// @Param input body dto.ExportCorrelationRulesRequest true "Rule identifiers to export" +// @Success 200 {file} binary +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /correlation-rule/export [post] +func (h *CorrelationRuleHandler) Export(c *gin.Context) { + var req dto.ExportCorrelationRulesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + files, err := h.usecase.ExportRules(c.Request.Context(), req.RelPaths) + if err != nil { + if isNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "correlation rule not found"}) + return + } + writeCorrelationError(c, err) + return + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, f := range files { + w, werr := zw.Create(filepath.Base(f.Filename)) + if werr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "zip create failed"}) + return + } + if _, werr := w.Write(f.Content); werr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "zip write failed"}) + return + } + } + if err := zw.Close(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "zip close failed"}) + return + } + + c.Header("Content-Disposition", `attachment; filename="correlation-rules.zip"`) + c.Data(http.StatusOK, "application/zip", buf.Bytes()) +} + // @Summary Update correlation rule // @Tags Correlation Rules // @Security BearerAuth diff --git a/backend/modules/eventprocessing/routes.go b/backend/modules/eventprocessing/routes.go index 863464132..6d0abc0de 100644 --- a/backend/modules/eventprocessing/routes.go +++ b/backend/modules/eventprocessing/routes.go @@ -31,6 +31,7 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { cr := g.Group("/correlation-rule") cr.POST("", write, crh.Create) cr.POST("/import", write, crh.Import) + cr.POST("/export", read, crh.Export) cr.PUT("/activate-deactivate", write, crh.ActivateDeactivate) cr.PUT("", write, crh.Update) cr.GET("/search-by-filters", read, crh.List) diff --git a/backend/modules/eventprocessing/usecase/correlation_rule.go b/backend/modules/eventprocessing/usecase/correlation_rule.go index 9ebfee05d..2fe214da2 100644 --- a/backend/modules/eventprocessing/usecase/correlation_rule.go +++ b/backend/modules/eventprocessing/usecase/correlation_rule.go @@ -203,6 +203,21 @@ func (u *correlationRuleUsecase) FindDistinctPropertyValues(_ context.Context, p return u.store.DistinctValues(prop, value), nil } +func (u *correlationRuleUsecase) ExportRules(_ context.Context, relPaths []string) ([]dto.ExportedRuleFile, error) { + if len(relPaths) == 0 { + relPaths = u.store.AllRelPaths() + } + out := make([]dto.ExportedRuleFile, 0, len(relPaths)) + for _, rel := range relPaths { + data, err := u.store.ReadRuleBytes(rel) + if err != nil { + return nil, mapStoreErr(err) + } + out = append(out, dto.ExportedRuleFile{Filename: rel, Content: data}) + } + return out, nil +} + // ── mappers ─────────────────────────────────────────────────────────────────── func buildRule(name, adversary string, conf, integ, avail int, category, technique, description string, diff --git a/backend/modules/eventprocessing/usecase/rule_store.go b/backend/modules/eventprocessing/usecase/rule_store.go index 95814ba01..80a421ba2 100644 --- a/backend/modules/eventprocessing/usecase/rule_store.go +++ b/backend/modules/eventprocessing/usecase/rule_store.go @@ -378,6 +378,33 @@ func (s *RuleStore) SetEnabled(relPath string, enabled bool) (bool, error) { return true, nil } +// AllRelPaths returns every known rule identity in load order (system first, +// then user). +func (s *RuleStore) AllRelPaths() []string { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]string, 0, len(s.rules)) + for _, sr := range s.rules { + out = append(out, sr.RelPath) + } + return out +} + +// ReadRuleBytes returns the raw on-disk YAML for a rule, preserving comments +// and formatting. Only rules present in the index are readable, so relPath is +// safe against traversal (it must match a known entry). +func (s *RuleStore) ReadRuleBytes(relPath string) ([]byte, error) { + s.mu.RLock() + sr, ok := s.index[relPath] + if !ok { + s.mu.RUnlock() + return nil, ErrRuleNotFound + } + abs := s.absPath(sr) + s.mu.RUnlock() + return os.ReadFile(abs) +} + // DistinctValues returns the distinct values of a rule property, optionally // filtered to those containing `value` (case-insensitive). prop is a legacy // column name (rule_name, rule_category, rule_technique, rule_adversary). From 5c7f36f36e72e7128d0e87631358c2a0c22644b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 27 Jul 2026 14:13:38 -0600 Subject: [PATCH 3/4] feat[frontend](alerting-rules): added bulk alerting rules export --- .../pages/AlertingRulesPage.tsx | 72 ++++++++++++++++++- .../services/alerting-rules-http.service.ts | 3 + frontend/src/shared/i18n/locales/de.json | 9 ++- frontend/src/shared/i18n/locales/en.json | 9 ++- frontend/src/shared/i18n/locales/es.json | 9 ++- frontend/src/shared/i18n/locales/fr.json | 9 ++- frontend/src/shared/i18n/locales/it.json | 9 ++- frontend/src/shared/i18n/locales/pt.json | 9 ++- frontend/src/shared/i18n/locales/ru.json | 9 ++- 9 files changed, 128 insertions(+), 10 deletions(-) diff --git a/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx b/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx index ab83af51b..488a02a13 100644 --- a/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx +++ b/frontend/src/features/alerting-rules/pages/AlertingRulesPage.tsx @@ -43,7 +43,7 @@ import { ruleFormToYaml, yamlToRuleForm } from '../lib/rule-yaml' import { parseCelTree, type CelNode } from '../lib/cel-tree' const SELECT_CLS = 'h-9 rounded-md border border-border bg-background px-2 text-sm' -const COLS = '1.4fr 1fr 110px 100px 80px 48px 50px' +const COLS = '32px 1.4fr 1fr 110px 100px 80px 48px 50px' function maxImpact(r: { confidentiality: number; integrity: number; availability: number }): number { return Math.max(r.confidentiality, r.integrity, r.availability) @@ -90,6 +90,37 @@ export function AlertingRulesPage() { const [importBusy, setImportBusy] = useState(false) const [importResults, setImportResults] = useState(null) const [showTestModal, setShowTestModal] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [exportBusy, setExportBusy] = useState(false) + + const toggleSelected = useCallback((relPath: string) => { + setSelected((cur) => { + const next = new Set(cur) + if (next.has(relPath)) next.delete(relPath) + else next.add(relPath) + return next + }) + }, []) + + const exportSelected = async () => { + if (exportBusy) return + setExportBusy(true) + try { + const blob = await svc.exportRules([...selected]) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `alerting-rules-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.zip` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + } catch (e) { + toast.error(e instanceof AlertingRulesHttpError ? e.message : t('alertingRules.export.error')) + } finally { + setExportBusy(false) + } + } const onImportFiles = async (fileList: FileList | null) => { if (!fileList || fileList.length === 0) return @@ -216,6 +247,10 @@ export function AlertingRulesPage() { {importBusy ? : } {t('alertingRules.import.button')} +