feat(go): advance validation sidecar toward production
This commit is contained in:
parent
6a06eba255
commit
f85c2e831a
92 changed files with 8881 additions and 91 deletions
92
go-agent/internal/correlation/correlation.go
Normal file
92
go-agent/internal/correlation/correlation.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package correlation adds higher-confidence stories without hiding or
|
||||
// replacing their raw alerts. It is transport- and persistence-independent.
|
||||
package correlation
|
||||
|
||||
import "sort"
|
||||
|
||||
const SIDMultiStageIntrusion = 100080
|
||||
|
||||
type Incident struct {
|
||||
FirstTimestamp float64 `json:"first_ts"`
|
||||
LastTimestamp float64 `json:"last_ts"`
|
||||
Signatures []string `json:"signatures"`
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
SID int `json:"sid"`
|
||||
Signature string `json:"signature"`
|
||||
Classtype string `json:"classtype"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
MatchedSignatures []string `json:"matched_signatures"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
SID int
|
||||
Signature string
|
||||
Classtype string
|
||||
Severity string
|
||||
Summary string
|
||||
RequiredAny []map[string]bool
|
||||
MaxWindow int
|
||||
}
|
||||
|
||||
var DefaultRules = []Rule{{
|
||||
SID: SIDMultiStageIntrusion, Signature: "correlation.multi_stage_intrusion",
|
||||
Classtype: "multi-stage-intrusion", Severity: "CRITICAL",
|
||||
Summary: "Web/database service spawned a shell and the same incident showed " +
|
||||
"suspicious egress or an unusual listener within the correlation window",
|
||||
RequiredAny: []map[string]bool{
|
||||
{"exec_rule.web-rce": true},
|
||||
{"host_rule.suspicious-egress": true, "host_rule.suspicious-listener": true},
|
||||
},
|
||||
MaxWindow: 600,
|
||||
}}
|
||||
|
||||
func Correlate(incident Incident, rules []Rule) []Record {
|
||||
if rules == nil {
|
||||
rules = DefaultRules
|
||||
}
|
||||
signatures := make(map[string]bool, len(incident.Signatures))
|
||||
for _, signature := range incident.Signatures {
|
||||
signatures[signature] = true
|
||||
}
|
||||
duration := incident.LastTimestamp - incident.FirstTimestamp
|
||||
result := make([]Record, 0)
|
||||
for _, rule := range rules {
|
||||
if duration > float64(rule.MaxWindow) {
|
||||
continue
|
||||
}
|
||||
matched := true
|
||||
for _, choices := range rule.RequiredAny {
|
||||
found := false
|
||||
for choice := range choices {
|
||||
if signatures[choice] {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
matchedSignatures := make([]string, 0, len(signatures))
|
||||
for signature := range signatures {
|
||||
matchedSignatures = append(matchedSignatures, signature)
|
||||
}
|
||||
sort.Strings(matchedSignatures)
|
||||
result = append(result, Record{
|
||||
SID: rule.SID, Signature: rule.Signature, Classtype: rule.Classtype,
|
||||
Severity: rule.Severity, Summary: rule.Summary,
|
||||
WindowSeconds: rule.MaxWindow, MatchedSignatures: matchedSignatures,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
44
go-agent/internal/correlation/correlation_test.go
Normal file
44
go-agent/internal/correlation/correlation_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package correlation
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCorrelateMultiStageIntrusion(t *testing.T) {
|
||||
records := Correlate(Incident{
|
||||
FirstTimestamp: 1000, LastTimestamp: 1050,
|
||||
Signatures: []string{"host_rule.suspicious-egress", "exec_rule.web-rce"},
|
||||
}, nil)
|
||||
if len(records) != 1 || records[0].SID != SIDMultiStageIntrusion ||
|
||||
!reflect.DeepEqual(records[0].MatchedSignatures,
|
||||
[]string{"exec_rule.web-rce", "host_rule.suspicious-egress"}) {
|
||||
t.Fatalf("unexpected correlation: %#v", records)
|
||||
}
|
||||
if records := Correlate(Incident{
|
||||
FirstTimestamp: 1000, LastTimestamp: 1601,
|
||||
Signatures: []string{"exec_rule.web-rce", "host_rule.suspicious-listener"},
|
||||
}, nil); len(records) != 0 {
|
||||
t.Fatalf("expired incident correlated: %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineageAssignAndSeverity(t *testing.T) {
|
||||
parents := map[int]int{42: 20, 20: 10, 10: 1}
|
||||
lineage := LineageOf([]int{42}, func(pid int) int { return parents[pid] }, 8)
|
||||
if !lineage[42] || !lineage[20] || !lineage[10] || lineage[1] {
|
||||
t.Fatalf("unexpected lineage: %#v", lineage)
|
||||
}
|
||||
index := []IndexIncident{
|
||||
{ID: "old", Host: "h", LastTimestamp: 1010, Lineage: []int{20}},
|
||||
{ID: "new", Host: "h", LastTimestamp: 1020, Lineage: []int{10}},
|
||||
}
|
||||
if got := Assign(index, lineage, 1030, "h", 60); got != "new" {
|
||||
t.Fatalf("unexpected assignment: %s", got)
|
||||
}
|
||||
if MaxSeverity("HIGH", "CRITICAL") != "CRITICAL" || MaxSeverity("unknown", "MEDIUM") != "MEDIUM" {
|
||||
t.Fatal("severity ordering drifted")
|
||||
}
|
||||
}
|
||||
78
go-agent/internal/correlation/incident.go
Normal file
78
go-agent/internal/correlation/incident.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package correlation
|
||||
|
||||
// LineageOf walks each starting PID through parentOf, excluding pid 0/init so
|
||||
// unrelated process trees never correlate merely because they share init.
|
||||
func LineageOf(pids []int, parentOf func(int) int, depth int) map[int]bool {
|
||||
seen := make(map[int]bool)
|
||||
for _, start := range pids {
|
||||
current := start
|
||||
steps := 0
|
||||
for current != 0 && current != 1 && steps <= depth {
|
||||
if seen[current] {
|
||||
break
|
||||
}
|
||||
seen[current] = true
|
||||
current = parentOf(current)
|
||||
steps++
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
type IndexIncident struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"`
|
||||
LastTimestamp float64 `json:"last_ts"`
|
||||
Lineage []int `json:"lineage"`
|
||||
}
|
||||
|
||||
// Assign returns the most recently active incident compatible with this host
|
||||
// and lineage. Empty-lineage evidence may join any open incident on the host,
|
||||
// reproducing Python's fallback for FIM/package evidence without a live PID.
|
||||
func Assign(index []IndexIncident, lineage map[int]bool, when float64, host string, window int) string {
|
||||
best := ""
|
||||
bestTimestamp := -1.0
|
||||
for _, incident := range index {
|
||||
if incident.Host != host || when-incident.LastTimestamp > float64(window) {
|
||||
continue
|
||||
}
|
||||
if len(lineage) > 0 && !intersects(incident.Lineage, lineage) {
|
||||
continue
|
||||
}
|
||||
if incident.LastTimestamp > bestTimestamp {
|
||||
best, bestTimestamp = incident.ID, incident.LastTimestamp
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func MaxSeverity(left, right string) string {
|
||||
if severityRank(left) >= severityRank(right) {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func severityRank(value string) int {
|
||||
switch value {
|
||||
case "MEDIUM":
|
||||
return 1
|
||||
case "HIGH":
|
||||
return 2
|
||||
case "CRITICAL":
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func intersects(values []int, wanted map[int]bool) bool {
|
||||
for _, value := range values {
|
||||
if wanted[value] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue