Detector API Reference¶
Complete reference documentation for the Tracee Detector API. This guide documents all interfaces, structures, and features for writing custom detectors.
New to detectors? Start with the Quick Start Guide first.
Table of Contents¶
- Core Interfaces
- DetectorDefinition
- Detector Requirements
- DetectorOutput
- Auto-Population
- Lifecycle Management
- Testing
- Best Practices
Core Interfaces¶
EventDetector Interface¶
All detectors must implement this interface:
type EventDetector interface {
// GetDefinition returns the detector's metadata and requirements
// Called once during registration - result is cached
GetDefinition() DetectorDefinition
// Init is called once at startup with shared resources
// Use this to store logger, datastores, and initialize state
Init(params DetectorParams) error
// OnEvent processes each matching event
// Return one or more DetectorOutput, nil for no detection, or error
OnEvent(ctx context.Context, event *v1beta1.Event) ([]DetectorOutput, error)
}
DetectorParams¶
Provided to Init() with shared resources:
type DetectorParams struct {
Logger Logger // Structured logger (zap-based)
DataStores datastores.Registry // Access to all datastores
Config DetectorConfig // Detector-specific configuration
}
Example:
func (d *MyDetector) Init(params detection.DetectorParams) error {
d.logger = params.Logger
d.dataStores = params.DataStores
// Check required datastore availability
if !params.DataStores.IsAvailable("process") {
return errors.New("process store required but unavailable")
}
d.logger.Debugw("Detector initialized", "id", d.GetDefinition().ID)
return nil
}
DetectorDefinition¶
Complete Structure¶
type DetectorDefinition struct {
// Unique identifier (required)
ID string
// Event and datastore requirements (required)
Requirements DetectorRequirements
// Output event specification (required)
ProducedEvent v1beta1.EventDefinition
// Default threat metadata (optional, recommended for threats)
ThreatMetadata *v1beta1.Threat
// Auto-population configuration (optional, recommended)
AutoPopulate AutoPopulateFields
}
Detector ID Conventions¶
Choose an appropriate ID format:
Threat Detectors (TRC-XXX):
ID: "TRC-001" // Sensitive file access
ID: "TRC-014" // Process injection detection
Derived Events (DRV-XXX):
ID: "DRV-001" // Container lifecycle events
ID: "DRV-003" // Hooked syscall detector
Custom/Vendor (VENDOR-XXX):
ID: "ACME-001" // Custom company detection
ProducedEvent: Event Definition¶
Defines the event your detector produces:
ProducedEvent: v1beta1.EventDefinition{
Name: "my_detection", // Required: unique snake_case name
Description: "Detailed description", // Required: clear explanation
Version: &v1beta1.Version{ // Required: semantic version
Major: 1,
Minor: 0,
Patch: 0,
},
Fields: []*v1beta1.EventField{ // Required: output fields schema
{
Name: "field_name",
Type: "const char*", // Type for display/docs
},
},
Tags: []string{"security", "file"}, // Optional: categorization
}
Field Types (documentation only):
const char*- Stringint,u32,u64- Integersbool- Boolean- Custom types for complex data
Example with all fields:
ProducedEvent: v1beta1.EventDefinition{
Name: "suspicious_shell_execution",
Description: "Detects suspicious shell command execution patterns",
Version: &v1beta1.Version{Major: 1, Minor: 2, Patch: 0},
Fields: []*v1beta1.EventField{
{Name: "command", Type: "const char*"},
{Name: "shell_type", Type: "const char*"},
{Name: "risk_score", Type: "int"},
{Name: "indicators", Type: "const char*[]"},
},
Tags: []string{"execution", "defense-evasion"},
}
ThreatMetadata: Threat Template¶
Default threat information, copied to outputs when AutoPopulate.Threat=true:
ThreatMetadata: &v1beta1.Threat{
Name: "Short threat name",
Description: "Detailed threat description",
Severity: v1beta1.Severity_MEDIUM,
Signature: &v1beta1.ThreatSignature{
ID: "MITRE T1055",
Name: "Process Injection",
},
Mitre: &v1beta1.MitreAttack{
Tactic: []string{"Defense Evasion", "Privilege Escalation"},
Technique: []string{"T1055"},
SubTechnique: []string{"T1055.001"},
},
}
Severity Levels:
Severity_INFO- Informational eventsSeverity_LOW- Low-risk threatsSeverity_MEDIUM- Moderate threats (default for most detections)Severity_HIGH- Serious threats requiring immediate attentionSeverity_CRITICAL- Critical threats (data exfiltration, rootkits)
Override per detection:
return []detection.DetectorOutput{{
Data: data,
Threat: &v1beta1.Threat{
Name: "Critical System Compromise",
Severity: v1beta1.Severity_CRITICAL, // Override default MEDIUM
},
}}, nil
Detector Requirements¶
DetectorRequirements Structure¶
Declare what your detector needs:
type DetectorRequirements struct {
// Events lists the events this detector needs to receive
Events []EventRequirement
// DataStores lists required datastores with their dependency types
DataStores []DataStoreRequirement
// Enrichments lists required event enrichment options
Enrichments []EnrichmentRequirement
// Architectures lists supported CPU architectures (empty = all)
Architectures []string
// MinTraceeVersion specifies minimum Tracee version (optional, inclusive)
MinTraceeVersion *v1beta1.Version
// MaxTraceeVersion specifies maximum Tracee version (optional, exclusive)
MaxTraceeVersion *v1beta1.Version
}
EventRequirement Structure¶
Declare which events your detector needs:
type EventRequirement struct {
Name string // Event name (required)
DataFilters []string // Filter by event data (optional)
ScopeFilters []string // Filter by origin (optional)
MinVersion *v1beta1.Version // Minimum event version (optional)
MaxVersion *v1beta1.Version // Maximum event version (optional, exclusive)
Dependency DependencyType // Required vs optional (optional, default Required)
}
Basic Requirements¶
Requirements: detection.DetectorRequirements{
Events: []detection.EventRequirement{
{Name: "security_file_open"},
{Name: "security_inode_unlink"},
},
}
DataFilters: Filtering Event Data¶
Engine-level filtering - only matching events reach your detector:
{
Name: "security_file_open",
DataFilters: []string{
"pathname=/etc/shadow", // Exact match
"pathname=/etc/sudoers", // OR condition
},
}
Filter operators:
"field=value" // Exact match
"field=/path/*" // Glob pattern (*, ?)
"field!=value" // Not equal
"field>100" // Greater than (numeric)
"field<100" // Less than (numeric)
"field>=100" // Greater or equal
"field<=100" // Less or equal
Multiple filters (AND logic):
DataFilters: []string{
"pathname=/tmp/*", // Must match /tmp/*
"flags>0", // AND flags > 0
}
Common patterns:
// Sensitive paths
DataFilters: []string{
"pathname=/etc/shadow",
"pathname=/etc/passwd",
"pathname=/root/.ssh/*",
}
// Specific flags
DataFilters: []string{
"flags=0x80000", // O_CLOEXEC
}
// Suspicious syscalls
DataFilters: []string{
"syscall=ptrace",
"syscall=process_vm_writev",
}
ScopeFilters: Filtering by Origin¶
Filter by where events originated:
{
Name: "security_file_open",
ScopeFilters: []string{
"container=started", // Only containers
},
}
Scope options:
"container=started" // Only from containers
"not-container" // Only from host (not containers)
"pid=1000" // Specific PID
"pid!=1" // Exclude init process
Event Version Constraints¶
Require minimum event version:
{
Name: "security_bprm_check",
MinVersion: &v1beta1.Version{
Major: 2,
Minor: 1,
},
}
Required vs Optional Events¶
type DependencyType int
const (
DependencyRequired DependencyType = iota // Detector fails if event unavailable
DependencyOptional // Detector works without event
)
Example:
Events: []detection.EventRequirement{
{
Name: "security_file_open",
Dependency: detection.DependencyRequired, // Must have
},
{
Name: "hooked_syscalls",
Dependency: detection.DependencyOptional, // Nice to have
},
}
DataStore Requirements¶
Declare datastore dependencies:
type DataStoreRequirement struct {
Name string // Datastore name
Dependency DependencyType // Required vs optional
}
Available datastores:
process- Process tree and ancestrycontainer- Container metadatadns- DNS cachesystem- System informationsyscall- Syscall mappingssymbol- Kernel symbols
Example:
Requirements: detection.DetectorRequirements{
Events: []detection.EventRequirement{
{Name: "security_file_open"},
},
DataStores: []detection.DataStoreRequirement{
{Name: "process", Dependency: detection.DependencyRequired},
{Name: "container", Dependency: detection.DependencyOptional},
},
}
Enrichment Requirements¶
Request specific data enrichments on input events:
type EnrichmentRequirement struct {
Name string // Enrichment option name
Dependency DependencyType // Required vs optional
Config string // Enrichment-specific config (optional)
}
Available enrichments:
exec-env- Execution environment variablesexec-hash- Executable file hashes (Config: "inode", "dev-inode", "digest-inode")
Example:
DetectorDefinition{
ID: "TRC-001",
Requirements: detection.DetectorRequirements{
Enrichments: []detection.EnrichmentRequirement{
{
Name: "exec-hash",
Config: "digest-inode",
Dependency: detection.DependencyRequired,
},
},
},
}
Architecture Filtering¶
Restrict detector to specific CPU architectures (rarely needed):
DetectorDefinition{
ID: "TRC-X86-001",
Requirements: detection.DetectorRequirements{
Architectures: []string{"amd64"}, // Only x86-64
// ... rest of requirements
},
}
Supported architectures:
amd64(x86-64)arm64(AArch64)
Default: Empty slice (or omit field) means the detector supports all architectures.
When to use: Only when your detector uses architecture-specific logic or syscalls that don't exist on all platforms.
Tracee Version Constraints¶
Require minimum/maximum Tracee version:
DetectorDefinition{
ID: "TRC-NEW-001",
Requirements: detection.DetectorRequirements{
MinTraceeVersion: &v1beta1.Version{
Major: 0,
Minor: 20,
Patch: 0,
},
// MaxTraceeVersion is optional (exclusive)
},
}
When to use: Your detector relies on features introduced in specific Tracee versions.
Complete Requirements Example¶
Putting it all together - a detector with all requirement types:
DetectorDefinition{
ID: "TRC-COMPREHENSIVE-001",
Requirements: detection.DetectorRequirements{
// Event requirements with filters
Events: []detection.EventRequirement{
{
Name: "security_file_open",
DataFilters: []string{
"pathname=/etc/shadow",
"pathname=/etc/sudoers",
"pathname=/root/.ssh/*",
},
ScopeFilters: []string{
"container=started", // Containers only
},
MinVersion: &v1beta1.Version{Major: 1, Minor: 5},
Dependency: detection.DependencyRequired,
},
{
Name: "security_inode_unlink",
DataFilters: []string{"pathname=/var/log/*"},
Dependency: detection.DependencyOptional,
},
},
// Datastore requirements
DataStores: []detection.DataStoreRequirement{
{Name: "process", Dependency: detection.DependencyRequired},
{Name: "container", Dependency: detection.DependencyOptional},
},
// Enrichment requirements
Enrichments: []detection.EnrichmentRequirement{
{
Name: "exec-hash",
Config: "digest-inode",
Dependency: detection.DependencyRequired,
},
{
Name: "exec-env",
Dependency: detection.DependencyOptional,
},
},
// Architecture constraints (rarely needed)
Architectures: []string{"amd64", "arm64"},
// Version constraints (if detector uses new features)
MinTraceeVersion: &v1beta1.Version{
Major: 0,
Minor: 20,
Patch: 0,
},
// MaxTraceeVersion is optional (exclusive upper bound)
},
// ... rest of definition (ProducedEvent, ThreatMetadata, etc.)
}
DetectorOutput¶
Structure¶
What your OnEvent() returns:
type DetectorOutput struct {
// Event data fields (required) - corresponds to ProducedEvent.Fields
Data []*v1beta1.EventValue
// Override auto-population settings for this output (optional)
// If nil, uses Definition.AutoPopulate
AutoPopulate *AutoPopulateFields
// Override threat metadata (optional) - overrides Definition.ThreatMetadata
Threat *v1beta1.Threat
// Override ancestry depth (optional) - overrides AutoPopulate.ProcessAncestry
AncestryDepth *uint32
}
Creating Event Data¶
Use helper functions to create type-safe event values:
// String values
v1beta1.NewStringValue("field_name", "value")
// Integer values
v1beta1.NewInt32Value("count", int32(42))
v1beta1.NewInt64Value("count", int64(42))
v1beta1.NewUInt32Value("flags", uint32(0x1234))
v1beta1.NewUInt64Value("flags", uint64(0x1234))
// Boolean values
v1beta1.NewBoolValue("is_suspicious", true)
// Byte array values
v1beta1.NewBytesValue("data", []byte{0x01, 0x02})
// Complex example
return []detection.DetectorOutput{{
Data: []*v1beta1.EventValue{
v1beta1.NewStringValue("command", "/bin/bash -c 'curl evil.com'"),
v1beta1.NewStringValue("shell", "bash"),
v1beta1.NewInt32Value("risk_score", int32(85)),
v1beta1.NewBoolValue("known_ioc", true),
},
}}, nil
Multiple Outputs¶
Return multiple detections from a single input:
func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
var outputs []detection.DetectorOutput
if condition1 {
outputs = append(outputs, detection.DetectorOutput{
Data: []*v1beta1.EventValue{
v1beta1.NewStringValue("type", "pattern_a"),
},
})
}
if condition2 {
outputs = append(outputs, detection.DetectorOutput{
Data: []*v1beta1.EventValue{
v1beta1.NewStringValue("type", "pattern_b"),
},
Threat: &v1beta1.Threat{
Severity: v1beta1.Severity_HIGH, // Different severity
},
})
}
return outputs, nil
}
No Detection¶
Return nil, nil when no detection occurred:
func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
value, found := v1beta1.GetData[string](event, "field")
if !found || !d.isSuspicious(value) {
return nil, nil // No detection
}
return []detection.DetectorOutput{{...}}, nil
}
Auto-Population¶
Declaratively specify automatic field enrichment:
type AutoPopulateFields struct {
Threat bool // Copy ThreatMetadata to output
DetectedFrom bool // Link to triggering event
ProcessAncestry bool // Fetch 5 levels of process ancestry
}
Threat: Automatic Threat Metadata¶
AutoPopulate: detection.AutoPopulateFields{
Threat: true,
}
Behavior:
- If
output.Threatis nil, engine copiesDefinition.ThreatMetadata - If
output.Threatis set, that takes precedence Definition.ThreatMetadataacts as a template/default
Example:
// In definition
ThreatMetadata: &v1beta1.Threat{
Name: "Suspicious File Access",
Severity: v1beta1.Severity_MEDIUM,
}
// In OnEvent - uses default
return []detection.DetectorOutput{{
Data: data,
// Threat: nil - engine copies ThreatMetadata above
}}, nil
// In OnEvent - overrides default
return []detection.DetectorOutput{{
Data: data,
Threat: &v1beta1.Threat{
Name: "Critical System File Access",
Severity: v1beta1.Severity_CRITICAL, // Higher severity
},
}}, nil
DetectedFrom: Provenance Tracking¶
AutoPopulate: detection.AutoPopulateFields{
DetectedFrom: true,
}
Behavior: Engine sets output.DetectedFrom with:
event_id- Triggering event's IDevent_name- Triggering event's namestack_addresses- Stack trace (if available)
Output structure:
{
"detected_from": {
"event_id": 257,
"event_name": "security_file_open",
"stack_addresses": [...]
}
}
ProcessAncestry: Automatic Ancestry Enrichment¶
AutoPopulate: detection.AutoPopulateFields{
ProcessAncestry: true, // Default: 5 levels
}
Requirements:
- Tracee must run with
--stores process.source=both - Process must be in the process tree
- Default depth: 5 levels (parent → grandparent → great-grandparent → ...)
Ancestry structure:
{
"workload": {
"process": {
"entity_id": 12345,
"pid": 67890,
"executable": {"path": "/bin/cat"},
"ancestors": [
{"entity_id": 12344, "pid": 67889, "executable": {"path": "/bin/bash"}},
{"entity_id": 12343, "pid": 67888, "thread": {"name": "sshd"}},
{"entity_id": 1, "pid": 1, "thread": {"name": "systemd"}}
]
}
}
}
Per-detection ancestry depth control:
func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
severity := d.analyzeThreat(event)
if severity == "critical" {
// Deep ancestry for forensics
depth := uint32(10)
return []detection.DetectorOutput{{
Data: data,
AncestryDepth: &depth,
}}, nil
}
if severity == "low" {
// Disable ancestry for performance
depth := uint32(0)
return []detection.DetectorOutput{{
Data: data,
AncestryDepth: &depth,
}}, nil
}
// Use default (ProcessAncestry=true → 5 levels)
return []detection.DetectorOutput{{
Data: data,
// AncestryDepth: nil
}}, nil
}
Complete Auto-Population Example¶
DetectorDefinition{
ID: "TRC-001",
ThreatMetadata: &v1beta1.Threat{
Name: "Sensitive File Access",
Severity: v1beta1.Severity_MEDIUM,
},
AutoPopulate: detection.AutoPopulateFields{
Threat: true, // Copy ThreatMetadata
DetectedFrom: true, // Add provenance
ProcessAncestry: true, // Add 5-level ancestry
},
}
// OnEvent just returns data - engine enriches everything else
func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
return []detection.DetectorOutput{{
Data: []*v1beta1.EventValue{
v1beta1.NewStringValue("file", "/etc/shadow"),
},
// Engine automatically adds:
// - Threat (from ThreatMetadata)
// - DetectedFrom (provenance)
// - Workload.Process.Ancestors (5 levels)
}}, nil
}
Overriding Auto-Population Per Output¶
You can override auto-population settings for specific outputs using the AutoPopulate field in DetectorOutput:
func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
severity := d.analyzeThreat(event)
if severity == "critical" {
// Enable all enrichment for critical threats
return []detection.DetectorOutput{{
Data: data,
AutoPopulate: &detection.AutoPopulateFields{
Threat: true,
DetectedFrom: true,
ProcessAncestry: true,
},
}}, nil
}
if severity == "low" {
// Disable enrichment for low-severity detections (performance)
return []detection.DetectorOutput{{
Data: data,
AutoPopulate: &detection.AutoPopulateFields{
Threat: true,
DetectedFrom: false, // Skip provenance
ProcessAncestry: false, // Skip ancestry
},
}}, nil
}
// Use default from Definition.AutoPopulate
return []detection.DetectorOutput{{
Data: data,
// AutoPopulate: nil - uses Definition.AutoPopulate
}}, nil
}
Use cases for per-output overrides:
- Vary enrichment based on threat severity
- Skip expensive enrichment for low-confidence detections
- Add extra context for high-priority alerts
Lifecycle Management¶
Init() Best Practices¶
Called once at startup - use for initialization:
func (d *MyDetector) Init(params detection.DetectorParams) error {
// 1. Store logger (always do this)
d.logger = params.Logger
// 2. Store datastores if needed
d.dataStores = params.DataStores
// 3. Validate required datastores
if !params.DataStores.IsAvailable("process") {
return errors.New("process store required but unavailable")
}
// 4. Initialize detector state
d.cache = make(map[string]int)
// 5. Log initialization
d.logger.Debugw("Detector initialized",
"id", d.GetDefinition().ID,
"version", d.GetDefinition().ProducedEvent.Version,
)
return nil
}
Don't do in Init():
- Heavy computation (blocks startup)
- Network requests
- File I/O (except small config files)
OnEvent() Guidelines¶
Called for every matching event - must be fast:
func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
// 1. Respect context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// 2. Extract required data early
value, found := v1beta1.GetData[string](event, "field")
if !found {
return nil, nil // Skip silently
}
// 3. Fast-path rejection
if !d.couldBeSuspicious(value) {
return nil, nil
}
// 4. Detailed analysis
if d.isSuspicious(value) {
return []detection.DetectorOutput{{
Data: []*v1beta1.EventValue{
v1beta1.NewStringValue("field", value),
},
}}, nil
}
return nil, nil
}
Performance tips:
- Check cheapest conditions first
- Use datastores judiciously (they're fast but not free)
- Avoid allocations in hot path
- Consider caching expensive computations
Error Handling Guidelines¶
Transient errors - Return error, engine may retry:
proc, err := d.dataStores.Processes().GetProcess(entityId)
if err != nil && !errors.Is(err, datastores.ErrNotFound) {
return nil, fmt.Errorf("failed to get process: %w", err)
}
Expected conditions - Return nil detection:
// Data not found - this is normal
if errors.Is(err, datastores.ErrNotFound) {
return nil, nil
}
// Optional field missing - expected
value, found := v1beta1.GetData[string](event, "optional_field")
if !found {
return nil, nil
}
Critical errors - Log and return error:
if err := d.criticalOperation(); err != nil {
d.logger.Errorw("Critical operation failed",
"error", err,
"event", event.Name,
)
return nil, fmt.Errorf("critical failure: %w", err)
}
Error wrapping:
// Good - provides context
return nil, fmt.Errorf("failed to analyze process %d: %w", pid, err)
// Bad - loses context
return nil, err
Testing¶
Unit Testing Patterns¶
Test detectors with mock events:
package detectors
import (
"context"
"testing"
"github.com/aquasecurity/tracee/api/v1beta1"
"github.com/aquasecurity/tracee/api/v1beta1/detection"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSensitiveFileAccess_OnEvent(t *testing.T) {
detector := &SensitiveFileAccess{}
// Initialize with test params
err := detector.Init(detection.DetectorParams{
Logger: &mockLogger{}, // Use your mock logger implementation
Config: detection.NewEmptyDetectorConfig(),
})
require.NoError(t, err)
t.Run("detects_shadow_access", func(t *testing.T) {
event := &v1beta1.Event{
Id: 257,
Name: "security_file_open",
Data: []*v1beta1.EventValue{
v1beta1.NewStringValue("pathname", "/etc/shadow"),
},
}
outputs, err := detector.OnEvent(context.Background(), event)
require.NoError(t, err)
require.Len(t, outputs, 1)
// Verify output data
data := outputs[0].Data
require.Len(t, data, 2)
assert.Equal(t, "file_path", data[0].Name)
assert.Equal(t, "/etc/shadow", data[0].GetValue().GetStringValue())
})
t.Run("ignores_normal_files", func(t *testing.T) {
event := &v1beta1.Event{
Id: 257,
Name: "security_file_open",
Data: []*v1beta1.EventValue{
v1beta1.NewStringValue("pathname", "/tmp/normal.txt"),
},
}
outputs, err := detector.OnEvent(context.Background(), event)
require.NoError(t, err)
assert.Empty(t, outputs) // No detection expected
})
}
Mock Logger and DataStores¶
Create mock implementations for testing:
// Mock logger for testing
type mockLogger struct{}
func (m *mockLogger) Debugw(msg string, keysAndValues ...any) {}
func (m *mockLogger) Infow(msg string, keysAndValues ...any) {}
func (m *mockLogger) Warnw(msg string, keysAndValues ...any) {}
func (m *mockLogger) Errorw(msg string, keysAndValues ...any) {}
// Mock process store
type mockProcessStore struct {
processes map[uint32]*datastores.ProcessInfo
}
func (m *mockProcessStore) GetProcess(entityId uint32) (*datastores.ProcessInfo, error) {
proc, ok := m.processes[entityId]
if !ok {
return nil, datastores.ErrNotFound
}
return proc, nil
}
// Mock datastore registry
type mockRegistry struct {
processStore datastores.ProcessStore
}
func (m *mockRegistry) Processes() datastores.ProcessStore {
return m.processStore
}
func (m *mockRegistry) IsAvailable(name string) bool {
if name == "process" && m.processStore != nil {
return true
}
return false
}
func TestDetectorWithDataStore(t *testing.T) {
// Create mock store with test data
mockStore := &mockProcessStore{
processes: map[uint32]*datastores.ProcessInfo{
12345: {
EntityID: 12345,
Executable: &v1beta1.Executable{Path: "/bin/bash"},
},
},
}
// Create mock registry
registry := &mockRegistry{processStore: mockStore}
// Initialize detector with mocks
detector := &MyDetector{}
err := detector.Init(detection.DetectorParams{
Logger: &mockLogger{},
DataStores: registry,
Config: detection.NewEmptyDetectorConfig(),
})
require.NoError(t, err)
// Test with mock data
// ...
}
Test Helpers¶
Use built-in type-safe helpers for data extraction:
// Type-safe data extraction with assertion
pathname, found := v1beta1.GetData[string](event, "pathname")
require.True(t, found, "pathname field should exist")
assert.Equal(t, "/etc/shadow", pathname)
Best Practices¶
Detector Design Principles¶
1. Single Responsibility Each detector should focus on one threat pattern or derived event type.
Good:
// Focused on one threat
type ContainerEscapeDetector struct {}
// Focused on one derived event type
type ContainerLifecycleDetector struct {}
Bad:
// Too broad
type AllContainerThreatsDetector struct {} // Don't do this!
2. Fail Fast Reject events as early as possible:
func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
// 1. Check required fields first
pathname, found := v1beta1.GetData[string](event, "pathname")
if !found {
return nil, nil
}
// 2. Quick pattern checks
if !strings.HasPrefix(pathname, "/etc/") {
return nil, nil
}
// 3. Expensive checks last
if d.isKnownMaliciousPattern(pathname) {
// Create detection
}
return nil, nil
}
3. Leverage Engine Filtering
Use DataFilters instead of logic in OnEvent():
Good:
DataFilters: []string{
"pathname=/etc/*",
"flags>0",
}
func (d *MyDetector) OnEvent(...) {
// Events already filtered - just detection logic
}
Bad:
func (d *MyDetector) OnEvent(...) {
pathname, _ := v1beta1.GetData[string](event, "pathname")
if !strings.HasPrefix(pathname, "/etc/") { // Should be in DataFilters!
return nil, nil
}
}
4. Use Auto-Population Let the engine handle enrichment:
Good:
AutoPopulate: detection.AutoPopulateFields{
Threat: true,
DetectedFrom: true,
ProcessAncestry: true,
}
func (d *MyDetector) OnEvent(...) {
return []detection.DetectorOutput{{
Data: simpleData, // Just the data fields
}}, nil
}
Bad:
func (d *MyDetector) OnEvent(...) {
// Don't manually query ancestry if you can auto-populate!
ancestry, _ := d.dataStores.Processes().GetAncestry(entityId, 5)
// Manual enrichment is error-prone and slower
}
5. Graceful Degradation Handle missing datastores gracefully:
// In Init - validate required stores
if !params.DataStores.IsAvailable("process") {
return errors.New("process store required")
}
// In OnEvent - handle optional stores
if params.DataStores.IsAvailable("container") {
containerInfo, _ := d.dataStores.Containers().GetContainer(id)
// Use if available
}
Performance Considerations¶
1. Minimize DataStore Queries
// Good - single query
proc, err := d.dataStores.Processes().GetProcess(entityId)
// Bad - unnecessary queries
proc, _ := d.dataStores.Processes().GetProcess(entityId)
ancestors, _ := d.dataStores.Processes().GetAncestry(entityId, 5)
// Could use AutoPopulate.ProcessAncestry instead!
2. Cache Expensive Computations
type MyDetector struct {
mu sync.RWMutex
cache map[string]result
}
func (d *MyDetector) expensiveCheck(key string) result {
// Check cache (read lock)
d.mu.RLock()
if cached, ok := d.cache[key]; ok {
d.mu.RUnlock()
return cached
}
d.mu.RUnlock()
// Compute (write lock)
d.mu.Lock()
defer d.mu.Unlock()
// Double-check after acquiring write lock
if cached, ok := d.cache[key]; ok {
return cached
}
result := d.compute(key)
d.cache[key] = result
return result
}
3. Batch Operations If checking multiple conditions, batch them:
// Good - batch check
if d.isSuspiciousPath(pathname) &&
d.isSuspiciousFlags(flags) &&
d.isSuspiciousProcess(proc) {
// All checks passed
}
// Bad - create intermediary slices
var checks []bool
checks = append(checks, d.isSuspiciousPath(pathname))
checks = append(checks, d.isSuspiciousFlags(flags))
// Unnecessary allocations
4. Use Appropriate Data Structures
// Good - fast lookup
type MyDetector struct {
suspiciousPaths map[string]bool // O(1) lookup
}
// Bad - linear search
type MyDetector struct {
suspiciousPaths []string // O(n) lookup
}
Code Organization¶
File structure:
detectors/
├── my_detector.go # Detector implementation
├── my_detector_test.go # Unit tests
└── my_detector_patterns.go # Optional: complex pattern logic
Detector file template:
package detectors
// 1. Imports
import (
"context"
"github.com/aquasecurity/tracee/api/v1beta1"
"github.com/aquasecurity/tracee/api/v1beta1/detection"
)
// 2. Auto-registration
func init() {
register(&MyDetector{})
}
// 3. Detector struct
type MyDetector struct {
logger detection.Logger
dataStores datastores.Registry
// ... other state
}
// 4. GetDefinition
func (d *MyDetector) GetDefinition() detection.DetectorDefinition {
// ...
}
// 5. Init
func (d *MyDetector) Init(params detection.DetectorParams) error {
// ...
}
// 6. OnEvent
func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
// ...
}
// 7. Private helper methods
func (d *MyDetector) helperMethod() {
// ...
}
Data Access Helpers¶
Type-Safe Event Data Extraction¶
Use generic GetData[T] for compile-time type safety:
// String extraction
pathname, found := v1beta1.GetData[string](event, "pathname")
if !found {
return nil, nil
}
// Integer extraction
flags, found := v1beta1.GetData[int64](event, "flags")
// Unsigned integer
uid, found := v1beta1.GetData[uint64](event, "uid")
// Boolean
success, found := v1beta1.GetData[bool](event, "success")
// Byte array
data, found := v1beta1.GetData[[]byte](event, "data")
Null-Safe Accessors¶
Use protobuf's generated Get*() methods - they're nil-safe:
// Safe - returns empty string if any field is nil
processPath := event.GetWorkload().GetProcess().GetExecutable().GetPath()
// Safe - returns 0 if nil
entityId := event.GetWorkload().GetProcess().GetEntityId()
// Safe - returns empty slice if nil
ancestors := event.GetWorkload().GetProcess().GetAncestors()
Never do this:
// UNSAFE - panics if any field is nil!
if event.Workload != nil && event.Workload.Process != nil {
path := event.Workload.Process.Executable.Path // Can still panic!
}
Common Access Patterns¶
// Process information
entityId := event.GetWorkload().GetProcess().GetEntityId()
pid := event.GetWorkload().GetProcess().GetPid()
executable := event.GetWorkload().GetProcess().GetExecutable().GetPath()
commandLine := event.GetWorkload().GetProcess().GetCommandLine()
// Container information (if in container)
containerId := event.GetWorkload().GetContainer().GetId()
containerName := event.GetWorkload().GetContainer().GetName()
containerImage := event.GetWorkload().GetContainer().GetImage().GetName()
// Kubernetes information (if available)
namespace := event.GetWorkload().GetK8s().GetNamespace()
podName := event.GetWorkload().GetK8s().GetPod().GetName()
// User information
uid := event.GetWorkload().GetProcess().GetRealUser().GetId()
username := event.GetWorkload().GetProcess().GetRealUser().GetName()
Next Steps¶
- Quick Start: Write your first detector
- DataStore API: Complete datastore reference
- Real Examples: Browse
detectors/directory
Getting Help¶
- GitHub Issues: https://github.com/aquasecurity/tracee/issues
- Discussions: https://github.com/aquasecurity/tracee/discussions
- API Definitions:
api/v1beta1/detection/detector.go
Migration from Signatures¶
This section helps you migrate existing signatures to the new detector API.
Quick Comparison¶
| Feature | Old Signature API | New Detector API |
|---|---|---|
| Package | package main (plugin) |
package detectors (compiled-in) |
| Interface | detect.Signature |
detection.EventDetector |
| Event Access | protocol.Event (runtime casting) |
*v1beta1.Event (direct protobuf) |
| Data Extraction | Manual loop through Args |
GetData[T] (type-safe) |
| Event Filtering | GetSelectedEvents() (name only) |
EventRequirement (data + scope filters) |
| Output | Async ctx.Callback() |
Synchronous return []detection.DetectorOutput |
| Metadata | GetMetadata() separate |
GetDefinition() unified |
| State Management | Manual | LRU caches + datastore access |
| Context Access | Limited | Full datastore access (process, container, etc.) |
| Auto-enrichment | Manual | Declarative (AutoPopulateFields) |
| Testing | Callback mocking | Direct function calls |
| Registration | ExportedSignatures list |
init() auto-registration |
Step-by-Step Migration¶
Before (old signature):
package main
import (
"github.com/aquasecurity/tracee/types/detect"
"github.com/aquasecurity/tracee/types/protocol"
)
type MySignature struct {
cb detect.SignatureHandler
}
func (s *MySignature) GetMetadata() detect.SignatureMetadata {
return detect.SignatureMetadata{
ID: "TRC-001",
Name: "Sensitive File Access",
Description: "Detects access to sensitive files",
Version: "1.0.0",
Severity: detect.SeverityMedium,
}
}
func (s *MySignature) GetSelectedEvents() []detect.SignatureEventSelector {
return []detect.SignatureEventSelector{
{Source: "tracee", Name: "security_file_open"},
}
}
func (s *MySignature) Init(ctx detect.SignatureContext) error {
s.cb = ctx.Callback
return nil
}
func (s *MySignature) OnEvent(event protocol.Event) error {
traceEvent, ok := event.Payload.(trace.Event)
if !ok {
return fmt.Errorf("unexpected event type")
}
var pathname string
for _, arg := range traceEvent.Args {
if arg.Name == "pathname" {
pathname = arg.Value.(string)
break
}
}
if !strings.HasPrefix(pathname, "/etc/") {
return nil
}
s.cb(&detect.Finding{
SigMetadata: s.GetMetadata(),
Event: event,
Data: map[string]interface{}{
"file": pathname,
},
})
return nil
}
func (s *MySignature) Close() {}
After (new detector):
package detectors
import (
"context"
"strings"
"github.com/aquasecurity/tracee/api/v1beta1"
"github.com/aquasecurity/tracee/api/v1beta1/detection"
)
func init() {
register(&SensitiveFileAccess{})
}
type SensitiveFileAccess struct {
logger detection.Logger
}
func (d *SensitiveFileAccess) GetDefinition() detection.DetectorDefinition {
return detection.DetectorDefinition{
ID: "TRC-001",
Requirements: detection.DetectorRequirements{
Events: []detection.EventRequirement{
{
Name: "security_file_open",
DataFilters: []string{"pathname=/etc/*"}, // Engine filters
},
},
},
ProducedEvent: v1beta1.EventDefinition{
Name: "sensitive_file_access",
Description: "Detects access to sensitive files",
Version: &v1beta1.Version{Major: 1, Minor: 0, Patch: 0},
Fields: []*v1beta1.EventField{
{Name: "file", Type: "const char*"},
},
},
ThreatMetadata: &v1beta1.Threat{
Name: "Sensitive File Access",
Description: "Access to sensitive system files detected",
Severity: v1beta1.Severity_MEDIUM,
},
AutoPopulate: detection.AutoPopulateFields{
Threat: true,
DetectedFrom: true,
ProcessAncestry: true,
},
}
}
func (d *SensitiveFileAccess) Init(params detection.DetectorParams) error {
d.logger = params.Logger
return nil
}
func (d *SensitiveFileAccess) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
// Type-safe extraction (no casting)
pathname, found := v1beta1.GetData[string](event, "pathname")
if !found {
return nil, nil
}
// No need to filter by /etc/* - engine already did it
return []detection.DetectorOutput{{
Data: []*v1beta1.EventValue{
v1beta1.NewStringValue("file", pathname),
},
// Threat, DetectedFrom, ProcessAncestry auto-populated by engine
}}, nil
}
// Close() is optional - only implement if you need cleanup
Migration Checklist¶
- [ ] Change package from
maintodetectors - [ ] Add
init() { register(&YourDetector{}) } - [ ] Update imports to
api/v1beta1packages - [ ] Replace
detect.Signaturewithdetection.EventDetector - [ ] Combine
GetMetadata()+GetSelectedEvents()→GetDefinition() - [ ] Move data filters from
OnEvent()toEventRequirement.DataFilters - [ ] Replace
protocol.Eventcasting with*v1beta1.Eventparameter - [ ] Replace manual
Argsloop withGetData[T]() - [ ] Replace
ctx.Callback()with synchronousreturn []detection.DetectorOutput{} - [ ] Return
DetectorOutputwithDatafields instead of fullv1beta1.Event - [ ] Add
AutoPopulateFieldsfor automatic enrichment - [ ] Update tests to call
OnEvent()directly and verifyDetectorOutput - [ ] Remove
Close()if empty (optional interface)
Common Pattern Translations¶
Pattern: Event Selection
// Before
GetSelectedEvents() []detect.SignatureEventSelector {
return []detect.SignatureEventSelector{
{Source: "tracee", Name: "openat"},
}
}
// After
Requirements: detection.DetectorRequirements{
Events: []detection.EventRequirement{
{Name: "openat"},
},
},
Pattern: Data Extraction
// Before
var pathname string
for _, arg := range traceEvent.Args {
if arg.Name == "pathname" {
pathname = arg.Value.(string) // Runtime casting
break
}
}
// After
pathname, found := v1beta1.GetData[string](event, "pathname") // Compile-time safety
if !found {
return nil, nil
}
Pattern: Filtering
// Before (in OnEvent)
if !strings.HasPrefix(pathname, "/etc/") {
return nil // Manual filter
}
// After (in EventRequirement)
DataFilters: []string{"pathname=/etc/*"}, // Engine filters before dispatch
Pattern: Callback → Return
// Before
s.cb(&detect.Finding{
SigMetadata: s.GetMetadata(),
Event: event,
Data: map[string]interface{}{"file": pathname},
})
return nil
// After
return []detection.DetectorOutput{{
Data: []*v1beta1.EventValue{
v1beta1.NewStringValue("file", pathname),
},
}}, nil
Troubleshooting¶
Problem: Detector Not Running¶
Symptoms: Detector code exists but never triggers
Diagnosis:
# Check if detector is registered
sudo ./dist/tracee list | grep detectors
# Check Tracee logs
sudo ./dist/tracee --logging debug
Solutions:
1. Verify init() { register(&YourDetector{}) } exists
2. Rebuild Tracee: make clean && make tracee
3. Check for panics in Init() (blocks registration)
Problem: No Events Received¶
Symptoms: OnEvent() never called
Diagnosis:
1. Check event name: sudo ./dist/tracee list | grep event_name
2. Temporarily remove DataFilters to see if they're too restrictive
3. Add debug logging in OnEvent()
Solutions:
1. Fix event name typo in EventRequirement.Name
2. Adjust DataFilters - they might be filtering everything out
3. Check ScopeFilters - ensure they match your test environment
Problem: DataStore Returns ErrNotFound¶
Symptoms: GetProcess(), GetContainer() return ErrNotFound
Possible causes:
1. Process tree not enabled: --stores process.source=both
2. Process exited before query
3. Container not tracked yet
Solutions:
// Always handle ErrNotFound gracefully
proc, err := d.dataStores.Processes().GetProcess(entityId)
if errors.Is(err, datastores.ErrNotFound) {
// This is normal - process might have exited
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("unexpected error: %w", err)
}
Problem: High CPU Usage¶
Symptoms: Tracee consuming excessive CPU
Diagnosis: 1. Check if detector receives too many events 2. Profile detector logic
Solutions:
1. Add more restrictive DataFilters
2. Optimize detector logic - avoid expensive operations in OnEvent()
3. Use caching for repeated computations
4. Consider batching if doing aggregations
Problem: Memory Leaks¶
Symptoms: Memory usage grows over time
Causes: 1. Unbounded maps/slices in detector state 2. Not cleaning up old entries
Solutions:
// Use LRU cache with TTL instead of unbounded maps
import "github.com/hashicorp/golang-lru/v2/expirable"
func (d *MyDetector) Init(params detection.DetectorParams) error {
// Bounded cache with expiration
d.cache = expirable.NewLRU[string, *data](
1000, // Max entries
nil, // No eviction callback
5*time.Minute, // TTL
)
return nil
}