diff --git a/.github/workflows/linux-port.yml b/.github/workflows/linux-port.yml index adebae1fa..91061f369 100644 --- a/.github/workflows/linux-port.yml +++ b/.github/workflows/linux-port.yml @@ -64,3 +64,9 @@ jobs: run: sudo -E env "PATH=$PATH" go test -tags ebpf_integration -count=1 -run TestPrerequisitesAreMet -v ./internal/ebpf - name: Privileged process source run: sudo -E env "PATH=$PATH" make test-integration + - name: Package + run: make pkg + - uses: actions/upload-artifact@v4 + with: + name: fibratus-linux-packages + path: build/pkg/* diff --git a/.gitignore b/.gitignore index 263b19bde..c133999cb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ build/msi/*.msi .idea filaments/__pycache__ + +# Linux packages +build/pkg/ diff --git a/Makefile b/Makefile index 2abb04bdd..e93b372a0 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ SHELL := /bin/bash BPF2GO_VERSION := v0.20.0 GOLANGCI_LINT_VERSION := v2.9.0 +NFPM_VERSION := v2.43.0 # The committed objects are byte-compared in CI, and clang encodes its version # into BTF, so generating with a different major produces a spurious diff. @@ -138,6 +139,17 @@ LINT_PKGS := \ lint: golangci-lint run $(LINT_PKGS) +VERSION ?= 0.0.0 +PKG_DIR := build/pkg +NFPM ?= go run github.com/goreleaser/nfpm/v2/cmd/nfpm@$(NFPM_VERSION) + +.PHONY: pkg +pkg: build + @mkdir -p $(PKG_DIR) + VERSION=$(VERSION) $(NFPM) package --config build/linux/nfpm.yaml --packager deb --target $(PKG_DIR) + VERSION=$(VERSION) $(NFPM) package --config build/linux/nfpm.yaml --packager rpm --target $(PKG_DIR) + .PHONY: clean clean: rm -f cmd/fibratus/fibratus + rm -rf $(PKG_DIR) diff --git a/build/linux/fibratus.service b/build/linux/fibratus.service new file mode 100644 index 000000000..3e0d265b0 --- /dev/null +++ b/build/linux/fibratus.service @@ -0,0 +1,44 @@ +[Unit] +Description=Fibratus +Documentation=https://www.fibratus.io +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/fibratus run +Restart=on-failure +RestartSec=5s + +# Fibratus starts as root because attaching a syscall tracepoint reads its id +# from /sys/kernel/tracing, which is root-only on stock distributions. The +# bounding set is what actually constrains it: everything below is dropped, so +# the process keeps only what the backend and the kill action need. +# +# CAP_BPF load programs, create and update maps +# CAP_PERFMON attach to tracepoints +# CAP_SYS_PTRACE read /proc//exe and cmdline of other users' processes +# CAP_KILL signal a target, needed only by the kill rule action +# +# CAP_BPF and CAP_PERFMON were split out of CAP_SYS_ADMIN in 5.8. On a kernel +# that predates the split, replace both with CAP_SYS_ADMIN. +CapabilityBoundingSet=CAP_BPF CAP_PERFMON CAP_SYS_PTRACE CAP_KILL +AmbientCapabilities=CAP_BPF CAP_PERFMON CAP_SYS_PTRACE CAP_KILL +NoNewPrivileges=yes + +# full rather than strict: strict would also mount /run read-only and the API +# socket lives there. +ProtectSystem=full +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes + +# Process enrichment reads other processes' procfs entries, so this must stay +# permissive. Hiding them would silently empty ps.exe and ps.cmdline. +ProtectProc=default + +[Install] +WantedBy=multi-user.target diff --git a/build/linux/nfpm.yaml b/build/linux/nfpm.yaml new file mode 100644 index 000000000..7d272a362 --- /dev/null +++ b/build/linux/nfpm.yaml @@ -0,0 +1,46 @@ +name: fibratus +arch: amd64 +platform: linux +version: ${VERSION} +section: admin +priority: optional +maintainer: Nedim Sabic Sabic +description: | + Security sensor for realtime threat detection and protection +vendor: Fibratus +homepage: https://fibratus.io +license: Apache-2.0 + +# The BPF objects are embedded in the binary at compile time, so there is +# nothing to ship alongside it. +contents: + - src: ./cmd/fibratus/fibratus + dst: /usr/bin/fibratus + + - src: ./configs/fibratus-linux.yml + dst: /etc/fibratus/fibratus.yml + type: config|noreplace + + - src: ./rules/linux/*.yml + dst: /etc/fibratus/rules/ + + - src: ./rules/linux/macros/macros.yml + dst: /etc/fibratus/rules/macros/macros.yml + + - src: ./build/linux/fibratus.service + dst: /lib/systemd/system/fibratus.service + +overrides: + rpm: + contents: + - src: ./cmd/fibratus/fibratus + dst: /usr/bin/fibratus + - src: ./configs/fibratus-linux.yml + dst: /etc/fibratus/fibratus.yml + type: config|noreplace + - src: ./rules/linux/*.yml + dst: /etc/fibratus/rules/ + - src: ./rules/linux/macros/macros.yml + dst: /etc/fibratus/rules/macros/macros.yml + - src: ./build/linux/fibratus.service + dst: /usr/lib/systemd/system/fibratus.service diff --git a/cmd/fibratus/app/stats/stats_linux.go b/cmd/fibratus/app/stats/stats_linux.go index da5a11dfb..67ac58257 100644 --- a/cmd/fibratus/app/stats/stats_linux.go +++ b/cmd/fibratus/app/stats/stats_linux.go @@ -21,4 +21,30 @@ package stats // Stats stores runtime statistics that are retrieved from the expvar endpoint. -type Stats struct{} +// Field order is the order the table renders in, so counters are grouped by the +// stage they describe: capture, then the kernel-side drops worth alerting on, +// then the startup handover, then the rule engine. +type Stats struct { + EventsProcessed int `json:"ebpf.events.processed"` + EventsExcluded int `json:"ebpf.events.excluded"` + EventsUnknown int `json:"ebpf.events.unknown"` + EventsParseErrs int `json:"ebpf.events.parse.errors"` + + RingbufDrops int `json:"ebpf.ringbuf.drops"` + ApproverDrops int `json:"ebpf.approver.drops"` + + EnrichmentMiss int `json:"ebpf.enrichment.miss"` + + StartupPendingQueued int `json:"ebpf.startup.pending.queued"` + StartupPendingDropped int `json:"ebpf.startup.pending.dropped"` + StartupReplayApplied int `json:"ebpf.startup.replay.applied"` + StartupSnapshotUpserts int `json:"ebpf.startup.snapshot.upserts"` + StartupSnapshotLate int `json:"ebpf.startup.snapshot.late"` + + FiltersCount int `json:"filter.filters.count"` + FilterMatches map[string]int `json:"filter.matches"` + + AggregatorBatchEvents int `json:"aggregator.batch.events"` + AggregatorFlushesCount int `json:"aggregator.flushes.count"` + AggregatorEventErrors int `json:"aggregator.event.errors"` +} diff --git a/configs/fibratus-linux.yml b/configs/fibratus-linux.yml new file mode 100644 index 000000000..51b9fb446 --- /dev/null +++ b/configs/fibratus-linux.yml @@ -0,0 +1,132 @@ +# Fibratus configuration for Linux. +# +# Only the settings that apply to the eBPF backend are listed here. Windows-only +# sections, such as filaments, PE introspection, YARA scanning, and handle or +# evasion telemetry, have no effect on this platform and are left out rather +# than shipped inert. + +# =============================== Event source ========================================= + +eventsource: + # Process telemetry is always collected. Execve, exit, and clone build the + # process state every other event is correlated against, so they cannot be + # disabled. + + # Collects openat, unlink, and rename. This is the highest-volume family by a + # wide margin on a busy host. + enable-fileio: true + + # Collects connect and accept. + enable-net: true + + # Collects mmap, process_vm_readv, and process_vm_writev. + enable-mem: true + + # Drops events before they reach the aggregator, by event name or by the + # image name of the process that produced them. + #blacklist: + # events: + # - openat + # images: + # - fibratus + +# =============================== Aggregator =========================================== + +aggregator: + # Determines the flush period that triggers the flushing of the event batches to output sinks + flush-period: 500ms + + # Represents the max time to wait before announcing failed flushing of enqueued events when fibratus + # is stopped + flush-timeout: 4s + +# =============================== Filters and rules ==================================== + +filters: + rules: + # Enables the rule engine. With no rules loaded every event reaches the + # outputs; with rules loaded only matching events do. + enabled: true + + # Rule definitions. The package installs the shipped Linux rules here. + from-paths: + - /etc/fibratus/rules/*.yml + + macros: + from-paths: + - /etc/fibratus/rules/macros/*.yml + +# =============================== API ================================================== + +api: + # The transport for the API HTTP server. A unix:// path keeps the API off the + # network and leaves access to the socket's filesystem permissions. A host:port + # value exposes it over TCP instead, e.g. 192.168.1.32:8084. + transport: unix:///var/run/fibratus.sock + + # Represents the timeout interval for the HTTP server responses. + timeout: 5s + +# =============================== Logging ============================================== + +logging: + # Specifies the minimum allowed log level. Anything logged below this log level will + # not get dumped to a file or stdout stream + level: info + + # Represents the log file format + #formatter: json + + # Under systemd, logging to stdout routes through the journal + log-stdout: true + + # Alternative directory for log files + #path: /var/log/fibratus + +# =============================== Outputs ============================================== + +output: + # Console output writes the event to the standard output stream. + console: + enabled: true + + # Indicates if the console output is colorized + colorize: true + + # The "pretty" format replaces specifiers in the template, "json" emits the + # event as a raw JSON string + format: pretty + + # Elasticsearch output indexes event bulks into Elasticsearch clusters. + elasticsearch: + enabled: false + #servers: + # - http://localhost:9200 + + # HTTP output posts event batches to an HTTP endpoint. + http: + enabled: false + #endpoint: http://localhost:8081 + + # AMQP output publishes events to a message broker. + amqp: + enabled: false + #url: amqp://localhost:5672 + +# =============================== Alert senders ======================================== + +alertsenders: + mail: + enabled: false + #host: smtp.gmail.com + #port: 587 + + slack: + enabled: false + #url: https://hooks.slack.com/services/... + +# =============================== General ============================================== + +# Forwards events to the output sinks without running the rule engine or +# building the full process state. +forward: false diff --git a/docs/setup/linux.md b/docs/setup/linux.md index 0287c5a01..d6d6f1a52 100644 --- a/docs/setup/linux.md +++ b/docs/setup/linux.md @@ -2,6 +2,64 @@ On Linux, Fibratus collects telemetry through eBPF CO-RE programs attached to syscall and scheduler tracepoints, instead of ETW. The rule engine, filters, actions, and outputs are the same on both platforms. +## Installing + +Packages are built for `x86_64` and carry the binary, a configuration, the shipped rules, and a systemd unit. The BPF objects are compiled into the binary, so there is nothing else to install and no compiler is needed on the host. + +``` +# Debian, Ubuntu +sudo dpkg -i fibratus__amd64.deb + +# RHEL, Fedora, Rocky, openSUSE +sudo rpm -i fibratus--1.x86_64.rpm +``` + +| Path | Contents | +| --- | --- | +| `/usr/bin/fibratus` | The binary | +| `/etc/fibratus/fibratus.yml` | Configuration, preserved across upgrades | +| `/etc/fibratus/rules/` | Shipped detection rules | +| `/etc/fibratus/rules/macros/` | Macros the rules expand | +| `/lib/systemd/system/fibratus.service` | Service unit, `/usr/lib/...` on RPM distributions | + +## Quick start + +Check the host before starting anything. This refuses to run and names every unmet requirement at once, rather than failing later inside the capture: + +``` +sudo fibratus rules validate +``` + +Run in the foreground to see events as they arrive: + +``` +sudo fibratus run +``` + +Narrow it with a filter expression, using the same language the rules use: + +``` +sudo fibratus run "evt.name = 'openat' and file.path startswith '/etc'" +``` + +Then run it as a service: + +``` +sudo systemctl enable --now fibratus +sudo systemctl status fibratus +sudo journalctl -u fibratus -f +``` + +The unit logs to the journal. With the rule engine enabled, only events matching a rule reach the outputs, so an idle host is expected to be quiet; drop the rules or set `filters.rules.enabled` to `false` to see the raw stream. + +Inspect a running instance over the API socket: + +``` +sudo fibratus config # the configuration as loaded +sudo fibratus stats # capture, drop, and rule engine counters +sudo fibratus list events +``` + ## Runtime prerequisites These are hard requirements. Fibratus probes each one at startup and refuses to run if any is missing, rather than degrading to partial telemetry. @@ -19,7 +77,7 @@ Nothing needs to be compiled at install time. The BPF objects are generated ahea ## Capabilities -Running as root works. To run unprivileged, grant: +The packaged unit starts Fibratus as root, because attaching a syscall tracepoint reads its id from `/sys/kernel/tracing`, which is root-only on stock distributions. What limits it is the bounding set in the unit, which drops everything except the four capabilities below. Granting these to an unprivileged invocation works too, provided tracefs is readable: | Capability | Needed for | | --- | --- | @@ -80,7 +138,8 @@ Counters are exported as expvars and available through `fibratus stats`. Ordinary builds consume the committed objects: ``` -make +make # the binary +make pkg # deb and rpm into build/pkg ``` Regenerating them needs clang, pinned to the major the committed objects were built with, because clang records its version in BTF and a different major rewrites every object: diff --git a/internal/ebpf/stats_integration_test.go b/internal/ebpf/stats_integration_test.go new file mode 100644 index 000000000..1ff99a911 --- /dev/null +++ b/internal/ebpf/stats_integration_test.go @@ -0,0 +1,94 @@ +//go:build linux && ebpf_integration + +/* + * Copyright 2026 by Mostafa Moradian + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ebpf + +import ( + "encoding/json" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/rabbitstack/fibratus/pkg/api" + "github.com/rabbitstack/fibratus/pkg/ps" + "github.com/rabbitstack/fibratus/pkg/util/rest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestStatsSurfaceOverLiveCapture drives the same path fibratus stats takes: +// the API over its default UNIX socket, reading expvars produced by a live +// capture. Unit tests can assert the counters exist, but only this shows the +// command surface reporting numbers a running sensor actually moved. +func TestStatsSurfaceOverLiveCapture(t *testing.T) { + socket := filepath.Join(t.TempDir(), "fibratus.sock") + cfg := testConfig() + cfg.API.Transport = "unix://" + socket + cfg.API.Timeout = 5 * time.Second + + es := NewEventSource(ps.NewSnapshotter(), cfg, nil, nil).(*EventSource) + require.NoError(t, es.Open(cfg)) + t.Cleanup(func() { _ = es.Close() }) + + require.NoError(t, api.StartServer(cfg)) + t.Cleanup(func() { require.NoError(t, api.CloseServer()) }) + + // Drain, otherwise the queue fills and the counters stop advancing. + go func() { + for range es.Events() { + } + }() + + var vars map[string]any + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + _ = exec.Command("/bin/sh", "-c", "true").Run() + + body, err := rest.Get(rest.WithTransport(cfg.API.Transport), rest.WithURI("debug/vars")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &vars)) + if processed, ok := vars["ebpf.events.processed"].(float64); ok && processed > 0 { + break + } + time.Sleep(200 * time.Millisecond) + } + + require.Contains(t, vars, "ebpf.events.processed") + assert.Greater(t, vars["ebpf.events.processed"], float64(0), "no events were captured") + + // Every counter the Linux stats table renders has to be present, or the + // command prints a zero for something that is simply not published. + for _, name := range []string{ + "ebpf.events.excluded", + "ebpf.events.unknown", + "ebpf.events.parse.errors", + "ebpf.ringbuf.drops", + "ebpf.approver.drops", + "ebpf.enrichment.miss", + "ebpf.startup.pending.queued", + "ebpf.startup.pending.dropped", + "ebpf.startup.replay.applied", + "ebpf.startup.snapshot.upserts", + "ebpf.startup.snapshot.late", + } { + assert.Contains(t, vars, name) + } +} diff --git a/pkg/api/server_linux_test.go b/pkg/api/server_linux_test.go new file mode 100644 index 000000000..038714dc2 --- /dev/null +++ b/pkg/api/server_linux_test.go @@ -0,0 +1,103 @@ +//go:build linux + +/* + * Copyright 2026 by Mostafa Moradian + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package api_test lives outside the package because the REST client imports +// the API for its local transport dialer. +package api_test + +import ( + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + + "github.com/rabbitstack/fibratus/pkg/api" + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/util/rest" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The config endpoint renders through viper, so the config has to come from +// the normal construction path rather than a bare struct. +func newConfig(t *testing.T, transport string) *config.Config { + t.Helper() + c := config.NewWithOpts(config.WithRun()) + c.MustViperize(&cobra.Command{}) + require.NoError(t, c.Init()) + c.API.Transport = transport + return c +} + +func freeTCPAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := l.Addr().String() + require.NoError(t, l.Close()) + return addr +} + +// The endpoints behind fibratus config and fibratus stats have to answer over +// both transports, since a socket is the default and a TCP address is what an +// operator switches to for remote access. +func TestServerServesBothTransports(t *testing.T) { + for _, transport := range []string{ + "unix://" + filepath.Join(t.TempDir(), "fibratus.sock"), + freeTCPAddr(t), + } { + t.Run(transport, func(t *testing.T) { + cfg := newConfig(t, transport) + require.NoError(t, api.StartServer(cfg)) + t.Cleanup(func() { require.NoError(t, api.CloseServer()) }) + + body, err := rest.Get(rest.WithTransport(transport), rest.WithURI("debug/vars")) + require.NoError(t, err) + var vars map[string]any + require.NoError(t, json.Unmarshal(body, &vars)) + assert.Contains(t, vars, "cmdline", "expvar payload is what fibratus stats reads") + + // This endpoint renders the config as text, which is what + // fibratus config prints verbatim. + body, err = rest.Get(rest.WithTransport(transport), rest.WithURI("config")) + require.NoError(t, err) + for _, section := range []string{"aggregator", "api", "eventsource", "filters", "output"} { + assert.Contains(t, string(body), section) + } + }) + } +} + +// A socket left behind by a killed process must not keep the server from +// starting, otherwise an unclean shutdown needs manual cleanup before restart. +func TestServerReplacesStaleSocket(t *testing.T) { + socket := filepath.Join(t.TempDir(), "fibratus.sock") + require.NoError(t, os.WriteFile(socket, nil, 0o600)) + + cfg := newConfig(t, "unix://"+socket) + require.NoError(t, api.StartServer(cfg)) + t.Cleanup(func() { require.NoError(t, api.CloseServer()) }) + + body, err := rest.Get(rest.WithTransport("unix://"+socket), rest.WithURI("debug/vars")) + require.NoError(t, err) + assert.NotEmpty(t, body) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a5e135e12..6de93c8c7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -273,7 +273,7 @@ func (c *Config) addFlags() { c.flags.String(filamentPath, defaultFilamentPath(), "Denotes the directory where filaments are located") } if c.opts.run || c.opts.replay || c.opts.capture || c.opts.stats { - c.flags.String(transport, "localhost:8080", "Specifies the underlying transport protocol for the API HTTP server") + c.flags.String(transport, defaultTransport(), "Specifies the underlying transport protocol for the API HTTP server") c.flags.Duration(timeout, 15*time.Second, "Determines the timeout for the API server responses") } c.Log.AddFlags(c.flags) diff --git a/pkg/config/config_linux.go b/pkg/config/config_linux.go index 7d803ce89..167516995 100644 --- a/pkg/config/config_linux.go +++ b/pkg/config/config_linux.go @@ -44,3 +44,7 @@ func defaultRulesPaths() []string { func defaultMacrosPaths() []string { return []string{filepath.Join("/etc", "fibratus", "rules", "macros", "*")} } + +// defaultTransport keeps the API off the network unless someone asks for it. +// Filesystem permissions on the socket then decide who can reach it. +func defaultTransport() string { return "unix:///var/run/fibratus.sock" } diff --git a/pkg/config/config_linux_test.go b/pkg/config/config_linux_test.go new file mode 100644 index 000000000..68d1bd774 --- /dev/null +++ b/pkg/config/config_linux_test.go @@ -0,0 +1,46 @@ +//go:build linux + +/* + * Copyright 2026 by Mostafa Moradian + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package config + +import ( + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLinuxDefaults(t *testing.T) { + // No config file on the box, which is the state a fresh install starts in. + c := NewWithOpts(WithRun()) + cmd := &cobra.Command{} + c.MustViperize(cmd) + require.NoError(t, c.Init()) + + // The API stays off the network unless asked, so a default install exposes + // nothing beyond what socket permissions allow. + assert.Equal(t, "unix:///var/run/fibratus.sock", c.API.Transport) + + assert.Equal(t, filepath.Join("/etc", "fibratus", "fibratus.yml"), defaultConfigFile()) + assert.Equal(t, []string{filepath.Join("/etc", "fibratus", "rules", "*")}, defaultRulesPaths()) + assert.Equal(t, []string{filepath.Join("/etc", "fibratus", "rules", "macros", "*")}, defaultMacrosPaths()) +} diff --git a/pkg/config/config_windows.go b/pkg/config/config_windows.go index 9e7374d16..eee1c5850 100644 --- a/pkg/config/config_windows.go +++ b/pkg/config/config_windows.go @@ -131,6 +131,8 @@ func defaultMacrosPaths() []string { return []string{filepath.Join(defaultRulesDir(), "Macros", "*")} } +func defaultTransport() string { return "localhost:8080" } + func defaultRulesDir() string { exe, err := os.Executable() if err != nil {