// SPDX-License-Identifier: GPL-3.0-or-later package fim import ( "context" "os/exec" "strings" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) // PackageFinding is one meaningful package-manager integrity discrepancy. // Package verification is intentionally separate from the FIM baseline: it // relies on distribution-owned hashes for files that Sentinel should not // silently re-baseline after an upgrade. type PackageFinding struct { Package string `json:"package"` Path string `json:"path"` Reason string `json:"reason"` } // ParsePacmanVerify mirrors Python's parse_pacman_verify. pacman emits both // routine summaries and diagnostics to stdout/stderr; only content, mode, or // ownership mismatches are security findings. A timestamp-only difference is // deliberately ignored because a harmless touch must not page an operator. func ParsePacmanVerify(output string) []PackageFinding { findings := make([]PackageFinding, 0) for _, raw := range strings.Split(output, "\n") { line := strings.TrimSpace(raw) open, close := strings.LastIndex(line, "("), strings.LastIndex(line, ")") if open < 0 || close <= open { continue } reason := strings.TrimSpace(line[open+1 : close]) lower := strings.ToLower(reason) if !containsAny(lower, "checksum", "sha", "size", "permission", "ownership", "uid", "gid") { continue } head := strings.TrimSpace(line[:open]) packageName, path, ok := strings.Cut(head, ": /") if !ok { continue } packageName = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(packageName), "warning:")) path = "/" + strings.TrimSpace(path) if packageName == "" || path == "/" { continue } findings = append(findings, PackageFinding{Package: packageName, Path: path, Reason: reason}) } return findings } func containsAny(value string, needles ...string) bool { for _, needle := range needles { if strings.Contains(value, needle) { return true } } return false } // PackageAlert converts a package-owned integrity discrepancy to the same // critical Sentinel alert as Python. The path is the stable cooldown key, so // multiple pacman diagnostics for one path do not cause repeated pages. func PackageAlert(finding PackageFinding) model.Alert { return model.Alert{ SID: SIDPackage, Severity: "CRITICAL", Signature: "fim_pkg_modified", Classtype: "integrity-violation", Key: "fim:pkg:" + finding.Path, Detail: "package file altered (" + finding.Package + ": " + finding.Reason + "): " + finding.Path, PIDs: []int{}, } } // VerifyAlerts executes an injected verification command and turns any // parseable output into alerts. A missing package manager or a timeout fails // open; a non-zero exit with diagnostics is still parsed because pacman uses // that exit status to report changed files. func VerifyAlerts(run func() (string, error)) []model.Alert { alerts := make([]model.Alert, 0) if run == nil { return alerts } output, _ := run() for _, finding := range ParsePacmanVerify(output) { alerts = append(alerts, PackageAlert(finding)) } return alerts } // PacmanVerify runs pacman without a shell and joins stdout/stderr because the // tool may use either stream for discrepancies. The caller supplies a bounded // context; this function has no unbounded host operation of its own. func PacmanVerify(ctx context.Context) (string, error) { command := exec.CommandContext(ctx, "pacman", "-Qkk") output, err := command.CombinedOutput() return string(output), err }