mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-26 12:56:21 +02:00
Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates
This commit is contained in:
parent
694e32be26
commit
41cbfd6e0a
178 changed files with 36008 additions and 399 deletions
|
|
@ -0,0 +1,65 @@
|
|||
-- +goose Up
|
||||
-- SENTINEL SOC — PostgreSQL Schema
|
||||
-- Tables: soc_events, soc_incidents, soc_sensors
|
||||
|
||||
CREATE TABLE soc_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
sensor_id TEXT NOT NULL DEFAULT '',
|
||||
severity TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
confidence DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
session_id TEXT NOT NULL DEFAULT '',
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
decision_hash TEXT NOT NULL DEFAULT '',
|
||||
verdict TEXT NOT NULL DEFAULT 'REVIEW',
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE soc_incidents (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'OPEN',
|
||||
severity TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
event_ids JSONB NOT NULL DEFAULT '[]',
|
||||
event_count INTEGER NOT NULL DEFAULT 0,
|
||||
decision_chain_anchor TEXT NOT NULL DEFAULT '',
|
||||
chain_length INTEGER NOT NULL DEFAULT 0,
|
||||
correlation_rule TEXT NOT NULL DEFAULT '',
|
||||
kill_chain_phase TEXT NOT NULL DEFAULT '',
|
||||
mitre_mapping JSONB NOT NULL DEFAULT '[]',
|
||||
playbook_applied TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE soc_sensors (
|
||||
sensor_id TEXT PRIMARY KEY,
|
||||
sensor_type TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'UNKNOWN',
|
||||
first_seen TIMESTAMPTZ NOT NULL,
|
||||
last_seen TIMESTAMPTZ NOT NULL,
|
||||
event_count INTEGER DEFAULT 0,
|
||||
missed_heartbeats INTEGER DEFAULT 0,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
version TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_soc_events_timestamp ON soc_events(timestamp);
|
||||
CREATE INDEX idx_soc_events_severity ON soc_events(severity);
|
||||
CREATE INDEX idx_soc_events_category ON soc_events(category);
|
||||
CREATE INDEX idx_soc_events_sensor ON soc_events(sensor_id);
|
||||
CREATE INDEX idx_soc_events_content_hash ON soc_events(content_hash);
|
||||
CREATE INDEX idx_soc_incidents_status ON soc_incidents(status);
|
||||
CREATE INDEX idx_soc_sensors_status ON soc_sensors(status);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS soc_sensors;
|
||||
DROP TABLE IF EXISTS soc_incidents;
|
||||
DROP TABLE IF EXISTS soc_events;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
-- +goose Up
|
||||
-- SENTINEL SOC — Auth & Multi-Tenancy (PostgreSQL)
|
||||
-- Tables: users, api_keys, tenants
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT UNIQUE NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used TIMESTAMPTZ,
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
plan_id TEXT NOT NULL DEFAULT 'free',
|
||||
stripe_customer_id TEXT NOT NULL DEFAULT '',
|
||||
stripe_sub_id TEXT NOT NULL DEFAULT '',
|
||||
owner_user_id TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
events_this_month INTEGER NOT NULL DEFAULT 0,
|
||||
month_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add assigned_to column to incidents (was missing in 001)
|
||||
ALTER TABLE soc_incidents ADD COLUMN IF NOT EXISTS assigned_to TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_owner ON tenants(owner_user_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
DROP TABLE IF EXISTS users;
|
||||
ALTER TABLE soc_incidents DROP COLUMN IF EXISTS assigned_to;
|
||||
91
internal/infrastructure/postgres/pg.go
Normal file
91
internal/infrastructure/postgres/pg.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Package postgres provides PostgreSQL persistence for the SENTINEL SOC.
|
||||
//
|
||||
// Uses pgx/v5 driver (pure Go, no CGO) with connection pooling.
|
||||
// Migrations managed by goose.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // pgx driver registered as "pgx"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
// DB wraps a PostgreSQL connection pool.
|
||||
type DB struct {
|
||||
pool *sql.DB
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Open connects to PostgreSQL and runs any pending goose migrations.
|
||||
//
|
||||
// dsn example: "postgres://sentinel:pass@localhost:5432/sentinel_soc?sslmode=disable"
|
||||
func Open(dsn string, logger *slog.Logger) (*DB, error) {
|
||||
pool, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres: open: %w", err)
|
||||
}
|
||||
|
||||
// Connection pool tuning for SOC workload.
|
||||
pool.SetMaxOpenConns(25)
|
||||
pool.SetMaxIdleConns(10)
|
||||
pool.SetConnMaxLifetime(5 * time.Minute)
|
||||
pool.SetConnMaxIdleTime(1 * time.Minute)
|
||||
|
||||
// Verify connectivity.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.PingContext(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("postgres: ping: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{pool: pool, logger: logger}
|
||||
|
||||
// Run pending goose migrations.
|
||||
if err := db.migrate(); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("postgres: migrate: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("PostgreSQL connected", "dsn_host", redactDSN(dsn))
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close releases the connection pool.
|
||||
func (db *DB) Close() error {
|
||||
return db.pool.Close()
|
||||
}
|
||||
|
||||
// Pool returns the underlying *sql.DB for direct queries.
|
||||
func (db *DB) Pool() *sql.DB {
|
||||
return db.pool
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
goose.SetBaseFS(migrations)
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return fmt.Errorf("goose dialect: %w", err)
|
||||
}
|
||||
if err := goose.Up(db.pool, "migrations"); err != nil {
|
||||
return fmt.Errorf("goose up: %w", err)
|
||||
}
|
||||
db.logger.Info("goose migrations applied")
|
||||
return nil
|
||||
}
|
||||
|
||||
// redactDSN extracts host:port for logging without exposing credentials.
|
||||
func redactDSN(dsn string) string {
|
||||
if len(dsn) > 60 {
|
||||
return dsn[:20] + "…" + dsn[len(dsn)-15:]
|
||||
}
|
||||
return "***"
|
||||
}
|
||||
427
internal/infrastructure/postgres/pg_soc_repo.go
Normal file
427
internal/infrastructure/postgres/pg_soc_repo.go
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/syntrex/gomcp/internal/domain/soc"
|
||||
)
|
||||
|
||||
// SOCRepo provides PostgreSQL persistence for SOC events, incidents, and sensors.
|
||||
// Implements domain/soc.SOCRepository.
|
||||
type SOCRepo struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewSOCRepo creates a PostgreSQL-backed SOC repository.
|
||||
// Unlike SQLite, tables are created via goose migrations (not inline DDL).
|
||||
func NewSOCRepo(db *DB) *SOCRepo {
|
||||
return &SOCRepo{db: db}
|
||||
}
|
||||
|
||||
// === Events ===
|
||||
|
||||
// InsertEvent persists a SOC event.
|
||||
func (r *SOCRepo) InsertEvent(e soc.SOCEvent) error {
|
||||
_, err := r.db.Pool().Exec(
|
||||
`INSERT INTO soc_events (id, tenant_id, source, sensor_id, severity, category, subcategory,
|
||||
confidence, description, session_id, content_hash, decision_hash, verdict, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||
e.ID, e.TenantID, e.Source, e.SensorID, e.Severity, e.Category, e.Subcategory,
|
||||
e.Confidence, e.Description, e.SessionID, e.ContentHash, e.DecisionHash, e.Verdict,
|
||||
e.Timestamp,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// EventExistsByHash checks if an event with the given content hash already exists (§5.2 dedup).
|
||||
func (r *SOCRepo) EventExistsByHash(contentHash string) (bool, error) {
|
||||
if contentHash == "" {
|
||||
return false, nil
|
||||
}
|
||||
var count int
|
||||
err := r.db.Pool().QueryRow(
|
||||
"SELECT COUNT(*) FROM soc_events WHERE content_hash = $1", contentHash,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ListEvents returns events ordered by timestamp (newest first), with limit.
|
||||
func (r *SOCRepo) ListEvents(tenantID string, limit int) ([]soc.SOCEvent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if tenantID != "" {
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT id, source, sensor_id, severity, category, subcategory,
|
||||
confidence, description, session_id, decision_hash, verdict, timestamp
|
||||
FROM soc_events WHERE tenant_id = $1 ORDER BY timestamp DESC LIMIT $2`, tenantID, limit)
|
||||
} else {
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT id, source, sensor_id, severity, category, subcategory,
|
||||
confidence, description, session_id, decision_hash, verdict, timestamp
|
||||
FROM soc_events ORDER BY timestamp DESC LIMIT $1`, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEvents(rows)
|
||||
}
|
||||
|
||||
// ListEventsByCategory returns events filtered by category.
|
||||
func (r *SOCRepo) ListEventsByCategory(tenantID string, category string, limit int) ([]soc.SOCEvent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if tenantID != "" {
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT id, source, sensor_id, severity, category, subcategory,
|
||||
confidence, description, session_id, decision_hash, verdict, timestamp
|
||||
FROM soc_events WHERE tenant_id = $1 AND category = $2 ORDER BY timestamp DESC LIMIT $3`,
|
||||
tenantID, category, limit)
|
||||
} else {
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT id, source, sensor_id, severity, category, subcategory,
|
||||
confidence, description, session_id, decision_hash, verdict, timestamp
|
||||
FROM soc_events WHERE category = $1 ORDER BY timestamp DESC LIMIT $2`,
|
||||
category, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEvents(rows)
|
||||
}
|
||||
|
||||
// CountEvents returns total event count.
|
||||
func (r *SOCRepo) CountEvents(tenantID string) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
if tenantID != "" {
|
||||
err = r.db.Pool().QueryRow("SELECT COUNT(*) FROM soc_events WHERE tenant_id = $1", tenantID).Scan(&count)
|
||||
} else {
|
||||
err = r.db.Pool().QueryRow("SELECT COUNT(*) FROM soc_events").Scan(&count)
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetEvent retrieves a single event by ID.
|
||||
func (r *SOCRepo) GetEvent(id string) (*soc.SOCEvent, error) {
|
||||
var e soc.SOCEvent
|
||||
err := r.db.Pool().QueryRow(
|
||||
`SELECT id, source, sensor_id, severity, category, subcategory,
|
||||
confidence, description, session_id, decision_hash, verdict, timestamp
|
||||
FROM soc_events WHERE id = $1`, id,
|
||||
).Scan(&e.ID, &e.Source, &e.SensorID, &e.Severity,
|
||||
&e.Category, &e.Subcategory, &e.Confidence, &e.Description,
|
||||
&e.SessionID, &e.DecisionHash, &e.Verdict, &e.Timestamp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// CountEventsSince returns events in the given time window.
|
||||
func (r *SOCRepo) CountEventsSince(tenantID string, since time.Time) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
if tenantID != "" {
|
||||
err = r.db.Pool().QueryRow(
|
||||
"SELECT COUNT(*) FROM soc_events WHERE tenant_id = $1 AND timestamp >= $2", tenantID, since,
|
||||
).Scan(&count)
|
||||
} else {
|
||||
err = r.db.Pool().QueryRow(
|
||||
"SELECT COUNT(*) FROM soc_events WHERE timestamp >= $1", since,
|
||||
).Scan(&count)
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
|
||||
func scanEvents(rows *sql.Rows) ([]soc.SOCEvent, error) {
|
||||
var events []soc.SOCEvent
|
||||
for rows.Next() {
|
||||
var e soc.SOCEvent
|
||||
err := rows.Scan(&e.ID, &e.Source, &e.SensorID, &e.Severity,
|
||||
&e.Category, &e.Subcategory, &e.Confidence, &e.Description,
|
||||
&e.SessionID, &e.DecisionHash, &e.Verdict, &e.Timestamp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// === Incidents ===
|
||||
|
||||
// InsertIncident persists a new incident.
|
||||
func (r *SOCRepo) InsertIncident(inc soc.Incident) error {
|
||||
_, err := r.db.Pool().Exec(
|
||||
`INSERT INTO soc_incidents (id, tenant_id, status, severity, title, description,
|
||||
event_count, decision_chain_anchor, chain_length, correlation_rule,
|
||||
kill_chain_phase, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||
inc.ID, inc.TenantID, inc.Status, inc.Severity, inc.Title, inc.Description,
|
||||
inc.EventCount, inc.DecisionChainAnchor, inc.ChainLength,
|
||||
inc.CorrelationRule, inc.KillChainPhase,
|
||||
inc.CreatedAt, inc.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetIncident retrieves an incident by ID.
|
||||
func (r *SOCRepo) GetIncident(id string) (*soc.Incident, error) {
|
||||
var inc soc.Incident
|
||||
var resolvedAt sql.NullTime
|
||||
err := r.db.Pool().QueryRow(
|
||||
`SELECT id, status, severity, title, description, event_count,
|
||||
decision_chain_anchor, chain_length, correlation_rule,
|
||||
kill_chain_phase, playbook_applied, created_at, updated_at, resolved_at
|
||||
FROM soc_incidents WHERE id = $1`, id,
|
||||
).Scan(&inc.ID, &inc.Status, &inc.Severity, &inc.Title, &inc.Description,
|
||||
&inc.EventCount, &inc.DecisionChainAnchor, &inc.ChainLength,
|
||||
&inc.CorrelationRule, &inc.KillChainPhase, &inc.PlaybookApplied,
|
||||
&inc.CreatedAt, &inc.UpdatedAt, &resolvedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolvedAt.Valid {
|
||||
inc.ResolvedAt = &resolvedAt.Time
|
||||
}
|
||||
return &inc, nil
|
||||
}
|
||||
|
||||
// ListIncidents returns incidents, optionally filtered by status.
|
||||
func (r *SOCRepo) ListIncidents(tenantID string, status string, limit int) ([]soc.Incident, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
switch {
|
||||
case tenantID != "" && status != "":
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT id, status, severity, title, description, event_count,
|
||||
decision_chain_anchor, chain_length, correlation_rule,
|
||||
kill_chain_phase, playbook_applied, created_at, updated_at
|
||||
FROM soc_incidents WHERE tenant_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3`,
|
||||
tenantID, status, limit)
|
||||
case tenantID != "":
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT id, status, severity, title, description, event_count,
|
||||
decision_chain_anchor, chain_length, correlation_rule,
|
||||
kill_chain_phase, playbook_applied, created_at, updated_at
|
||||
FROM soc_incidents WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT $2`,
|
||||
tenantID, limit)
|
||||
case status != "":
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT id, status, severity, title, description, event_count,
|
||||
decision_chain_anchor, chain_length, correlation_rule,
|
||||
kill_chain_phase, playbook_applied, created_at, updated_at
|
||||
FROM soc_incidents WHERE status = $1 ORDER BY created_at DESC LIMIT $2`,
|
||||
status, limit)
|
||||
default:
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT id, status, severity, title, description, event_count,
|
||||
decision_chain_anchor, chain_length, correlation_rule,
|
||||
kill_chain_phase, playbook_applied, created_at, updated_at
|
||||
FROM soc_incidents ORDER BY created_at DESC LIMIT $1`, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var incidents []soc.Incident
|
||||
for rows.Next() {
|
||||
var inc soc.Incident
|
||||
err := rows.Scan(&inc.ID, &inc.Status, &inc.Severity, &inc.Title,
|
||||
&inc.Description, &inc.EventCount, &inc.DecisionChainAnchor,
|
||||
&inc.ChainLength, &inc.CorrelationRule, &inc.KillChainPhase,
|
||||
&inc.PlaybookApplied, &inc.CreatedAt, &inc.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
incidents = append(incidents, inc)
|
||||
}
|
||||
return incidents, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateIncidentStatus updates status (and optionally resolved_at).
|
||||
func (r *SOCRepo) UpdateIncidentStatus(id string, status soc.IncidentStatus) error {
|
||||
now := time.Now()
|
||||
if status == soc.StatusResolved || status == soc.StatusFalsePositive {
|
||||
_, err := r.db.Pool().Exec(
|
||||
`UPDATE soc_incidents SET status = $1, updated_at = $2, resolved_at = $3 WHERE id = $4`,
|
||||
status, now, now, id)
|
||||
return err
|
||||
}
|
||||
_, err := r.db.Pool().Exec(
|
||||
`UPDATE soc_incidents SET status = $1, updated_at = $2 WHERE id = $3`,
|
||||
status, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountOpenIncidents returns count of non-resolved incidents.
|
||||
func (r *SOCRepo) CountOpenIncidents(tenantID string) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
if tenantID != "" {
|
||||
err = r.db.Pool().QueryRow(
|
||||
"SELECT COUNT(*) FROM soc_incidents WHERE tenant_id = $1 AND status IN ('OPEN', 'INVESTIGATING')",
|
||||
tenantID,
|
||||
).Scan(&count)
|
||||
} else {
|
||||
err = r.db.Pool().QueryRow(
|
||||
"SELECT COUNT(*) FROM soc_incidents WHERE status IN ('OPEN', 'INVESTIGATING')",
|
||||
).Scan(&count)
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
|
||||
// UpdateIncident persists full incident state (case management).
|
||||
func (r *SOCRepo) UpdateIncident(inc *soc.Incident) error {
|
||||
_, err := r.db.Pool().Exec(
|
||||
`UPDATE soc_incidents SET
|
||||
status = $1, severity = $2, description = $3,
|
||||
event_count = $4, assigned_to = COALESCE($5, ''),
|
||||
playbook_applied = $6, kill_chain_phase = $7,
|
||||
updated_at = $8, resolved_at = $9
|
||||
WHERE id = $10`,
|
||||
inc.Status, inc.Severity, inc.Description,
|
||||
inc.EventCount, inc.AssignedTo,
|
||||
inc.PlaybookApplied, inc.KillChainPhase,
|
||||
inc.UpdatedAt, inc.ResolvedAt,
|
||||
inc.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// === Sensors ===
|
||||
|
||||
// UpsertSensor creates or updates a sensor entry.
|
||||
func (r *SOCRepo) UpsertSensor(s soc.Sensor) error {
|
||||
_, err := r.db.Pool().Exec(
|
||||
`INSERT INTO soc_sensors (sensor_id, tenant_id, sensor_type, status, first_seen, last_seen,
|
||||
event_count, missed_heartbeats, hostname, version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT(sensor_id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
last_seen = EXCLUDED.last_seen,
|
||||
event_count = EXCLUDED.event_count,
|
||||
missed_heartbeats = EXCLUDED.missed_heartbeats`,
|
||||
s.SensorID, s.TenantID, s.SensorType, s.Status,
|
||||
s.FirstSeen, s.LastSeen,
|
||||
s.EventCount, s.MissedHeartbeats, s.Hostname, s.Version,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSensor retrieves a sensor by ID.
|
||||
func (r *SOCRepo) GetSensor(id string) (*soc.Sensor, error) {
|
||||
var s soc.Sensor
|
||||
err := r.db.Pool().QueryRow(
|
||||
`SELECT sensor_id, sensor_type, status, first_seen, last_seen,
|
||||
event_count, missed_heartbeats, hostname, version
|
||||
FROM soc_sensors WHERE sensor_id = $1`, id,
|
||||
).Scan(&s.SensorID, &s.SensorType, &s.Status, &s.FirstSeen, &s.LastSeen,
|
||||
&s.EventCount, &s.MissedHeartbeats, &s.Hostname, &s.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// ListSensors returns all registered sensors.
|
||||
func (r *SOCRepo) ListSensors(tenantID string) ([]soc.Sensor, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if tenantID != "" {
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT sensor_id, sensor_type, status, first_seen, last_seen,
|
||||
event_count, missed_heartbeats, hostname, version
|
||||
FROM soc_sensors WHERE tenant_id = $1 ORDER BY last_seen DESC`, tenantID)
|
||||
} else {
|
||||
rows, err = r.db.Pool().Query(
|
||||
`SELECT sensor_id, sensor_type, status, first_seen, last_seen,
|
||||
event_count, missed_heartbeats, hostname, version
|
||||
FROM soc_sensors ORDER BY last_seen DESC`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sensors []soc.Sensor
|
||||
for rows.Next() {
|
||||
var s soc.Sensor
|
||||
err := rows.Scan(&s.SensorID, &s.SensorType, &s.Status,
|
||||
&s.FirstSeen, &s.LastSeen, &s.EventCount, &s.MissedHeartbeats,
|
||||
&s.Hostname, &s.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sensors = append(sensors, s)
|
||||
}
|
||||
return sensors, rows.Err()
|
||||
}
|
||||
|
||||
// CountSensorsByStatus returns sensor count grouped by status.
|
||||
func (r *SOCRepo) CountSensorsByStatus(tenantID string) (map[soc.SensorStatus]int, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if tenantID != "" {
|
||||
rows, err = r.db.Pool().Query("SELECT status, COUNT(*) FROM soc_sensors WHERE tenant_id = $1 GROUP BY status", tenantID)
|
||||
} else {
|
||||
rows, err = r.db.Pool().Query("SELECT status, COUNT(*) FROM soc_sensors GROUP BY status")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[soc.SensorStatus]int)
|
||||
for rows.Next() {
|
||||
var status soc.SensorStatus
|
||||
var count int
|
||||
if err := rows.Scan(&status, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[status] = count
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// PurgeExpiredEvents deletes events older than the retention period.
|
||||
func (r *SOCRepo) PurgeExpiredEvents(retentionDays int) (int64, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -retentionDays)
|
||||
result, err := r.db.Pool().Exec("DELETE FROM soc_events WHERE timestamp < $1", cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge events: %w", err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// PurgeExpiredIncidents deletes resolved incidents older than the retention period.
|
||||
func (r *SOCRepo) PurgeExpiredIncidents(retentionDays int) (int64, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -retentionDays)
|
||||
result, err := r.db.Pool().Exec(
|
||||
"DELETE FROM soc_incidents WHERE status = $1 AND created_at < $2",
|
||||
soc.StatusResolved, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge incidents: %w", err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// Compile-time interface compliance check.
|
||||
var _ soc.SOCRepository = (*SOCRepo)(nil)
|
||||
Loading…
Add table
Add a link
Reference in a new issue