// SPDX-License-Identifier: GPL-3.0-or-later // Package pkgdb ports Sentinel's layer-one pacman database tamper evidence. // It does not treat the mutable local database as a root of trust: it records // a baseline fingerprint, permits only transaction-correlated change, and // retains the old anchor when a change is unexplained. package pkgdb import ( "crypto/sha256" "encoding/hex" "encoding/json" "io" "os" "path/filepath" "strings" "time" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) const ( SIDTamper = 100021 defaultDBDir = "/var/lib/pacman/local" defaultPacmanLog = "/var/log/pacman.log" anchorFilename = "pkgdb-anchor.json" transactionSlop = 120 * time.Second ) // Anchor is deliberately compatible with Python's small JSON record. Time is // an epoch value so both implementations can compare it with pacman's log // timestamp without formatting or local-time ambiguity. type Anchor struct { Fingerprint string `json:"fingerprint"` Time float64 `json:"time"` } // Fingerprint computes the deterministic SHA-256 used for the local package // database. It reads each non-directory entry in lexical walk order; a read // failure makes the whole check unavailable rather than producing a partial // fingerprint that could become an unsafe new baseline. func Fingerprint(directory string) (string, error) { hash := sha256.New() err := filepath.WalkDir(directory, func(path string, entry os.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() { return nil } if _, err := io.WriteString(hash, path+"\x00"); err != nil { return err } file, err := os.Open(path) if err != nil { return err } _, copyErr := io.Copy(hash, file) closeErr := file.Close() if copyErr != nil { return copyErr } return closeErr }) if err != nil { return "", err } return hex.EncodeToString(hash.Sum(nil)), nil } // LastTransactionTime returns the final pacman transaction marker. A missing // or unparseable log is intentionally inconclusive; it is never treated as a // successful transaction that could authorize a changed database. func LastTransactionTime(path string) (time.Time, bool) { raw, err := os.ReadFile(path) if err != nil { return time.Time{}, false } var latest time.Time for _, line := range strings.Split(string(raw), "\n") { if !strings.Contains(line, "transaction completed") && !strings.Contains(line, "transaction started") { continue } end := strings.IndexByte(line, ']') if !strings.HasPrefix(line, "[") || end < 2 { continue } stamp := line[1:end] for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05-0700"} { if parsed, err := time.Parse(layout, stamp); err == nil { latest = parsed break } } } return latest, !latest.IsZero() } func tamperAlert() model.Alert { return model.Alert{ SID: SIDTamper, Severity: "CRITICAL", Signature: "pkgdb_tamper", Classtype: "anti-tamper", Key: "pkgdb:tamper", PIDs: []int{}, Detail: "pacman local DB changed with no corresponding transaction — the package checksum database may have been altered to mask file tampering", } } func loadAnchor(path string) (Anchor, bool) { raw, err := os.ReadFile(path) if err != nil { return Anchor{}, false } var anchor Anchor if err := json.Unmarshal(raw, &anchor); err != nil || anchor.Fingerprint == "" || anchor.Time <= 0 { return Anchor{}, false } return anchor, true } func saveAnchor(path string, anchor Anchor) error { raw, err := json.Marshal(anchor) if err != nil { return err } raw = append(raw, '\n') temporary := path + ".tmp" if err := os.WriteFile(temporary, raw, 0o640); err != nil { return err } if err := os.Rename(temporary, path); err != nil { return err } return os.Chmod(path, 0o640) } // check returns an alert only for changed state that no later pacman // transaction can explain. It deliberately returns a replacement anchor only // for first observation or a transaction-correlated change. func check(current string, anchor Anchor, hasAnchor bool, transaction time.Time, hasTransaction bool, now time.Time) (*model.Alert, *Anchor) { if !hasAnchor { fresh := Anchor{Fingerprint: current, Time: float64(now.Unix())} return nil, &fresh } if current == anchor.Fingerprint { return nil, nil } if hasTransaction && !transaction.Before(time.Unix(int64(anchor.Time), 0).Add(-transactionSlop)) { fresh := Anchor{Fingerprint: current, Time: float64(now.Unix())} return nil, &fresh } alert := tamperAlert() return &alert, nil }