31010 santa tables (#33218)

This commit is contained in:
Tim Lee
2025-10-08 08:58:08 -06:00
committed by GitHub
parent ad93a44ad9
commit aae4ccec54
8 changed files with 1096 additions and 0 deletions
+5
View File
@@ -24,6 +24,7 @@ import (
"github.com/fleetdm/fleet/v4/orbit/pkg/table/pmset"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/privaterelay"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/pwd_policy"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/santa"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/software_update"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/sudo_info"
"github.com/fleetdm/fleet/v4/orbit/pkg/table/tcc_access"
@@ -100,6 +101,10 @@ func PlatformTables(opts PluginOpts) ([]osquery.OsqueryPlugin, error) {
table.NewPlugin("codesign", codesign.Columns(), codesign.Generate),
table.NewPlugin("app_sso_platform", app_sso_platform.Columns(), app_sso_platform.Generate),
table.NewPlugin("santa_status", santa.StatusColumns(), santa.GenerateStatus),
table.NewPlugin("santa_allowed", santa.LogColumns(), santa.GenerateAllowed),
table.NewPlugin("santa_denied", santa.LogColumns(), santa.GenerateDenied),
}
// append platform specific tables
+39
View File
@@ -0,0 +1,39 @@
//go:build darwin
// ringBuffer is a fixed-size circular buffer for log entries.
package santa
type ringBuffer struct {
buf []logEntry
start int
size int
}
func newRingBuffer(n int) *ringBuffer {
return &ringBuffer{buf: make([]logEntry, n)}
}
func (r *ringBuffer) Add(e logEntry) {
if len(r.buf) == 0 {
return
}
if r.size < len(r.buf) {
r.buf[(r.start+r.size)%len(r.buf)] = e
r.size++
} else {
r.buf[r.start] = e
r.start = (r.start + 1) % len(r.buf)
}
}
func (r *ringBuffer) Len() int {
return r.size
}
func (r *ringBuffer) SliceChrono() []logEntry {
out := make([]logEntry, r.size)
for i := 0; i < r.size; i++ {
out[i] = r.buf[(r.start+i)%len(r.buf)]
}
return out
}
+65
View File
@@ -0,0 +1,65 @@
//go:build darwin
package santa
import (
"testing"
"github.com/stretchr/testify/require"
)
func mk(n int) logEntry {
return logEntry{Timestamp: string(rune('A' + n))}
}
func tsSlice(entries []logEntry) []string {
out := make([]string, len(entries))
for i := range entries {
out[i] = entries[i].Timestamp
}
return out
}
func TestRingBuffer_Len(t *testing.T) {
rb := newRingBuffer(3)
require.Equal(t, 0, rb.Len())
rb.Add(mk(0))
require.Equal(t, 1, rb.Len())
rb.Add(mk(1))
require.Equal(t, 2, rb.Len())
rb.Add(mk(2))
require.Equal(t, 3, rb.Len())
rb.Add(mk(3))
require.Equal(t, 3, rb.Len())
rb.Add(mk(4))
require.Equal(t, 3, rb.Len())
}
func TestRingBuffer_NoWrap(t *testing.T) {
rb := newRingBuffer(3)
rb.Add(mk(0)) // A
rb.Add(mk(1)) // B
require.Equal(t, []string{"A", "B"}, tsSlice(rb.SliceChrono()))
}
func TestRingBuffer_Wrap(t *testing.T) {
rb := newRingBuffer(3)
// Add 6: A B C D E F → keep last 3: D E F
for i := range 6 {
rb.Add(mk(i))
}
require.Equal(t, []string{"D", "E", "F"}, tsSlice(rb.SliceChrono()))
}
func TestRingBuffer_ExactCapacity(t *testing.T) {
rb := newRingBuffer(2)
rb.Add(mk(0)) // A
rb.Add(mk(1)) // B
require.Equal(t, []string{"A", "B"}, tsSlice(rb.SliceChrono()))
}
func TestRingBuffer_Empty(t *testing.T) {
rb := newRingBuffer(2)
require.Empty(t, rb.SliceChrono())
}
+220
View File
@@ -0,0 +1,220 @@
//go:build darwin
// Package santa implements the tables for getting Santa data
// (logs/status) on macOS.
//
// Santa is an open source macOS endpoint security system with
// binary whitelisting and blacklisting capabilities.
// Based on https://github.com/allenhouchins/fleet-extensions/tree/main/santa
package santa
import (
"bufio"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"regexp"
"strings"
"github.com/osquery/osquery-go/plugin/table"
"github.com/rs/zerolog/log"
)
const (
kLogEntryPreface = "santad: "
defaultLogPath = "/var/db/santa/santa.log"
)
var maxEntries = 10_000
type santaDecisionType int
const (
decisionAllowed santaDecisionType = iota
decisionDenied
)
type logEntry struct {
Timestamp string
Application string
Reason string
SHA256 string
}
var timestampRegex = regexp.MustCompile(`\[([^\]]+)\]`)
func LogColumns() []table.ColumnDefinition {
return []table.ColumnDefinition{
table.TextColumn("timestamp"),
table.TextColumn("application"),
table.TextColumn("reason"),
table.TextColumn("sha256"),
}
}
func GenerateAllowed(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) {
return generate(ctx, decisionAllowed)
}
func GenerateDenied(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) {
return generate(ctx, decisionDenied)
}
func generate(ctx context.Context, dec santaDecisionType) ([]map[string]string, error) {
entries, err := scrapeSantaLog(ctx, dec)
if err != nil {
log.Debug().Err(err).Msg("failed to scrape santa log")
return []map[string]string{}, nil
}
results := make([]map[string]string, 0, len(entries))
for _, entry := range entries {
results = append(results, map[string]string{
"timestamp": entry.Timestamp,
"application": entry.Application,
"reason": entry.Reason,
"sha256": entry.SHA256,
})
}
return results, nil
}
func extractValues(line string) map[string]string {
values := make(map[string]string, 8)
if m := timestampRegex.FindStringSubmatch(line); len(m) > 1 {
values["timestamp"] = m[1]
}
pos := strings.Index(line, kLogEntryPreface)
if pos == -1 {
return values
}
rest := line[pos+len(kLogEntryPreface):]
for seg := range strings.SplitSeq(rest, "|") {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
k, v, ok := strings.Cut(seg, "=")
if !ok {
continue
}
k = strings.ToLower(strings.TrimSpace(k))
v = strings.Trim(strings.TrimSpace(v), `"'`)
if k != "" && v != "" {
values[k] = v
}
}
return values
}
func scrapeStream(ctx context.Context, scanner *bufio.Scanner, decision santaDecisionType, rb *ringBuffer) error {
for scanner.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
line := scanner.Text()
// Filter by decision type early to keep it fast.
switch decision {
case decisionAllowed:
if !strings.Contains(line, "decision=ALLOW") {
continue
}
case decisionDenied:
if !strings.Contains(line, "decision=DENY") {
continue
}
}
values := extractValues(line)
if values["timestamp"] == "" {
continue
}
rb.Add(logEntry{
Timestamp: values["timestamp"],
Application: values["path"],
Reason: values["reason"],
SHA256: values["sha256"],
})
}
return scanner.Err()
}
func scrapeCurrentLog(ctx context.Context, path string, decision santaDecisionType, rb *ringBuffer) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open Santa log file: %v", err)
}
defer file.Close()
scanner := makeBufferedScanner(file)
return scrapeStream(ctx, scanner, decision, rb)
}
func scrapeCompressedSantaLog(ctx context.Context, path string, decision santaDecisionType, rb *ringBuffer) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open compressed log file %s: %v", path, err)
}
defer file.Close()
gzReader, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to create gzip reader for %s: %v", path, err)
}
defer gzReader.Close()
scanner := makeBufferedScanner(gzReader)
return scrapeStream(ctx, scanner, decision, rb)
}
func makeBufferedScanner(r io.Reader) *bufio.Scanner {
s := bufio.NewScanner(r)
// Uncomment to support very large lines if needed:
// buf := make([]byte, 64*1024)
// s.Buffer(buf, 1<<20) // 1 MiB
return s
}
func scrapeSantaLog(ctx context.Context, decision santaDecisionType) ([]logEntry, error) {
return scrapeSantaLogFromBase(ctx, decision, defaultLogPath)
}
func scrapeSantaLogFromBase(ctx context.Context, decision santaDecisionType, path string) ([]logEntry, error) {
rb := newRingBuffer(maxEntries)
// Find highest archive index (0 = newest archive, higher = older)
maxIdx := -1
for i := 0; ; i++ {
if _, err := os.Stat(fmt.Sprintf("%s.%d.gz", path, i)); err != nil {
break
}
maxIdx = i
}
// 1) Archives oldest → newest: maxIdx, maxIdx-1, ..., 0
for i := maxIdx; i >= 0; i-- {
archivePath := fmt.Sprintf("%s.%d.gz", path, i)
if err := scrapeCompressedSantaLog(ctx, archivePath, decision, rb); err != nil {
return nil, err
}
}
// 2) Current log last (newest overall)
if err := scrapeCurrentLog(ctx, path, decision, rb); err != nil {
return nil, err
}
// Return the last N entries (oldest → newest among those last N).
return rb.SliceChrono(), nil
}
+407
View File
@@ -0,0 +1,407 @@
//go:build darwin
package santa
import (
"bufio"
"compress/gzip"
"context"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestExtractValues(t *testing.T) {
tests := []struct {
name string
line string
want map[string]string
}{
{
name: "happy path with timestamp and kv pairs",
line: `[2025-09-18T10:15:30.123Z] santad: decision=ALLOW | path=/Applications/Foo.app | reason=cdhash | sha256=abc123`,
want: map[string]string{
"timestamp": "2025-09-18T10:15:30.123Z",
"decision": "ALLOW",
"path": "/Applications/Foo.app",
"reason": "cdhash",
"sha256": "abc123",
},
},
{
name: "no santad preface returns only timestamp",
line: `[2025-09-18 10:15:30] something else: decision=DENY | path=/bin/bash`,
want: map[string]string{
"timestamp": "2025-09-18 10:15:30",
},
},
{
name: "no timestamp but has kv pairs",
line: `santad: decision=DENY | path=/usr/local/bin/tool | reason=rule | sha256=def456`,
want: map[string]string{
"decision": "DENY",
"path": "/usr/local/bin/tool",
"reason": "rule",
"sha256": "def456",
},
},
{
name: "trims spaces around keys and values",
line: `[2025-09-18] santad: decision = ALLOW | path = /a/b/c | reason = ok `,
want: map[string]string{
"timestamp": "2025-09-18",
"decision": "ALLOW",
"path": "/a/b/c",
"reason": "ok",
},
},
{
name: "ignores empty segments and missing equals",
line: `[ts] santad: decision=DENY | | path=/p | just-a-flag | sha256=zzz`,
want: map[string]string{
"timestamp": "ts",
"decision": "DENY",
"path": "/p",
"sha256": "zzz",
},
},
{
name: "value containing equals keeps everything after first equals",
line: `[ts] santad: note=a=b=c | path=/eq | sha256=x`,
want: map[string]string{
"timestamp": "ts",
"note": "a=b=c",
"path": "/eq",
"sha256": "x",
},
},
{
name: "duplicate keys last one wins",
line: `[ts] santad: path=/first | path=/second | reason=one | reason=two`,
want: map[string]string{
"timestamp": "ts",
"path": "/second",
"reason": "two",
},
},
{
name: "quoted values are preserved (current impl trims spaces only)",
line: `[ts] santad: path="/Applications/App With Spaces.app" | reason='quoted'`,
want: map[string]string{
"timestamp": "ts",
`path`: `/Applications/App With Spaces.app`,
`reason`: `quoted`,
},
},
{
name: "no matches yields empty map",
line: `completely unrelated line`,
want: map[string]string{},
},
{
name: "handles trailing separator",
line: `[ts] santad: decision=ALLOW | path=/a/b/c |`,
want: map[string]string{
"timestamp": "ts",
"decision": "ALLOW",
"path": "/a/b/c",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got := extractValues(tt.line)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("extractValues() mismatch\nline: %q\n got: %#v\nwant: %#v", tt.line, got, tt.want)
}
})
}
}
func TestExtractValues_DoesNotPanicOnLongLine(t *testing.T) {
// Construct a long line to ensure no unexpected behavior for big inputs.
longVal := make([]byte, 0, 300_000)
for range 10000 {
longVal = append(longVal, 'a')
}
line := "[2025-09-18] santad: path=/" + string(longVal) + " | reason=ok"
got := extractValues(line)
require.Equal(t, "2025-09-18", got["timestamp"])
require.Contains(t, got, "path", "expected path key to be present on long input")
require.Equal(t, "ok", got["reason"])
}
func TestScrapeSantaLogFromBase_EndToEnd(t *testing.T) {
tmp := t.TempDir()
base := filepath.Join(tmp, "santa.log")
// current (plain) log with ALLOW and DENY
current := strings.Builder{}
current.WriteString(mkLine("decision=ALLOW", "2025-09-18 12:00:00.000", "/Applications/A.app", "ok", "aaa"))
current.WriteString(mkLine("decision=DENY", "2025-09-18 12:00:01.000", "/Applications/B.app", "rule", "bbb"))
writeFile(t, base, current.String())
// archive 0 (gz): a DENY (older)
writeGz(t, base+".0.gz", mkLine("decision=DENY", "2025-09-18 11:59:59.000", "/Blocked/X", "blacklist", "xxx"))
// archive 1 (gz): an ALLOW (older)
writeGz(t, base+".1.gz", mkLine("decision=ALLOW", "2025-09-18 11:59:58.000", "/OK/C", "scope", "ccc"))
ctx := t.Context()
denied, err := scrapeSantaLogFromBase(ctx, decisionDenied, base)
require.NoError(t, err)
// With current scanned first, chronological (insertion) order is:
// current DENY, then archive 0 DENY.
require.Len(t, denied, 2)
require.Equal(t, "/Blocked/X", denied[0].Application)
require.Equal(t, "/Applications/B.app", denied[1].Application)
allowed, err := scrapeSantaLogFromBase(ctx, decisionAllowed, base)
require.NoError(t, err)
// current ALLOW, then archive 1 ALLOW.
require.Len(t, allowed, 2)
require.Equal(t, "/OK/C", allowed[0].Application)
require.Equal(t, "/Applications/A.app", allowed[1].Application)
}
// TestScrapeSantaLogFromBase_IgnoresGapsAfterFirstMiss verifies that archive
// iteration stops cleanly at the first missing archive file.
// In this setup only the current log exists (no ".0.gz"), so the function
// should return entries from the current log only and not attempt to read
// later archives (".1.gz", ".2.gz", etc.).
func TestScrapeSantaLogFromBase_IgnoresGapsAfterFirstMiss(t *testing.T) {
tmp := t.TempDir()
base := filepath.Join(tmp, "santa.log")
// only current exists; no .0.gz
writeFile(t, base, mkLine("decision=ALLOW", "2025-09-18 12:00:00.000", "/A", "ok", "aaa"))
got, err := scrapeSantaLogFromBase(context.Background(), decisionAllowed, base)
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, "/A", got[0].Application)
}
func TestScrapeStream_EnforcesGlobalCap(t *testing.T) {
// Lower the global cap to make the test fast and predictable.
oldCap := maxEntries
maxEntries = 1_000
defer func() { maxEntries = oldCap }()
const perLine = `[` +
`2025-09-18 12:00:00.000` +
`] santad: decision=ALLOW | path=/Applications/App.app | reason=ok | sha256=abc123` + "\n"
var sb strings.Builder
sb.Grow(len(perLine) * (maxEntries + 50)) // generate a bit more than the cap
for i := 0; i < maxEntries+50; i++ {
sb.WriteString(perLine)
}
sc := bufio.NewScanner(strings.NewReader(sb.String()))
rb := newRingBuffer(maxEntries)
err := scrapeStream(context.Background(), sc, decisionAllowed, rb)
require.NoError(t, err, "cap should not surface as an error")
require.Len(t, rb.SliceChrono(), maxEntries, "SliceChrono should return exactly maxEntries items")
}
func TestScrapeSantaLogFromBase_PrefersLatestWithinArchiveOnCap(t *testing.T) {
tmp := t.TempDir()
base := filepath.Join(tmp, "santa.log")
// Keep the test fast and intentional.
oldCap := maxEntries
maxEntries = 3
defer func() { maxEntries = oldCap }()
writeFile(t, base, mkLine("decision=DENY", "2025-09-18 12:00:00.000", "/CUR-DENY", "ok", "aaa"))
writeGz(t, base+".0.gz", mkLine("decision=DENY", "2025-09-18 11:59:59.500", "/ARC0-DENY", "ok", "bbb"))
// Older archive (.1.gz): many DENY lines with increasing timestamps.
// We want to ensure that when the cap is hit *inside this archive*,
// the buffer ends up holding the *latest* lines from within it.
var arc1 strings.Builder
arc1.WriteString(mkLine("decision=DENY", "2025-09-18 11:59:59.001", "/DENY-1", "r", "d1"))
arc1.WriteString(mkLine("decision=DENY", "2025-09-18 11:59:59.002", "/DENY-2", "r", "d2"))
arc1.WriteString(mkLine("decision=DENY", "2025-09-18 11:59:59.003", "/DENY-3", "r", "d3"))
arc1.WriteString(mkLine("decision=DENY", "2025-09-18 11:59:59.004", "/DENY-4", "r", "d4"))
arc1.WriteString(mkLine("decision=DENY", "2025-09-18 11:59:59.005", "/DENY-5", "r", "d5"))
writeGz(t, base+".1.gz", arc1.String())
// Scan: archives oldest→newest (.1.gz then .0.gz), then current last.
// Since only .1.gz has DENY lines and it contains more than maxEntries,
// the ring should end up with the last 3 from that archive:
// "/DENY-3", "/DENY-4", "/DENY-5" (chronological).
got, err := scrapeSantaLogFromBase(context.Background(), decisionDenied, base)
require.NoError(t, err)
require.Equal(t,
[]string{"/DENY-5", "/ARC0-DENY", "/CUR-DENY"},
[]string{got[0].Application, got[1].Application, got[2].Application},
"should keep the latest entries within the archive when hitting the cap",
)
maxEntries = 2
got, err = scrapeSantaLogFromBase(context.Background(), decisionDenied, base)
require.NoError(t, err)
require.Equal(t,
[]string{"/ARC0-DENY", "/CUR-DENY"},
[]string{got[0].Application, got[1].Application},
"with a smaller cap, should keep the latest entries within the archive",
)
maxEntries = 1
got, err = scrapeSantaLogFromBase(context.Background(), decisionDenied, base)
require.NoError(t, err)
require.Equal(t,
[]string{"/CUR-DENY"},
[]string{got[0].Application},
"with a cap of 1, should keep only the latest entry overall",
)
}
func writeFile(tb testing.TB, path, content string) {
tb.Helper()
require.NoError(tb, os.WriteFile(path, []byte(content), 0o644))
}
func writeGz(tb testing.TB, path, content string) {
tb.Helper()
f, err := os.Create(path)
require.NoError(tb, err)
gz := gzip.NewWriter(f)
_, err = gz.Write([]byte(content))
require.NoError(tb, err)
require.NoError(tb, gz.Close())
require.NoError(tb, f.Close())
}
func mkLine(dec, ts, path, reason, sha string) string {
// example Santa line format
return "[" + ts + "] santad: " + dec +
` | path="` + path + `" | reason=` + reason + ` | sha256=` + sha + "\n"
}
//////////////////
// BENCHMARKS
// Santa log scraping can be slow due to potentially large files and
// multiple compressed archives. These benchmarks help track performance
// over time.
//
// goos: darwin
// goarch: arm64
// cpu: Apple M2 Pro
//////////////////
// Small (~150KB) non-compressed
// BenchmarkScrapeSantaLogFromBase_SmallPlain-12 1436 827449 ns/op 185.63 MB/s 966170 B/op 5060 allocs/op
func BenchmarkScrapeSantaLogFromBase_SmallPlain(b *testing.B) {
tmp := b.TempDir()
base := filepath.Join(tmp, "santa.log")
content := fillToSize(150*1024, "decision=ALLOW")
writeFile(b, base, content)
ctx := context.Background()
b.SetBytes(int64(len(content)))
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
if _, err := scrapeSantaLogFromBase(ctx, decisionAllowed, base); err != nil {
b.Fatal(err)
}
}
}
// ~10MB non-compressed
// BenchmarkScrapeSantaLogFromBase_10MB_Plain-12 20 58003575 ns/op 180.78 MB/s 75833864 B/op 343898 allocs/op
func BenchmarkScrapeSantaLogFromBase_10MB_Plain(b *testing.B) {
tmp := b.TempDir()
base := filepath.Join(tmp, "santa.log")
content := fillToSize(10*1024*1024, "decision=ALLOW")
writeFile(b, base, content)
ctx := context.Background()
b.SetBytes(int64(len(content)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := scrapeSantaLogFromBase(ctx, decisionAllowed, base); err != nil {
b.Fatal(err)
}
}
}
// ~10MB current log + five compressed archives (each ~10MB uncompressed)
// BenchmarkScrapeSantaLogFromBase_10MB_PlainPlus5x10MB_Gzip-12 6 212764465 ns/op 295.70 MB/s 281107640 B/op 1298057 allocs/op
func BenchmarkScrapeSantaLogFromBase_10MB_PlainPlus5x10MB_Gzip(b *testing.B) {
tmp := b.TempDir()
base := filepath.Join(tmp, "santa.log")
plain := fillToSize(10*1024*1024, "decision=ALLOW")
writeFile(b, base, plain)
totalUncompressed := len(plain)
for i := 0; i < 5; i++ {
dec := "decision=DENY"
if i%2 == 1 {
dec = "decision=ALLOW"
}
raw := fillToSize(10*1024*1024, dec)
writeGz(b, base+fmt.Sprintf(".%d.gz", i), raw)
totalUncompressed += len(raw)
}
ctx := context.Background()
b.SetBytes(int64(totalUncompressed))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Choose either decision; archives contain both.
if _, err := scrapeSantaLogFromBase(ctx, decisionDenied, base); err != nil {
b.Fatal(err)
}
}
}
// fillToSize builds a string ≈ targetBytes by repeating mkLine(dec,...).
func fillToSize(targetBytes int, decision string) string {
line := mkLine(decision,
"2025-09-18 12:00:00.000",
"/Applications/App.app",
"ok",
"deadbeefcafebabef00d",
)
ll := len(line)
if ll == 0 {
panic("mkLine returned empty line")
}
n := targetBytes / ll
if n < 1 {
n = 1
}
var sb strings.Builder
sb.Grow(n * ll)
for i := 0; i < n; i++ {
sb.WriteString(line)
}
return sb.String()
}
+163
View File
@@ -0,0 +1,163 @@
//go:build darwin
// Package santa implements the tables for getting Santa data
// (logs/status) on macOS.
//
// Santa is an open source macOS endpoint security system with
// binary whitelisting and blacklisting capabilities.
// Based on https://github.com/allenhouchins/fleet-extensions/tree/main/santa
package santa
import (
"context"
"encoding/json"
"os/exec"
"strconv"
"github.com/osquery/osquery-go/plugin/table"
"github.com/rs/zerolog/log"
)
// declare execCommandContext for testing
var execCommandContext = exec.CommandContext
type santaStatus struct {
WatchItems struct {
Enabled bool `json:"enabled"`
} `json:"watch_items"`
Daemon struct {
FileLogging bool `json:"file_logging"`
WatchdogRamEvents int `json:"watchdog_ram_events"`
DriverConnected bool `json:"driver_connected"`
LogType string `json:"log_type"`
WatchdogCpuEvents int `json:"watchdog_cpu_events"`
Mode string `json:"mode"`
WatchdogCpuPeak float64 `json:"watchdog_cpu_peak"`
WatchdogRamPeak float64 `json:"watchdog_ram_peak"`
TransitiveRules bool `json:"transitive_rules"`
RemountUsbMode string `json:"remount_usb_mode"`
BlockUsb bool `json:"block_usb"`
OnStartUsbOptions string `json:"on_start_usb_options"`
} `json:"daemon"`
Cache struct {
RootCacheCount int `json:"root_cache_count"`
NonRootCacheCount int `json:"non_root_cache_count"`
} `json:"cache"`
StaticRules struct {
RuleCount int `json:"rule_count"`
} `json:"static_rules"`
Database struct {
CertificateRules int `json:"certificate_rules"`
CdhashRules int `json:"cdhash_rules"`
TransitiveRules int `json:"transitive_rules"`
TeamidRules int `json:"teamid_rules"`
SigningidRules int `json:"signingid_rules"`
CompilerRules int `json:"compiler_rules"`
BinaryRules int `json:"binary_rules"`
EventsPendingUpload int `json:"events_pending_upload"`
} `json:"database"`
Sync struct {
LastSuccessfulRule string `json:"last_successful_rule"`
PushNotifications string `json:"push_notifications"`
BundleScanning bool `json:"bundle_scanning"`
CleanRequired bool `json:"clean_required"`
Server string `json:"server"`
LastSuccessfulFull string `json:"last_successful_full"`
} `json:"sync"`
}
func StatusColumns() []table.ColumnDefinition {
return []table.ColumnDefinition{
table.TextColumn("last_successful_rule"),
table.TextColumn("push_notifications"),
table.IntegerColumn("bundle_scanning"),
table.IntegerColumn("clean_required"),
table.TextColumn("server"),
table.TextColumn("last_successful_full"),
table.IntegerColumn("file_logging"),
table.IntegerColumn("watchdog_ram_events"),
table.IntegerColumn("driver_connected"),
table.TextColumn("log_type"),
table.IntegerColumn("watchdog_cpu_events"),
table.TextColumn("mode"),
table.DoubleColumn("watchdog_cpu_peak"),
table.DoubleColumn("watchdog_ram_peak"),
table.IntegerColumn("transitive_rules_enabled"),
table.TextColumn("remount_usb_mode"),
table.IntegerColumn("block_usb"),
table.TextColumn("on_start_usb_options"),
table.IntegerColumn("root_cache_count"),
table.IntegerColumn("non_root_cache_count"),
table.IntegerColumn("static_rule_count"),
table.IntegerColumn("certificate_rules"),
table.IntegerColumn("cdhash_rules"),
table.IntegerColumn("transitive_rules_count"),
table.IntegerColumn("teamid_rules"),
table.IntegerColumn("signingid_rules"),
table.IntegerColumn("compiler_rules"),
table.IntegerColumn("binary_rules"),
table.IntegerColumn("events_pending_upload"),
table.IntegerColumn("watch_items_enabled"),
}
}
func GenerateStatus(ctx context.Context, _ table.QueryContext) ([]map[string]string, error) {
cmd := execCommandContext(ctx, "/usr/local/bin/santactl", "status", "--json")
output, err := cmd.Output()
if err != nil {
// Gracefully return an empty result if santactl fails
log.Debug().Err(err).Msg("failed to run santactl status --json")
return []map[string]string{}, nil
}
var status santaStatus
if err := json.Unmarshal(output, &status); err != nil {
return nil, err
}
row := map[string]string{
"last_successful_rule": status.Sync.LastSuccessfulRule,
"push_notifications": status.Sync.PushNotifications,
"bundle_scanning": boolToIntString(status.Sync.BundleScanning),
"clean_required": boolToIntString(status.Sync.CleanRequired),
"server": status.Sync.Server,
"last_successful_full": status.Sync.LastSuccessfulFull,
"file_logging": boolToIntString(status.Daemon.FileLogging),
"watchdog_ram_events": strconv.Itoa(status.Daemon.WatchdogRamEvents),
"driver_connected": boolToIntString(status.Daemon.DriverConnected),
"log_type": status.Daemon.LogType,
"watchdog_cpu_events": strconv.Itoa(status.Daemon.WatchdogCpuEvents),
"mode": status.Daemon.Mode,
"watchdog_cpu_peak": floatToString(status.Daemon.WatchdogCpuPeak),
"watchdog_ram_peak": floatToString(status.Daemon.WatchdogRamPeak),
"transitive_rules_enabled": boolToIntString(status.Daemon.TransitiveRules),
"remount_usb_mode": status.Daemon.RemountUsbMode,
"block_usb": boolToIntString(status.Daemon.BlockUsb),
"on_start_usb_options": status.Daemon.OnStartUsbOptions,
"root_cache_count": strconv.Itoa(status.Cache.RootCacheCount),
"non_root_cache_count": strconv.Itoa(status.Cache.NonRootCacheCount),
"static_rule_count": strconv.Itoa(status.StaticRules.RuleCount),
"certificate_rules": strconv.Itoa(status.Database.CertificateRules),
"cdhash_rules": strconv.Itoa(status.Database.CdhashRules),
"transitive_rules_count": strconv.Itoa(status.Database.TransitiveRules),
"teamid_rules": strconv.Itoa(status.Database.TeamidRules),
"signingid_rules": strconv.Itoa(status.Database.SigningidRules),
"compiler_rules": strconv.Itoa(status.Database.CompilerRules),
"binary_rules": strconv.Itoa(status.Database.BinaryRules),
"events_pending_upload": strconv.Itoa(status.Database.EventsPendingUpload),
"watch_items_enabled": boolToIntString(status.WatchItems.Enabled),
}
return []map[string]string{row}, nil
}
func boolToIntString(b bool) string {
if b {
return "1"
}
return "0"
}
func floatToString(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
+196
View File
@@ -0,0 +1,196 @@
//go:build darwin
package santa
import (
"context"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
"github.com/osquery/osquery-go/plugin/table"
"github.com/stretchr/testify/require"
)
func TestGenerateStatus_HappyPath(t *testing.T) {
t.Cleanup(func() { execCommandContext = exec.CommandContext })
execCommandContext = fakeExecCommandContext(t, sampleStatusJSON())
rows, err := GenerateStatus(context.Background(), table.QueryContext{})
require.NoError(t, err)
require.Len(t, rows, 1)
row := rows[0]
// spot check a few fields and types
require.Equal(t, "2025-09-01T12:34:56Z", row["last_successful_rule"])
require.Equal(t, "apns", row["push_notifications"])
require.Equal(t, "1", row["bundle_scanning"]) // bool → "1"
require.Equal(t, "0", row["clean_required"])
require.Equal(t, "monitor", row["mode"])
require.Equal(t, "3", row["watchdog_cpu_events"])
require.Equal(t, "42", row["root_cache_count"])
require.Equal(t, "1", row["watch_items_enabled"])
// float formatting should be plain (no trailing zeros or scientific unless big)
require.True(t, strings.Contains(row["watchdog_ram_peak"], "1024"))
}
func TestGenerateStatus_CommandErrorReturnsEmptyNoError(t *testing.T) {
t.Cleanup(func() { execCommandContext = exec.CommandContext })
execCommandContext = fakeExecCommandContext(t, "ERROR: nope", withExitCode(1))
rows, err := GenerateStatus(context.Background(), table.QueryContext{})
require.NoError(t, err)
require.Empty(t, rows)
}
func TestGenerateStatus_BadJSONReturnsError(t *testing.T) {
t.Cleanup(func() { execCommandContext = exec.CommandContext })
execCommandContext = fakeExecCommandContext(t, "{not-json}")
rows, err := GenerateStatus(context.Background(), table.QueryContext{})
require.Error(t, err)
require.Nil(t, rows)
}
func TestGenerateStatus_ContextCancelBehavesLikeCmdError(t *testing.T) {
t.Cleanup(func() { execCommandContext = exec.CommandContext })
// Simulate a slow command; we'll cancel the context before it returns
execCommandContext = fakeExecCommandContext(t, sampleStatusJSON(), withSleep(200*time.Millisecond))
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
rows, err := GenerateStatus(ctx, table.QueryContext{})
require.NoError(t, err) // your code treats cmd failure gracefully
require.Empty(t, rows)
}
func TestStatusColumns_Contract(t *testing.T) {
cols := StatusColumns()
require.Greater(t, len(cols), 10)
names := make(map[string]struct{}, len(cols))
for _, c := range cols {
names[c.Name] = struct{}{}
}
// a few key columns to lock contract
for _, name := range []string{
"last_successful_rule", "push_notifications", "bundle_scanning",
"file_logging", "mode", "watchdog_cpu_events", "watch_items_enabled",
} {
if _, ok := names[name]; !ok {
t.Fatalf("missing column %q", name)
}
}
}
func TestHelpers(t *testing.T) {
require.Equal(t, "1", boolToIntString(true))
require.Equal(t, "0", boolToIntString(false))
// float formatting: expect compact representation
got := floatToString(1.25)
require.Equal(t, "1.25", got)
got = floatToString(1024.0)
require.Equal(t, "1024", got)
}
// ---- test helpers ----
type fakeOpt func(*fakeCfg)
type fakeCfg struct {
exitCode int
sleep time.Duration
}
func withExitCode(code int) fakeOpt {
return func(c *fakeCfg) { c.exitCode = code }
}
func withSleep(d time.Duration) fakeOpt {
return func(c *fakeCfg) { c.sleep = d }
}
func fakeExecCommandContext(t *testing.T, payload string, opts ...fakeOpt) func(ctx context.Context, name string, args ...string) *exec.Cmd {
t.Helper()
cfg := &fakeCfg{}
for _, o := range opts {
o(cfg)
}
return func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestHelperProcess") //nolint: gosec
cmd.Env = append(os.Environ(),
"GO_WANT_HELPER_PROCESS=1",
"FAKE_PAYLOAD="+payload,
"FAKE_EXIT_CODE="+strconv.Itoa(cfg.exitCode),
"FAKE_SLEEP_MS="+strconv.Itoa(int(cfg.sleep.Milliseconds())),
)
return cmd
}
}
// This is invoked as a subprocess by fakeExecCommandContext
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
sleepMS := os.Getenv("FAKE_SLEEP_MS")
if sleepMS != "" && sleepMS != "0" {
if d, _ := time.ParseDuration(sleepMS + "ms"); d > 0 {
time.Sleep(d)
}
}
exit := 0
if v := os.Getenv("FAKE_EXIT_CODE"); v != "" && v != "0" {
exit = 1
}
_, _ = os.Stdout.WriteString(os.Getenv("FAKE_PAYLOAD"))
if exit != 0 {
os.Exit(exit)
}
os.Exit(0)
}
func sampleStatusJSON() string {
return `{
"watch_items": { "enabled": true },
"daemon": {
"file_logging": true,
"watchdog_ram_events": 5,
"driver_connected": true,
"log_type": "file",
"watchdog_cpu_events": 3,
"mode": "monitor",
"watchdog_cpu_peak": 1.25,
"watchdog_ram_peak": 1024,
"transitive_rules": true,
"remount_usb_mode": "ro",
"block_usb": false,
"on_start_usb_options": "block"
},
"cache": { "root_cache_count": 42, "non_root_cache_count": 7 },
"static_rules": { "rule_count": 9 },
"database": {
"certificate_rules": 1,
"cdhash_rules": 2,
"transitive_rules": 3,
"teamid_rules": 4,
"signingid_rules": 5,
"compiler_rules": 6,
"binary_rules": 7,
"events_pending_upload": 8
},
"sync": {
"last_successful_rule": "2025-09-01T12:34:56Z",
"push_notifications": "apns",
"bundle_scanning": true,
"clean_required": false,
"server": "https://example.test",
"last_successful_full": "2025-09-01T12:34:56Z"
}
}`
}