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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/modules/eventprocessing/connectors/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions backend/modules/eventprocessing/dto/correlation_rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
54 changes: 54 additions & 0 deletions backend/modules/eventprocessing/handler/correlation_rule.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package handler

import (
"archive/zip"
"bytes"
"net/http"
"path/filepath"
"strconv"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/modules/eventprocessing/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions backend/modules/eventprocessing/usecase/correlation_rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions backend/modules/eventprocessing/usecase/rule_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { TFunction } from 'i18next'
import { ConditionList } from './condition-list'
import type { AfterStep } from './rule-form'

export function AfterEventsView({ steps, t }: { steps: AfterStep[]; t: TFunction }) {
return (
<div className="space-y-2">
{steps.map((s, i) => (
<div key={i} className="rounded-md border border-border bg-card p-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
<span>{t('alertingRules.editor.indexPattern')}: <span className="font-mono text-foreground">{s.indexPattern || '—'}</span></span>
<span>{t('alertingRules.editor.within')}: <span className="font-mono text-foreground">{s.within || '—'}</span></span>
<span>{t('alertingRules.editor.count')} ≥ <span className="font-mono text-foreground">{s.count}</span></span>
</div>
{s.with.length > 0 && <ConditionList conds={s.with} t={t} />}
{s.or.map((g, gi) => (
<div key={gi} className="mt-1.5 border-t border-border/60 pt-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{t('alertingRules.where.or')}</span>
<ConditionList conds={g.with} t={t} />
</div>
))}
</div>
))}
</div>
)
}
34 changes: 34 additions & 0 deletions frontend/src/features/alerting-rules/components/cel-node-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { TFunction } from 'i18next'
import { cn } from '@/shared/lib/utils'
import type { CelNode } from '../lib/cel-tree'

export function CelNodeView({ node, t, depth }: { node: CelNode; t: TFunction; depth: number }) {
if (node.type === 'cond') {
return (
<div className="flex flex-wrap items-center gap-1.5 text-xs">
{node.negate && <span className="rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-red-500">not</span>}
<span className="rounded bg-background px-1.5 py-0.5 font-mono text-[11px]">{node.field}</span>
<span className="text-[11px] text-muted-foreground">{t(`alertingRules.celOp.${node.fn}`)}</span>
{node.values.filter(Boolean).map((v, i) => (
<span key={i} className="rounded bg-primary/10 px-1.5 py-0.5 font-mono text-[11px] text-primary">{v}</span>
))}
</div>
)
}
const connector = node.type === 'and' ? t('alertingRules.where.and') : t('alertingRules.where.or')
return (
<div className={cn('space-y-1.5', depth > 0 && 'rounded-md border border-border/60 bg-background/30 p-2')}>
{node.negate && (
<span className="inline-block rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-red-500">not</span>
)}
{node.children.map((child, i) => (
<div key={i}>
{i > 0 && (
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">{connector}</div>
)}
<CelNodeView node={child} t={t} depth={depth + 1} />
</div>
))}
</div>
)
}
5 changes: 5 additions & 0 deletions frontend/src/features/alerting-rules/components/center.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { ReactNode } from 'react'

export function Center({ children }: { children: ReactNode }) {
return <div className="mt-4 flex flex-1 items-center justify-center gap-2 rounded-xl border border-border bg-card text-sm text-muted-foreground">{children}</div>
}
15 changes: 15 additions & 0 deletions frontend/src/features/alerting-rules/components/condition-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { TFunction } from 'i18next'

export function ConditionList({ conds, t }: { conds: { field: string; operator: string; value: string }[]; t: TFunction }) {
return (
<div className="mt-1.5 space-y-1">
{conds.map((c, i) => (
<div key={i} className="flex flex-wrap items-center gap-1.5 text-xs">
<span className="rounded bg-background px-1.5 py-0.5 font-mono text-[11px]">{c.field}</span>
<span className="text-[11px] text-muted-foreground">{t(`alertingRules.operator.${c.operator}`)}</span>
{c.value && <span className="rounded bg-primary/10 px-1.5 py-0.5 font-mono text-[11px] text-primary">{c.value}</span>}
</div>
))}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { TFunction } from 'i18next'
import { parseCelTree } from '../lib/cel-tree'
import { CelNodeView } from './cel-node-view'

export function DefinitionView({ definition, t }: { definition: string; t: TFunction }) {
const tree = parseCelTree(definition)
if (!tree) {
return <pre className="overflow-x-auto rounded-md border border-border bg-card p-3 font-mono text-[11px] leading-relaxed">{definition || '—'}</pre>
}
return <CelNodeView node={tree} t={t} depth={0} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { TFunction } from 'i18next'
import { CheckCircle2, Upload, X, XCircle } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import type { ImportRulesResponse } from '../services/alerting-rules-http.service'

export function ImportResultsDialog({ res, onClose, t }: { res: ImportRulesResponse; onClose: () => void; t: TFunction }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="flex max-h-[80vh] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-border bg-card shadow-xl" onClick={(e) => e.stopPropagation()}>
<header className="flex items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="flex items-center gap-2 text-base font-semibold">
<Upload size={16} /> {t('alertingRules.import.resultTitle')}
</h2>
<button onClick={onClose} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
<X size={16} />
</button>
</header>

<div className="flex shrink-0 items-center gap-4 border-b border-border bg-muted/20 px-5 py-2.5 text-xs">
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={14} /> {t('alertingRules.import.approved')}: <b>{res.approved}</b>
</span>
<span className="inline-flex items-center gap-1.5 text-red-600 dark:text-red-400">
<XCircle size={14} /> {t('alertingRules.import.rejected')}: <b>{res.rejected}</b>
</span>
</div>

<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="space-y-1.5">
{res.results.map((r, i) => (
<div key={i} className="flex items-start gap-2 rounded-md border border-border px-3 py-2 text-xs">
{r.approved ? (
<CheckCircle2 size={15} className="mt-0.5 shrink-0 text-emerald-500" />
) : (
<XCircle size={15} className="mt-0.5 shrink-0 text-red-500" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2">
<span className="font-mono text-[11px] text-muted-foreground">{r.filename}</span>
{r.name && <span className="font-medium">{r.name}</span>}
</div>
{!r.approved && r.error && <p className="mt-0.5 text-[11px] text-red-600 dark:text-red-400">{r.error}</p>}
</div>
</div>
))}
</div>
</div>

<footer className="flex justify-end border-t border-border px-5 py-3">
<Button size="sm" onClick={onClose}>{t('alertingRules.import.done')}</Button>
</footer>
</div>
</div>
)
}
10 changes: 10 additions & 0 deletions frontend/src/features/alerting-rules/components/row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactNode } from 'react'

export function Row({ k, children }: { k: string; children: ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{k}</dt>
<dd className="min-w-0">{children}</dd>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { ReactNode } from 'react'

function inlineFmt(text: string): ReactNode[] {
const out: ReactNode[] = []
const re = /\*\*([^*]+)\*\*|`([^`]+)`/g
let last = 0
let key = 0
let m: RegExpExecArray | null
while ((m = re.exec(text))) {
if (m.index > last) out.push(text.slice(last, m.index))
if (m[1] != null) out.push(<strong key={key++} className="font-semibold text-foreground">{m[1]}</strong>)
else if (m[2] != null) out.push(<code key={key++} className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{m[2]}</code>)
last = m.index + m[0].length
}
if (last < text.length) out.push(text.slice(last))
return out
}

export function RuleDescription({ text }: { text: string }) {
const lines = text.split('\n')
const blocks: ReactNode[] = []
let i = 0
let key = 0
const isOl = (l: string) => /^\d+[.)]\s+/.test(l.trim())
const isUl = (l: string) => /^[-*]\s+/.test(l.trim())

while (i < lines.length) {
if (lines[i].trim() === '') {
i++
continue
}
if (isOl(lines[i])) {
const items: string[] = []
while (i < lines.length && isOl(lines[i])) items.push(lines[i++].trim().replace(/^\d+[.)]\s+/, ''))
blocks.push(<ol key={key++} className="ml-5 list-decimal space-y-1">{items.map((it, j) => <li key={j}>{inlineFmt(it)}</li>)}</ol>)
continue
}
if (isUl(lines[i])) {
const items: string[] = []
while (i < lines.length && isUl(lines[i])) items.push(lines[i++].trim().replace(/^[-*]\s+/, ''))
blocks.push(<ul key={key++} className="ml-5 list-disc space-y-1">{items.map((it, j) => <li key={j}>{inlineFmt(it)}</li>)}</ul>)
continue
}
const para: string[] = []
while (i < lines.length && lines[i].trim() !== '' && !isOl(lines[i]) && !isUl(lines[i])) para.push(lines[i++].trim())
blocks.push(<p key={key++}>{inlineFmt(para.join(' '))}</p>)
}
return <div className="space-y-2 text-xs leading-relaxed text-muted-foreground">{blocks}</div>
}
Loading
Loading