Skip to content

feat(ebpf): Add sound in-kernel approvers - #736

Merged
rabbitstack merged 5 commits into
rabbitstack:linux-portfrom
mostafa:feat/linux-approvers
Sep 22, 2026
Merged

rabbitstack merged 5 commits into
rabbitstack:linux-portfrom
mostafa:feat/linux-approvers

Conversation

@mostafa

@mostafa mostafa commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Adds optional in-kernel approvers to the Linux eBPF source. Events that cannot possibly match the compiled ruleset or the CLI filter are dropped before they reach the ring buffer, which cuts userspace parsing and enrichment work on high-volume syscalls.

The design goal is that prefiltering never changes what the engine sees. A prefilter is installed only when the predicate can be proven a superset of userspace matches; everything else stays default-allow and is evaluated in userspace exactly as before. There are no discarders.

What gets extracted

filter.ApproverProvider is an optional capability on filter.Filter. The rule compiler emits a platform-neutral filter.ApproverPlan, bootstrap hands it to the source, and the CLI expression contributes its own plan through SetFilter. RulesCompileResult stays a usage summary and is not read as predicate data.

Supported shapes are positive =, in, startswith, and matches over:

Field Kernel check
ps.pid, evt.pid bpf_get_current_pid_tgid() >> 32 against a hash
file.path exact hash, then LPM trie for prefixes
net.dport destination port parsed from the stashed sockaddr

matches is supported by rewriting patterns rather than matching globs in the kernel. A pattern with no wildcard becomes an exact lookup and one ending in a single star becomes an LPM prefix, both exactly rather than conservatively, since a trailing star spans every remaining byte including /. A star anywhere else, any ?, and imatches leave the event type default-allow. A backtracking matcher in BPF is not verifiable: every byte comparison forks a path, and even two patterns over 64 steps exhausts the 1M instruction budget across 29668 states.

Anything else forces default-allow for the affected event types: boolean OR, not, !=, comparisons, imatches, function calls, bound fields, and sequences. A filter carrying no evt.name or evt.category predicate is applied to every type, and a filter the extractor does not understand at all blocks prefiltering for every type it could match.

execve, exit, and clone are never prefiltered. The process snapshotter consumes them to build identity, so dropping one would desynchronize process state from live tasks.

ps.name is deliberately not extracted. The only process name the kernel can read cheaply is the live task comm, while ps.name is snapshot state captured at execve. The two diverge under prctl(PR_SET_NAME) and whenever the snapshot falls back to an executable basename longer than comm's 16 bytes, so approving on comm would drop events that userspace matches.

Combining rules and the CLI filter

Plans union with filter.Union. Required fields intersect and their allowed values union, so the result is always at least as permissive as either input, and any default-allow policy wins. Userspace applies the rule engine and the CLI filter as a conjunction, so a union is a safe over-approximation of both.

Reload

Map sets are versioned into two generations. A reload clears the inactive generation, populates it off-path, then flips a single generation counter. The retired generation is intentionally left populated: a program that read the old generation just before the flip still resolves its lookups against it, and wiping it immediately would turn those in-flight events into drops. The next reload clears it before reuse.

SetFilter can run before Open, so the source stores the plan and populates maps only once the collections are loaded. A failed population returns before the flip, so the previous generation, or default-allow on first load, stays in effect.

Sequence rules are scoped using the same string-field set rules.Engine indexes them by, so the approver and the engine cannot disagree about which types reach a rule. A destination port is only enforced for connect, because that is the only event the kernel parses a sockaddr for; a port requirement on any other type keeps the rest of its policy instead of comparing against zero.

ebpf.approver.drops reports how many events the kernel rejected, alongside the existing ebpf.ringbuf.drops.

Tests

  • Extraction unit tests for each supported shape and for every unsupported shape falling back to default-allow, including a test pinning that ps.name cannot narrow a policy.
  • FuzzApproverNoFalseNegatives asserts that any event the userspace filter matches is also kept by the prefilter, across 21 expressions and 6 event types.
  • TestApproverDecisionsMatchLoadedPrograms drives real openat calls through the loaded programs and asserts the approved path arrives, the unapproved one does not, and the reject counter advances. The Go Allows model and the BPF predicate are separate implementations, so this is what makes the property meaningful. It runs twice, once with no approver installed and once with one, so a failure says whether capture or approval broke. The opens come from a short-lived child because some runners do not report the tracing process's own syscalls.
  • TestEngineIndexesSequenceByStringFieldTypes pins the indexing assumption the sequence scoping relies on, and TestExtractFilePathList pins that a list of literal paths resolves entirely in the exact-match hash.
  • TestTrailingStarRewriteMatchesOperator checks the rewrite against wildcard.Match directly, and TestNonPrefixGlobsAreDefaultAllow pins that everything else widens instead of being approximated.
  • TestModeArrayMatchesGeneratedMap checks the Go mode-array stride against the generated map spec so it cannot drift from EVT_TYPE_MAX.
  • A privileged integration test covers the generation flip and verifies that an oversized plan fails without disturbing the active generation.
  • BenchmarkApproverReduction measures the drop ratio; a mixed openat path set yields 0.60.

Objects were regenerated with Ubuntu 24.04 clang 18. make test, the generation drift check, rules validate, and a GOOS=windows build of the touched packages all pass.

@mostafa
mostafa force-pushed the feat/linux-approvers branch from fdd182a to 2184b50 Compare September 21, 2026 16:31
Comment thread internal/ebpf/c/common/approvers.h
@mostafa
mostafa force-pushed the feat/linux-approvers branch from ff24bc6 to ec9013d Compare September 22, 2026 08:48

@rabbitstack rabbitstack left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Comment thread internal/ebpf/c/common/approvers.h Outdated
Comment thread internal/ebpf/c/common/approvers.h
Comment thread internal/ebpf/approver_test.go
Let the matches operator narrow the in-kernel prefilter instead of
forcing default-allow, by rewriting the patterns that have an exact
kernel equivalent rather than matching globs in BPF.

A pattern with no wildcard becomes an exact lookup, and one ending in a
single star becomes an LPM prefix. Both rewrites are exact, not merely
conservative, because a trailing star spans every remaining byte
including a path separator, exactly as wildcard.matchCaseSensitive does.
Runs of stars collapse first, so /tmp/** arrives here as /tmp/*. A star
anywhere else, or any '?', leaves the event type default-allow.

Matching globs in the kernel was the obvious approach and does not
survive the verifier. A faithful port of matchCaseSensitive backtracks,
so every byte comparison forks a path the verifier has to walk: at eight
patterns it exceeded the 8192 jump-sequence limit, and cutting it to two
patterns over 64 steps still burned the full 1M instruction budget
across 29668 states. Directory-aware globbing would verify more cheaply
but reject paths the operator accepts in userspace, which is the silent
false negative this prefilter exists to avoid.

imatches stays unsupported: folding in the kernel would have to agree
with unicode.ToLower on every rune.
@mostafa
mostafa force-pushed the feat/linux-approvers branch 3 times, most recently from bc88776 to b68dd51 Compare September 22, 2026 10:46
@mostafa

mostafa commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

CI is green now. The privileged job had been failing on my new prefilter test, and the cause turned out to be the test rather than the approver, so recording it here since it may bite other tests later.

I split the test into two phases, one with no approver installed and one with, which immediately located it: the no-approver phase failed too, and the diagnostic showed selfOpens=0 while other processes' opens came through fine:

pid=3390 selfOpens=0 saw ["pid=1979 sys=257 /proc/stat" "pid=1365 sys=257 /var/run/waagent.pid" ...]

So on that runner the syscall tracepoints never reported the tracing process's own opens, while every other process was captured normally. The test now drives the opens from a short-lived child, which is how TestLiveSyscallTelemetry already does it, and that is presumably why that test was unaffected.

I kept the two-phase structure. It costs one extra source startup, but it means a future failure says whether capture or approval broke, instead of blaming the approver for something upstream of it.

Pin that a list of literal paths resolves entirely in the kernel's
exact-match hash, since nothing about it needs the wildcard machinery.

Run the privileged prefilter test twice, once with no approver and once
with one. The first pass establishes that the capture path reports the
open at all, so a failure in the second pass is about the prefilter and
not about an enter/exit correlation that lost its scratch entry.
@mostafa
mostafa force-pushed the feat/linux-approvers branch from b68dd51 to edc5c12 Compare September 22, 2026 10:59
@rabbitstack
rabbitstack merged commit b5075eb into rabbitstack:linux-port Sep 22, 2026
1 check passed
@mostafa
mostafa deleted the feat/linux-approvers branch September 22, 2026 11:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants