188 lines
6.8 KiB
Go
188 lines
6.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"example.com/siem-greenfield/internal/clickhouse"
|
|
"example.com/siem-greenfield/internal/config"
|
|
"example.com/siem-greenfield/internal/postgres"
|
|
)
|
|
|
|
func Run(ctx context.Context, cfg config.Config) error {
|
|
pg, e := postgres.Open(ctx, cfg.PostgresURL)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
defer pg.Close()
|
|
ch := clickhouse.New(cfg)
|
|
tpl, e := template.ParseFiles("/app/web/index.html")
|
|
if e != nil {
|
|
return e
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { j(w, 200, map[string]string{"status": "ok"}) })
|
|
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
cctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
|
defer cancel()
|
|
if e := pg.Pool.Ping(cctx); e != nil {
|
|
j(w, 503, map[string]string{"status": "not_ready", "component": "postgres"})
|
|
return
|
|
}
|
|
if e := ch.Exec(cctx, "SELECT 1"); e != nil {
|
|
j(w, 503, map[string]string{"status": "not_ready", "component": "clickhouse"})
|
|
return
|
|
}
|
|
j(w, 200, map[string]string{"status": "ready"})
|
|
})
|
|
mux.HandleFunc("/ui", func(w http.ResponseWriter, r *http.Request) {
|
|
_ = tpl.Execute(w, map[string]string{"Tenant": cfg.TenantID})
|
|
})
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/" {
|
|
http.Redirect(w, r, "/ui", http.StatusFound)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
})
|
|
mux.HandleFunc("/api/summary", func(w http.ResponseWriter, r *http.Request) { summary(w, r, cfg, pg, ch) })
|
|
mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { events(w, r, cfg, ch) })
|
|
mux.HandleFunc("/api/detections", func(w http.ResponseWriter, r *http.Request) { detections(w, r, cfg, pg) })
|
|
mux.HandleFunc("/api/detections/status", func(w http.ResponseWriter, r *http.Request) { detectionStatus(w, r, cfg, pg) })
|
|
mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) {
|
|
x, e := pg.ListAgents(r.Context(), cfg.TenantID)
|
|
if e != nil {
|
|
j(w, 500, map[string]string{"error": e.Error()})
|
|
return
|
|
}
|
|
j(w, 200, x)
|
|
})
|
|
srv := &http.Server{Addr: cfg.ServiceAddr, Handler: security(basicAuth(mux, cfg.UIUsername, cfg.UIPassword)), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second}
|
|
go func() {
|
|
<-ctx.Done()
|
|
c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(c)
|
|
}()
|
|
log.Printf("api/ui listening on %s", cfg.ServiceAddr)
|
|
e = srv.ListenAndServe()
|
|
if e == http.ErrServerClosed {
|
|
return nil
|
|
}
|
|
return e
|
|
}
|
|
func summary(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store, ch *clickhouse.Client) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
|
defer cancel()
|
|
q := fmt.Sprintf(`SELECT uniqExactMerge(cnt_state) events_24h, uniqExact(host_name) active_hosts FROM siem.events_5m WHERE tenant_id=%s AND bucket>=now()-INTERVAL 24 HOUR`, clickhouse.Q(cfg.TenantID))
|
|
rows, e := ch.QueryJSON(ctx, q)
|
|
if e != nil {
|
|
j(w, 500, map[string]string{"error": e.Error()})
|
|
return
|
|
}
|
|
out := map[string]any{"events_24h": 0, "active_hosts": 0}
|
|
if len(rows) > 0 {
|
|
for k, v := range rows[0] {
|
|
out[k] = v
|
|
}
|
|
}
|
|
dc, _ := pg.DetectionCounts(ctx, cfg.TenantID)
|
|
out["detections"] = dc
|
|
j(w, 200, out)
|
|
}
|
|
func events(w http.ResponseWriter, r *http.Request, cfg config.Config, ch *clickhouse.Client) {
|
|
limit := cfg.UIQueryLimit
|
|
if n, e := strconv.Atoi(r.URL.Query().Get("limit")); e == nil && n > 0 && n <= 2000 {
|
|
limit = n
|
|
}
|
|
hours := 24
|
|
if n, e := strconv.Atoi(r.URL.Query().Get("hours")); e == nil && n > 0 && n <= 2160 {
|
|
hours = n
|
|
}
|
|
where := []string{"tenant_id=" + clickhouse.Q(cfg.TenantID), fmt.Sprintf("event_time>=now()-INTERVAL %d HOUR", hours)}
|
|
for key, col := range map[string]string{"host": "host_name", "user": "user_name", "ip": "source_ip", "channel": "channel", "action": "action"} {
|
|
if v := strings.TrimSpace(r.URL.Query().Get(key)); v != "" {
|
|
where = append(where, col+"="+clickhouse.Q(v))
|
|
}
|
|
}
|
|
if v := strings.TrimSpace(r.URL.Query().Get("event_code")); v != "" {
|
|
if _, e := strconv.ParseUint(v, 10, 32); e == nil {
|
|
where = append(where, "event_code="+v)
|
|
}
|
|
}
|
|
q := fmt.Sprintf(`SELECT event_uid,event_time,host_name,channel,event_code,category,action,outcome,severity,user_name,subject_user,target_user,source_ip,workstation,process_path,message,raw_object_key,raw_index FROM siem.events WHERE %s ORDER BY event_time DESC, ingest_time DESC LIMIT 1 BY event_uid LIMIT %d`, strings.Join(where, " AND "), limit)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
|
defer cancel()
|
|
rows, e := ch.QueryJSON(ctx, q)
|
|
if e != nil {
|
|
j(w, 500, map[string]string{"error": e.Error()})
|
|
return
|
|
}
|
|
j(w, 200, rows)
|
|
}
|
|
func detections(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
|
|
x, e := pg.ListDetections(r.Context(), cfg.TenantID, 500)
|
|
if e != nil {
|
|
j(w, 500, map[string]string{"error": e.Error()})
|
|
return
|
|
}
|
|
j(w, 200, x)
|
|
}
|
|
func detectionStatus(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
|
|
if r.Method != http.MethodPost {
|
|
j(w, 405, map[string]string{"error": "method not allowed"})
|
|
return
|
|
}
|
|
var v struct {
|
|
ID int64 `json:"id"`
|
|
Status string `json:"status"`
|
|
}
|
|
if e := json.NewDecoder(r.Body).Decode(&v); e != nil {
|
|
j(w, 400, map[string]string{"error": "invalid json"})
|
|
return
|
|
}
|
|
if e := pg.UpdateDetectionStatus(r.Context(), cfg.TenantID, v.ID, v.Status); e != nil {
|
|
j(w, 400, map[string]string{"error": e.Error()})
|
|
return
|
|
}
|
|
j(w, 200, map[string]string{"status": "ok"})
|
|
}
|
|
func j(w http.ResponseWriter, s int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(s)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
func basicAuth(next http.Handler, user, pass string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
u, p, ok := r.BasicAuth()
|
|
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 || subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="Greenfield SIEM", charset="UTF-8"`)
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func security(n http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
|
n.ServeHTTP(w, r)
|
|
})
|
|
}
|