feat(go): port FIM/pkgdb/rootcheck integrity engines and add go-sidecar dashboard consumer
Adds sidecar-only, --state-dir-gated FIM, package-DB-anchor, and rootcheck
engines to the Go migration sidecar, wired into agent.Sweep/Initialize
behind the existing baseline lifecycle. Adds a read-only, schema-checked
Python dashboard consumer (go_sidecar_state_dir, /api/go-sidecar/*, Sidecar
tab) that never starts, stops, or mutates the Go sidecar's state.
Also fixes drift found while reconciling this work: enodia_sentinel/web.py
had reinvented local schema constants instead of using the canonical
enodia_sentinel/schemas.py catalog (now registers enodia.go.sidecar.v1
there and reuses ALERT_SNAPSHOT_V1/INCIDENT_V1); docs/RULES.md had drifted
from what `rules docs` actually generates for SIDs 100069-100078 (ruleops.py
was missing metadata for four SIDs and had stale drill text for four more),
now back in sync with a regression test pinning them together.
Updates CLAUDE.md, README.md, go-agent/README.md, and docs/{ROADMAP,
GO_PORT_HANDOFF,SURICATA_ASSIMILATION,THREAT_MODEL,OPERATIONS,SCHEMAS,
COMMAND_REFERENCE}.md to reflect the landed work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQivSKBqQJsayz1xcYqWzZ
This commit is contained in:
parent
1d1dee7f6e
commit
d835386381
43 changed files with 3419 additions and 72 deletions
153
go-agent/internal/fim/fim.go
Normal file
153
go-agent/internal/fim/fim.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package fim ports the Python file-integrity engine: a SHA-256 baseline over
|
||||
// security-critical paths the package manager does not track, and the diff that
|
||||
// turns baseline drift into alerts.
|
||||
//
|
||||
// Content hashing is the upgrade over mtime checks — it catches a file swapped
|
||||
// for a malicious copy even when the timestamp is preserved.
|
||||
package fim
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
// SIDs 100017+ are FIM signatures, matching Python.
|
||||
const (
|
||||
SIDModified = 100017
|
||||
SIDAdded = 100018
|
||||
SIDRemoved = 100019
|
||||
SIDPackage = 100020
|
||||
)
|
||||
|
||||
// Entry is one path's integrity record. SHA256 and Link are pointers so that
|
||||
// "absent" and "present but empty" stay distinguishable: Python builds these
|
||||
// dicts with optional keys and compares them with dict.get, so a regular file
|
||||
// carries no link key at all while a symlink may legitimately carry "".
|
||||
type Entry struct {
|
||||
Mode int `json:"mode"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 *string `json:"sha256,omitempty"`
|
||||
Link *string `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
// Change is one modified path and the fields that differ, in Python's order.
|
||||
type Change struct {
|
||||
Path string `json:"path"`
|
||||
Fields []string `json:"fields"`
|
||||
}
|
||||
|
||||
// Result is one baseline-versus-live comparison.
|
||||
type Result struct {
|
||||
Added []string `json:"added"`
|
||||
Removed []string `json:"removed"`
|
||||
Modified []Change `json:"modified"`
|
||||
}
|
||||
|
||||
// comparedFields is Python's tuple order and drives the detail string. Size is
|
||||
// deliberately absent: a content change already surfaces as a sha256 change.
|
||||
var comparedFields = []string{"sha256", "mode", "uid", "gid", "link"}
|
||||
|
||||
func sameOptional(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func changedFields(old, current Entry) []string {
|
||||
changed := make([]string, 0, len(comparedFields))
|
||||
for _, field := range comparedFields {
|
||||
differs := false
|
||||
switch field {
|
||||
case "sha256":
|
||||
differs = !sameOptional(old.SHA256, current.SHA256)
|
||||
case "mode":
|
||||
differs = old.Mode != current.Mode
|
||||
case "uid":
|
||||
differs = old.UID != current.UID
|
||||
case "gid":
|
||||
differs = old.GID != current.GID
|
||||
case "link":
|
||||
differs = !sameOptional(old.Link, current.Link)
|
||||
}
|
||||
if differs {
|
||||
changed = append(changed, field)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Diff compares a baseline against a live scan. Every category is sorted so
|
||||
// alert order is stable across runs and matches the Python oracle.
|
||||
func Diff(old, current map[string]Entry) Result {
|
||||
added := make([]string, 0)
|
||||
removed := make([]string, 0)
|
||||
shared := make([]string, 0)
|
||||
for path := range current {
|
||||
if _, ok := old[path]; !ok {
|
||||
added = append(added, path)
|
||||
}
|
||||
}
|
||||
for path := range old {
|
||||
if _, ok := current[path]; ok {
|
||||
shared = append(shared, path)
|
||||
} else {
|
||||
removed = append(removed, path)
|
||||
}
|
||||
}
|
||||
sort.Strings(added)
|
||||
sort.Strings(removed)
|
||||
sort.Strings(shared)
|
||||
|
||||
modified := make([]Change, 0)
|
||||
for _, path := range shared {
|
||||
if fields := changedFields(old[path], current[path]); len(fields) > 0 {
|
||||
modified = append(modified, Change{Path: path, Fields: fields})
|
||||
}
|
||||
}
|
||||
return Result{Added: added, Removed: removed, Modified: modified}
|
||||
}
|
||||
|
||||
// DiffAlerts turns a comparison into alerts, emitting modified, then added,
|
||||
// then removed, to match Python's generator order.
|
||||
func DiffAlerts(result Result) []model.Alert {
|
||||
alerts := make([]model.Alert, 0, len(result.Modified)+len(result.Added)+len(result.Removed))
|
||||
for _, change := range result.Modified {
|
||||
// Content or permission changes are the ones that let an attacker
|
||||
// substitute or escalate; ownership alone is weaker evidence.
|
||||
severity := "MEDIUM"
|
||||
for _, field := range change.Fields {
|
||||
if field == "sha256" || field == "mode" {
|
||||
severity = "HIGH"
|
||||
break
|
||||
}
|
||||
}
|
||||
alerts = append(alerts, model.Alert{
|
||||
SID: SIDModified, Severity: severity, Signature: "fim_modified",
|
||||
Classtype: "integrity-violation", Key: "fim:mod:" + change.Path,
|
||||
Detail: "integrity change (" + strings.Join(change.Fields, ", ") + "): " + change.Path,
|
||||
PIDs: []int{},
|
||||
})
|
||||
}
|
||||
for _, path := range result.Added {
|
||||
alerts = append(alerts, model.Alert{
|
||||
SID: SIDAdded, Severity: "MEDIUM", Signature: "fim_added",
|
||||
Classtype: "integrity-violation", Key: "fim:add:" + path,
|
||||
Detail: "new file in monitored path: " + path, PIDs: []int{},
|
||||
})
|
||||
}
|
||||
for _, path := range result.Removed {
|
||||
alerts = append(alerts, model.Alert{
|
||||
SID: SIDRemoved, Severity: "MEDIUM", Signature: "fim_removed",
|
||||
Classtype: "integrity-violation", Key: "fim:del:" + path,
|
||||
Detail: "monitored file removed: " + path, PIDs: []int{},
|
||||
})
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue