Skip to content

Detector Quick Start Guide

Get your first Tracee detector running in 30 minutes. This hands-on tutorial assumes basic Go knowledge but no prior Tracee experience.

What You'll Build

A detector that identifies when processes access sensitive system files like /etc/shadow or /etc/sudoers, with:

  • Automatic threat metadata enrichment
  • Full process ancestry (parent โ†’ grandparent โ†’ ...)
  • Container context (if applicable)

Prerequisites

# Clone Tracee
git clone https://github.com/aquasecurity/tracee
cd tracee

# Verify build environment
make env

# Required: Linux kernel 4.18+, Go 1.24+, Clang 12+

Step 1: Create Your Detector File

Create detectors/sensitive_file_access.go:

package detectors

import (
    "context"

    "github.com/aquasecurity/tracee/api/v1beta1"
    "github.com/aquasecurity/tracee/api/v1beta1/detection"
)

// Auto-register the detector on startup
func init() {
    register(&SensitiveFileAccess{})
}

// SensitiveFileAccess detects access to sensitive system files
type SensitiveFileAccess struct {
    logger detection.Logger
}

// GetDefinition declares what this detector does and what it needs
func (d *SensitiveFileAccess) GetDefinition() detection.DetectorDefinition {
    return detection.DetectorDefinition{
        ID: "TRC-001",  // Unique identifier

        Requirements: detection.DetectorRequirements{
            Events: []detection.EventRequirement{
                {
                    Name: "security_file_open",
                    // Engine filters - only matching events reach OnEvent()
                    DataFilters: []string{
                        "pathname=/etc/shadow",
                        "pathname=/etc/sudoers",
                    },
                },
            },
        },

        // Define the output event this detector produces
        ProducedEvent: v1beta1.EventDefinition{
            Name:        "sensitive_file_access",
            Description: "Access to sensitive system files detected",
            Version: &v1beta1.Version{Major: 1, Minor: 0, Patch: 0},
            Fields: []*v1beta1.EventField{
                {Name: "file_path", Type: "const char*"},
                {Name: "executable", Type: "const char*"},
            },
        },

        // Threat information template (auto-copied to outputs)
        ThreatMetadata: &v1beta1.Threat{
            Name:        "Sensitive File Access",
            Description: "A process attempted to access a sensitive system file",
            Severity:    v1beta1.Severity_MEDIUM,
        },

        // Tell engine to auto-enrich our outputs
        AutoPopulate: detection.AutoPopulateFields{
            Threat:          true,  // Copy ThreatMetadata above
            DetectedFrom:    true,  // Link to triggering event
            ProcessAncestry: true,  // Add 5 levels of parent processes
        },
    }
}

// Init is called once at startup
func (d *SensitiveFileAccess) Init(params detection.DetectorParams) error {
    d.logger = params.Logger
    d.logger.Infow("SensitiveFileAccess detector initialized")
    return nil
}

// OnEvent processes each matching event
func (d *SensitiveFileAccess) OnEvent(
    ctx context.Context,
    event *v1beta1.Event,
) ([]detection.DetectorOutput, error) {
    // Type-safe data extraction
    pathname, found := v1beta1.GetData[string](event, "pathname")
    if !found {
        return nil, nil  // Skip if pathname missing
    }

    // Extract executable path (nil-safe protobuf getters)
    executablePath := event.GetWorkload().GetProcess().GetExecutable().GetPath()

    // Return output data - engine handles the rest (threat, ancestry, etc.)
    return []detection.DetectorOutput{{
        Data: []*v1beta1.EventValue{
            v1beta1.NewStringValue("file_path", pathname),
            v1beta1.NewStringValue("executable", executablePath),
        },
    }}, nil
}

Step 2: Build Tracee

# Build with your detector included
make tracee

# The detector is compiled in - no plugins or manual registration needed!

Step 3: Run Tracee

# Start Tracee with process tree and JSON output
sudo ./dist/tracee --stores process --output json | jq

Step 4: Trigger Your Detector

In a third terminal, access a sensitive file:

sudo cat /etc/shadow

Step 5: See the Detection

You should see output like:

{
  "id": 5001,
  "name": "sensitive_file_access",
  "version": "1.0.0",
  "timestamp": "2025-12-16T10:30:45.123Z",
  "threat": {
    "name": "Sensitive File Access",
    "description": "A process attempted to access a sensitive system file",
    "severity": "MEDIUM"
  },
  "data": {
    "file_path": "/etc/shadow",
    "executable": "/bin/cat"
  },
  "detected_from": {
    "event_id": 257,
    "event_name": "security_file_open"
  },
  "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"}}
      ]
    }
  }
}

What Just Happened?

Let's break down the magic:

1. Auto-Registration

func init() {
    register(&SensitiveFileAccess{})
}

Your detector registered itself automatically. No manual list maintenance!

2. Engine Filtering

DataFilters: []string{
    "pathname=/etc/shadow",
    "pathname=/etc/sudoers",
},

Tracee's engine filtered millions of security_file_open events. Only those matching your paths reached OnEvent(). This happens in the engine - super efficient!

3. Type-Safe Extraction

pathname, found := v1beta1.GetData[string](event, "pathname")

Generic type parameter ensures compile-time type safety. No runtime casting errors!

4. Auto-Enrichment

AutoPopulate: detection.AutoPopulateFields{
    Threat:          true,
    DetectedFrom:    true,
    ProcessAncestry: true,
}

The engine automatically:

  • โœ… Copied ThreatMetadata to output.Threat
  • โœ… Set output.DetectedFrom pointing to the triggering event
  • โœ… Queried the process tree and populated 5 ancestor levels
  • โœ… Preserved timestamp, workload, and policies from input event

You just returned simple data fields - the engine did the heavy lifting!

Common Patterns

Multiple Outputs

A single input can produce multiple detections:

func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
    var outputs []detection.DetectorOutput

    // Check condition 1
    if isSuspicious {
        outputs = append(outputs, detection.DetectorOutput{
            Data: []*v1beta1.EventValue{
                v1beta1.NewStringValue("reason", "suspicious pattern"),
            },
        })
    }

    // Check condition 2
    if isMalicious {
        outputs = append(outputs, detection.DetectorOutput{
            Data: []*v1beta1.EventValue{
                v1beta1.NewStringValue("reason", "known malware signature"),
            },
        })
    }

    return outputs, nil
}

Custom Threat Severity

Override the default threat metadata per detection:

return []detection.DetectorOutput{{
    Data: []*v1beta1.EventValue{
        v1beta1.NewStringValue("file", pathname),
    },
    Threat: &v1beta1.Threat{
        Name:        "Critical System File Access",
        Severity:    v1beta1.Severity_CRITICAL,  // Override default MEDIUM
        Description: "Root accessed critical system file",
    },
}}, nil

Using DataStores

Access system state like process information:

func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
    entityId := event.GetWorkload().GetProcess().GetEntityId()

    // Query process store for process details
    proc, err := d.dataStores.Processes().GetProcess(entityId)
    if errors.Is(err, datastores.ErrNotFound) {
        // Process not in tree yet
        return nil, nil
    }
    if err != nil {
        return nil, err
    }

    // Use process info in detection logic
    if proc.GetInterpreter().GetPath() == "/bin/bash" {
        return []detection.DetectorOutput{{
            Data: []*v1beta1.EventValue{
                v1beta1.NewStringValue("shell", "bash"),
            },
        }}, nil
    }

    return nil, nil
}

Filtering by Container

Only detect events from containers:

Requirements: detection.DetectorRequirements{
    Events: []detection.EventRequirement{
        {
            Name: "security_file_open",
            ScopeFilters: []string{"container=started"},  // Containers only
        },
    },
}

Or only from host:

ScopeFilters: []string{"not-container"},  // Host only

Accessing Container Information

Detectors can access container information using the container enrichment requirement.

Container enrichment is required for both approaches below - it enables Tracee to query container runtimes and populate metadata.

Option 1: Use Pre-Populated Event Fields

Requirements: detection.DetectorRequirements{
    Events: []detection.EventRequirement{
        {Name: "security_file_open"},
    },
    Enrichments: []detection.EnrichmentRequirement{
        {
            Name:       detection.EnrichmentContainer,
            Dependency: detection.DependencyRequired,
        },
    },
}

func (d *MyDetector) OnEvent(event *v1beta1.Event) {
    // Container fields are pre-populated in the event
    containerName := event.GetWorkload().GetContainer().GetName()
    containerImage := event.GetWorkload().GetContainer().GetImage().GetName()

    if strings.Contains(containerImage, "malicious") {
        // detect...
    }
}

Pros:

  • Simple, direct field access

Cons:

  • Cannot handle missing/not-found containers gracefully

Option 2: Query Container Datastore

Requirements: detection.DetectorRequirements{
    Events: []detection.EventRequirement{
        {Name: "security_file_open"},
    },
    Enrichments: []detection.EnrichmentRequirement{
        {
            Name:       detection.EnrichmentContainer,
            Dependency: detection.DependencyRequired,
        },
    },
    DataStores: []detection.DataStoreRequirement{
        {
            Name:       detection.DataStoreContainer,
            Dependency: detection.DependencyRequired,
        },
    },
}

func (d *MyDetector) OnEvent(event *v1beta1.Event) {
    // Container ID is always available
    containerID := event.GetWorkload().GetContainer().GetId()
    if containerID == "" {
        return nil, nil  // Not a container event
    }

    // Explicitly query datastore for metadata
    containerInfo, err := d.dataStores.Containers().GetContainer(containerID)
    if errors.Is(err, datastores.ErrNotFound) {
        // Handle not-found case (e.g., short-lived container)
        return nil, nil
    }
    if err != nil {
        return nil, err
    }

    if strings.Contains(containerInfo.Image, "malicious") {
        // detect...
    }
}

Pros:

  • Flexible error handling, access to additional datastore methods

Cons:

  • Slightly more verbose

Both approaches require --enrichment container to be enabled. The enrichment flag tells Tracee to query container runtimes and populate both Event fields and the datastore.

User must run: tracee --enrichment container --detector my-detector

Debugging Tips

Enable Debug Logging

# Run with debug logs
sudo ./dist/tracee --logging debug --stores process

Check Detector Registration

# List detector events
sudo ./dist/tracee list | grep detectors

Verify Event Filtering

Check that your event name is correct:

# List available events
sudo ./dist/tracee list | grep security_file_open

Test Without Filters

Temporarily remove DataFilters to see all events:

Requirements: detection.DetectorRequirements{
    Events: []detection.EventRequirement{
        {
            Name: "security_file_open",
            // DataFilters: []string{...},  // Commented out for testing
        },
    },
}

Add logging in OnEvent():

func (d *SensitiveFileAccess) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
    d.logger.Debugw("Event received", "event", event.Name)
    // ... rest of logic
}

Next Steps

Congratulations! You've written your first Tracee detector. Now explore:

๐Ÿ“š Deep Dive into APIs

๐Ÿ”„ Migration and Troubleshooting

๐Ÿ” Study Real Examples

  • Browse detectors/ directory for production implementations
  • See api/v1beta1/detection/detector.go for interface definitions

๐Ÿงช Write Tests

  • Add unit tests in detectors/sensitive_file_access_test.go
  • See Testing Guide for patterns

Troubleshooting

Detector Not Running

Problem: Your detector code changed but behavior didn't

Solution: Rebuild Tracee - detectors are compiled in, not dynamically loaded

make tracee

No Events Received

Problem: OnEvent() never called

Possible causes:

  1. Wrong event name - check tracee list
  2. Filters too restrictive - temporarily remove them
  3. Event not enabled - Tracee enables events based on requirements automatically

Process Ancestry Empty

Problem: workload.process.ancestors is null

Solution: Enable process tree:

sudo ./dist/tracee --stores process

Build Errors

Problem: Compilation fails

Common fixes:

# Update dependencies
make go-tidy

# Clean build
make clean
make tracee

Quick Reference

Detector Skeleton

package detectors

import (
    "context"

    "github.com/aquasecurity/tracee/api/v1beta1"
    "github.com/aquasecurity/tracee/api/v1beta1/datastores"
    "github.com/aquasecurity/tracee/api/v1beta1/detection"
)

func init() {
    register(&MyDetector{})
}

type MyDetector struct {
    logger     detection.Logger
    dataStores datastores.Registry
}

func (d *MyDetector) GetDefinition() detection.DetectorDefinition {
    return detection.DetectorDefinition{
        ID: "TRC-XXX",
        Requirements: detection.DetectorRequirements{
            Events: []detection.EventRequirement{{Name: "event_name"}},
        },
        ProducedEvent: v1beta1.EventDefinition{
            Name:    "my_detection",
            Version: &v1beta1.Version{Major: 1},
        },
        AutoPopulate: detection.AutoPopulateFields{
            Threat:          true,
            DetectedFrom:    true,
            ProcessAncestry: true,
        },
    }
}

func (d *MyDetector) Init(params detection.DetectorParams) error {
    d.logger = params.Logger
    d.dataStores = params.DataStores
    return nil
}

func (d *MyDetector) OnEvent(ctx context.Context, event *v1beta1.Event) ([]detection.DetectorOutput, error) {
    // Your detection logic
    return []detection.DetectorOutput{{
        Data: []*v1beta1.EventValue{},
    }}, nil
}

Essential Commands

# Build
make tracee

# Run with process tree
sudo ./dist/tracee --stores process

# View detections in JSON
sudo ./dist/tracee --output json | jq

# List detector events
sudo ./dist/tracee list | grep detectors

# Debug mode
sudo ./dist/tracee --logging debug

Next Steps

Learn More Advanced Patterns

For a comprehensive example demonstrating all detector API features, see detectors/example_detector.go:

  • DataStore API usage (ContainerStore, SystemStore)
  • Event filtering patterns (scope, data, version constraints)
  • Conditional field population
  • Advanced error handling
  • Proper enrichment patterns

To build and test it:

# Build Tracee with example detectors
make tracee-with-examples

# Run tracee and filter for the example detection
sudo ./dist/tracee -e example_detection --output json

Note: The example detector is excluded from default builds (via build tag) to prevent noise in production environments.


Ready for more? Continue to the API Reference for comprehensive documentation, including migration guides and troubleshooting.