Files
jbergner e9d9583f28
Some checks failed
release-tag / release-image (push) Failing after 1m8s
Funktionsrollback
2026-07-24 07:17:38 +02:00

405 lines
16 KiB
Go

package postgres
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"strings"
"time"
"example.com/siem-greenfield/internal/rules"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrUnauthorized = errors.New("unauthorized")
type Store struct{ Pool *pgxpool.Pool }
type Agent struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
LastIP string `json:"last_ip"`
Enabled bool `json:"enabled"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
type Detection struct {
ID int64 `json:"id"`
Fingerprint string `json:"fingerprint"`
RuleID string `json:"rule_id"`
RuleSetID string `json:"rule_set_id"`
RuleName string `json:"rule_name"`
Severity string `json:"severity"`
Status string `json:"status"`
Hostname string `json:"hostname"`
UserName string `json:"user_name"`
SourceIP string `json:"source_ip"`
Workstation string `json:"workstation"`
Summary string `json:"summary"`
EventCode uint32 `json:"event_code"`
Score float64 `json:"score"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
Count int64 `json:"count"`
Tags []string `json:"tags"`
MITRE []string `json:"mitre"`
}
type RuleSetRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version int `json:"version"`
Enabled bool `json:"enabled"`
Source string `json:"source"`
Locked bool `json:"locked"`
RuleCount int64 `json:"rule_count"`
EnabledRules int64 `json:"enabled_rules"`
UpdatedAt time.Time `json:"updated_at"`
}
type Suppression struct {
ID int64 `json:"id"`
RuleID string `json:"rule_id"`
HostPattern string `json:"host_pattern"`
UserPattern string `json:"user_pattern"`
SourceIPPattern string `json:"source_ip_pattern"`
Reason string `json:"reason"`
Enabled bool `json:"enabled"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func Open(ctx context.Context, url string) (*Store, error) {
p, e := pgxpool.New(ctx, url)
if e != nil {
return nil, e
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if e = p.Ping(cctx); e != nil {
p.Close()
return nil, e
}
return &Store{Pool: p}, nil
}
func (s *Store) Close() { s.Pool.Close() }
func (s *Store) AuthenticateOrEnroll(ctx context.Context, tenant, hostname, apiKey, enrollmentKey, expectedEnrollment, remoteIP string) (string, error) {
hostname = strings.TrimSpace(hostname)
apiKey = strings.TrimSpace(apiKey)
var id, hash string
var enabled bool
err := s.Pool.QueryRow(ctx, `SELECT id::text, api_key_hash, enabled FROM agents WHERE tenant_id=$1 AND hostname=$2`, tenant, hostname).Scan(&id, &hash, &enabled)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
if enrollmentKey == "" || !secureEqual(hashHex(enrollmentKey), hashHex(expectedEnrollment)) {
return "", ErrUnauthorized
}
err = s.Pool.QueryRow(ctx, `INSERT INTO agents(tenant_id,hostname,api_key_hash,last_ip) VALUES($1,$2,$3,$4) ON CONFLICT(tenant_id,hostname) DO NOTHING RETURNING id::text`, tenant, hostname, hashHex(apiKey), remoteIP).Scan(&id)
if err == nil {
return id, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
err = s.Pool.QueryRow(ctx, `SELECT id::text, api_key_hash, enabled FROM agents WHERE tenant_id=$1 AND hostname=$2`, tenant, hostname).Scan(&id, &hash, &enabled)
if err != nil || !enabled || !secureEqual(strings.ToLower(hash), hashHex(apiKey)) {
return "", ErrUnauthorized
}
return id, nil
}
if !enabled || !secureEqual(strings.ToLower(hash), hashHex(apiKey)) {
return "", ErrUnauthorized
}
_, err = s.Pool.Exec(ctx, `UPDATE agents SET last_seen=now(), last_ip=$3 WHERE tenant_id=$1 AND hostname=$2`, tenant, hostname, remoteIP)
return id, err
}
func (s *Store) ListAgents(ctx context.Context, tenant string) ([]Agent, error) {
rows, e := s.Pool.Query(ctx, `SELECT id::text,hostname,enabled,COALESCE(last_ip,''),first_seen,last_seen FROM agents WHERE tenant_id=$1 ORDER BY last_seen DESC`, tenant)
if e != nil {
return nil, e
}
defer rows.Close()
var out []Agent
for rows.Next() {
var a Agent
if e := rows.Scan(&a.ID, &a.Hostname, &a.Enabled, &a.LastIP, &a.FirstSeen, &a.LastSeen); e != nil {
return nil, e
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) SetAgentEnabled(ctx context.Context, tenant, id string, enabled bool) error {
tag, e := s.Pool.Exec(ctx, `UPDATE agents SET enabled=$3 WHERE tenant_id=$1 AND id=$2::uuid`, tenant, id, enabled)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *Store) SyncRuleSet(ctx context.Context, tenant string, rs rules.RuleSet) error {
tx, e := s.Pool.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
_, e = tx.Exec(ctx, `INSERT INTO rule_sets(tenant_id,id,name,description,version,enabled,source,locked)
VALUES($1,$2,$3,$4,$5,$6,'builtin',true)
ON CONFLICT(tenant_id,id) DO UPDATE SET name=EXCLUDED.name,description=EXCLUDED.description,version=GREATEST(rule_sets.version,EXCLUDED.version),source='builtin',locked=true,updated_at=now()`, tenant, rs.ID, rs.Name, rs.Description, rs.Version, rs.Enabled)
if e != nil {
return e
}
ids := make([]string, 0, len(rs.Rules))
for _, r := range rs.Rules {
ids = append(ids, r.ID)
b, e := json.Marshal(r)
if e != nil {
return e
}
_, e = tx.Exec(ctx, `INSERT INTO detection_rules(tenant_id,id,rule_set_id,title,severity,score,enabled,source,definition)
VALUES($1,$2,$3,$4,$5,$6,$7,'builtin',$8::jsonb)
ON CONFLICT(tenant_id,id) DO UPDATE SET rule_set_id=EXCLUDED.rule_set_id,title=EXCLUDED.title,severity=EXCLUDED.severity,score=EXCLUDED.score,source='builtin',definition=EXCLUDED.definition,updated_at=now()`, tenant, r.ID, rs.ID, r.Title, r.Severity, r.Score, r.Enabled, string(b))
if e != nil {
return e
}
}
if len(ids) == 0 {
_, e = tx.Exec(ctx, `DELETE FROM detection_rules WHERE tenant_id=$1 AND rule_set_id=$2 AND source='builtin'`, tenant, rs.ID)
} else {
_, e = tx.Exec(ctx, `DELETE FROM detection_rules WHERE tenant_id=$1 AND rule_set_id=$2 AND source='builtin' AND NOT (id = ANY($3))`, tenant, rs.ID, ids)
}
if e != nil {
return e
}
return tx.Commit(ctx)
}
func (s *Store) PruneBuiltinRuleSets(ctx context.Context, tenant string, keep []string) error {
if len(keep) == 0 {
_, e := s.Pool.Exec(ctx, `DELETE FROM rule_sets WHERE tenant_id=$1 AND source='builtin'`, tenant)
return e
}
_, e := s.Pool.Exec(ctx, `DELETE FROM rule_sets WHERE tenant_id=$1 AND source='builtin' AND NOT (id = ANY($2))`, tenant, keep)
return e
}
func (s *Store) EnsureCustomRuleSet(ctx context.Context, tenant string) error {
_, e := s.Pool.Exec(ctx, `INSERT INTO rule_sets(tenant_id,id,name,description,version,enabled,source,locked)
VALUES($1,'custom','Eigene Regeln','Über die SIEM-Oberfläche verwaltete Regeln',1,true,'custom',false)
ON CONFLICT(tenant_id,id) DO NOTHING`, tenant)
return e
}
func (s *Store) ListRuleSets(ctx context.Context, tenant string) ([]RuleSetRecord, error) {
rows, e := s.Pool.Query(ctx, `SELECT rs.id,rs.name,rs.description,rs.version,rs.enabled,rs.source,rs.locked,rs.updated_at,
count(r.id),count(r.id) FILTER (WHERE r.enabled)
FROM rule_sets rs LEFT JOIN detection_rules r ON r.tenant_id=rs.tenant_id AND r.rule_set_id=rs.id
WHERE rs.tenant_id=$1 GROUP BY rs.id,rs.name,rs.description,rs.version,rs.enabled,rs.source,rs.locked,rs.updated_at ORDER BY rs.name`, tenant)
if e != nil {
return nil, e
}
defer rows.Close()
var out []RuleSetRecord
for rows.Next() {
var x RuleSetRecord
if e := rows.Scan(&x.ID, &x.Name, &x.Description, &x.Version, &x.Enabled, &x.Source, &x.Locked, &x.UpdatedAt, &x.RuleCount, &x.EnabledRules); e != nil {
return nil, e
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) ListRules(ctx context.Context, tenant string, enabledOnly bool) ([]rules.StoredRule, error) {
q := `SELECT r.rule_set_id,rs.name,rs.enabled,r.source,r.enabled,r.definition FROM detection_rules r JOIN rule_sets rs ON rs.tenant_id=r.tenant_id AND rs.id=r.rule_set_id WHERE r.tenant_id=$1`
if enabledOnly {
q += ` AND r.enabled AND rs.enabled`
}
q += ` ORDER BY rs.name,r.severity DESC,r.title`
rows, e := s.Pool.Query(ctx, q, tenant)
if e != nil {
return nil, e
}
defer rows.Close()
var out []rules.StoredRule
for rows.Next() {
var x rules.StoredRule
var b []byte
var enabled bool
if e := rows.Scan(&x.RuleSetID, &x.RuleSetName, &x.RuleSetOn, &x.Source, &enabled, &b); e != nil {
return nil, e
}
if e := json.Unmarshal(b, &x.Rule); e != nil {
return nil, e
}
x.Enabled = enabled
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) SetRuleEnabled(ctx context.Context, tenant, id string, enabled bool) error {
tag, e := s.Pool.Exec(ctx, `UPDATE detection_rules SET enabled=$3,updated_at=now() WHERE tenant_id=$1 AND id=$2`, tenant, id, enabled)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *Store) SetRuleSetEnabled(ctx context.Context, tenant, id string, enabled bool) error {
tag, e := s.Pool.Exec(ctx, `UPDATE rule_sets SET enabled=$3,updated_at=now() WHERE tenant_id=$1 AND id=$2`, tenant, id, enabled)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *Store) SaveCustomRule(ctx context.Context, tenant string, r rules.Rule) error {
if err := rules.Validate(r); err != nil {
return err
}
if err := s.EnsureCustomRuleSet(ctx, tenant); err != nil {
return err
}
b, e := json.Marshal(r)
if e != nil {
return e
}
tag, e := s.Pool.Exec(ctx, `INSERT INTO detection_rules(tenant_id,id,rule_set_id,title,severity,score,enabled,source,definition)
VALUES($1,$2,'custom',$3,$4,$5,$6,'custom',$7::jsonb)
ON CONFLICT(tenant_id,id) DO UPDATE SET rule_set_id='custom',title=EXCLUDED.title,severity=EXCLUDED.severity,score=EXCLUDED.score,enabled=EXCLUDED.enabled,definition=EXCLUDED.definition,updated_at=now()
WHERE detection_rules.source='custom'`, tenant, r.ID, r.Title, r.Severity, r.Score, r.Enabled, string(b))
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("rule id %q belongs to a built-in rule set", r.ID)
}
return nil
}
func (s *Store) CreateSuppression(ctx context.Context, tenant string, x Suppression) error {
if x.RuleID == "" && x.HostPattern == "" && x.UserPattern == "" && x.SourceIPPattern == "" {
return fmt.Errorf("suppression must constrain rule or entity")
}
_, e := s.Pool.Exec(ctx, `INSERT INTO detection_suppressions(tenant_id,rule_id,host_pattern,user_pattern,source_ip_pattern,reason,enabled,expires_at) VALUES($1,$2,$3,$4,$5,$6,true,$7)`, tenant, x.RuleID, x.HostPattern, x.UserPattern, x.SourceIPPattern, x.Reason, x.ExpiresAt)
return e
}
func (s *Store) DeleteSuppression(ctx context.Context, tenant string, id int64) error {
_, e := s.Pool.Exec(ctx, `DELETE FROM detection_suppressions WHERE tenant_id=$1 AND id=$2`, tenant, id)
return e
}
func (s *Store) ListSuppressions(ctx context.Context, tenant string) ([]Suppression, error) {
rows, e := s.Pool.Query(ctx, `SELECT id,rule_id,host_pattern,user_pattern,source_ip_pattern,reason,enabled,expires_at,created_at FROM detection_suppressions WHERE tenant_id=$1 ORDER BY created_at DESC`, tenant)
if e != nil {
return nil, e
}
defer rows.Close()
var out []Suppression
for rows.Next() {
var x Suppression
if e := rows.Scan(&x.ID, &x.RuleID, &x.HostPattern, &x.UserPattern, &x.SourceIPPattern, &x.Reason, &x.Enabled, &x.ExpiresAt, &x.CreatedAt); e != nil {
return nil, e
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) IsSuppressed(ctx context.Context, tenant, ruleID, host, user, ip string, at time.Time) (bool, error) {
var ok bool
e := s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM detection_suppressions WHERE tenant_id=$1 AND enabled AND (expires_at IS NULL OR expires_at>$6)
AND (rule_id='' OR rule_id=$2)
AND (host_pattern='' OR $3 LIKE replace(host_pattern,'*','%'))
AND (user_pattern='' OR $4 LIKE replace(user_pattern,'*','%'))
AND (source_ip_pattern='' OR $5 LIKE replace(source_ip_pattern,'*','%')))`, tenant, ruleID, host, user, ip, at).Scan(&ok)
return ok, e
}
func (s *Store) UpsertDetection(ctx context.Context, d Detection, tenant string) error {
tags, _ := json.Marshal(d.Tags)
mitre, _ := json.Marshal(d.MITRE)
_, e := s.Pool.Exec(ctx, `INSERT INTO detections(tenant_id,fingerprint,rule_id,rule_set_id,rule_name,severity,status,hostname,user_name,source_ip,workstation,event_code,score,window_start,window_end,summary,hit_count,tags,mitre,first_seen,last_seen)
VALUES($1,$2,$3,$4,$5,$6,'open',$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17::jsonb,$18::jsonb,$13,$14)
ON CONFLICT(tenant_id,fingerprint) DO UPDATE SET first_seen=LEAST(detections.first_seen,EXCLUDED.first_seen),last_seen=GREATEST(detections.last_seen,EXCLUDED.last_seen),window_start=LEAST(detections.window_start,EXCLUDED.window_start),window_end=GREATEST(detections.window_end,EXCLUDED.window_end),hit_count=GREATEST(detections.hit_count,EXCLUDED.hit_count),score=GREATEST(detections.score,EXCLUDED.score),summary=EXCLUDED.summary,workstation=EXCLUDED.workstation,tags=EXCLUDED.tags,mitre=EXCLUDED.mitre,updated_at=now()`, tenant, d.Fingerprint, d.RuleID, d.RuleSetID, d.RuleName, d.Severity, d.Hostname, d.UserName, d.SourceIP, d.Workstation, d.EventCode, d.Score, d.WindowStart, d.WindowEnd, d.Summary, d.Count, string(tags), string(mitre))
return e
}
func (s *Store) ListDetections(ctx context.Context, tenant string, limit int) ([]Detection, error) {
rows, e := s.Pool.Query(ctx, `SELECT id,fingerprint,rule_id,rule_set_id,rule_name,severity,status,hostname,user_name,source_ip,workstation,event_code,score,window_start,window_end,summary,hit_count,tags,mitre,first_seen,last_seen FROM detections WHERE tenant_id=$1 ORDER BY last_seen DESC LIMIT $2`, tenant, limit)
if e != nil {
return nil, e
}
defer rows.Close()
var out []Detection
for rows.Next() {
var d Detection
var tags, mitre []byte
if e := rows.Scan(&d.ID, &d.Fingerprint, &d.RuleID, &d.RuleSetID, &d.RuleName, &d.Severity, &d.Status, &d.Hostname, &d.UserName, &d.SourceIP, &d.Workstation, &d.EventCode, &d.Score, &d.WindowStart, &d.WindowEnd, &d.Summary, &d.Count, &tags, &mitre, &d.FirstSeen, &d.LastSeen); e != nil {
return nil, e
}
_ = json.Unmarshal(tags, &d.Tags)
_ = json.Unmarshal(mitre, &d.MITRE)
out = append(out, d)
}
return out, rows.Err()
}
func (s *Store) UpdateDetectionStatus(ctx context.Context, tenant string, id int64, status string) error {
if status != "open" && status != "investigating" && status != "closed" && status != "false_positive" {
return fmt.Errorf("invalid status")
}
_, e := s.Pool.Exec(ctx, `UPDATE detections SET status=$3,updated_at=now() WHERE tenant_id=$1 AND id=$2`, tenant, id, status)
return e
}
func (s *Store) DetectionCounts(ctx context.Context, tenant string) (map[string]int64, error) {
rows, e := s.Pool.Query(ctx, `SELECT severity,count(*) FROM detections WHERE tenant_id=$1 AND status IN ('open','investigating') GROUP BY severity`, tenant)
if e != nil {
return nil, e
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var k string
var n int64
if e := rows.Scan(&k, &n); e != nil {
return nil, e
}
out[k] = n
}
return out, rows.Err()
}
func hashHex(v string) string { h := sha256.Sum256([]byte(v)); return hex.EncodeToString(h[:]) }
func secureEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func ClientIP(remote string) string {
h, _, e := net.SplitHostPort(remote)
if e == nil {
return h
}
return remote
}