31010 santa tables (#33218)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}`
|
||||
}
|
||||
Reference in New Issue
Block a user