// 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 }