Skip to content

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

Open
mostafa wants to merge 4 commits into
rabbitstack:linux-portfrom
mostafa:feat/linux-approvers
Open

mostafa wants to merge 4 commits into
rabbitstack:linux-portfrom
mostafa:feat/linux-approvers

Conversation

@mostafa

@mostafa mostafa commented Sep 21, 2026

Copy link
Copy Markdown

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, then a wildcard scan
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 15 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.
  • TestEngineIndexesSequenceByStringFieldTypes pins the indexing assumption the sequence scoping relies on.
  • 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
return 1;
}

static __always_inline int approver_file_match(u32 gen, u32 type)

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.

I really like the in-kernel filtering idea. File access events could easily dominate the sensor event stream. By any chance, could we also support the matches operator? It will probably be one of the most-used operators in detection rules. Claude drafted something for me a while ago which essentially mimics the userspace util/wildcard/wildcard.go implementation:

/ ─── Kernel-space glob matcher ───────────────────────────────────────────────
//
// Iterative (non-recursive) wildcard matcher.  All loops are bounded so the
// BPF verifier can prove termination.
//
// Semantics:
//   *   – zero or more chars, does NOT cross '/'
//   **  – zero or more chars, crosses '/' freely
//   ?   – exactly one char that is not '/'
//
// Returns 1 on match, 0 on no-match.
static __always_inline int glob_match(const char *pat, const char *str)
{
    int pi = 0, si = 0;
    int star_pi = -1, star_si = -1;
    int double_star = 0;
 
    #pragma unroll
    for (int iter = 0; iter < GLOB_MAX_ITER; iter++) {
        char p = (pi < MAX_PATH_LEN) ? pat[pi] : '\0';
        char s = (si < MAX_PATH_LEN) ? str[si] : '\0';
 
        // Both exhausted → full match.
        if (p == '\0' && s == '\0')
            return 1;
 
        // Detect '**' (two consecutive asterisks).
        if (p == '*' && pi + 1 < MAX_PATH_LEN && pat[pi + 1] == '*') {
            star_pi    = pi;
            star_si    = si;
            double_star = 1;
            pi += 2;
            continue;
        }
 
        // Single '*'.
        if (p == '*') {
            star_pi    = pi;
            star_si    = si;
            double_star = 0;
            pi++;
            continue;
        }
 
        // '?' matches any single non-slash char.
        if (p == '?' && s != '\0' && s != '/') {
            pi++; si++;
            continue;
        }
 
        // Literal match.
        if (p != '\0' && p == s) {
            pi++; si++;
            continue;
        }
 
        // Mismatch – backtrack to last star if available.
        if (star_pi >= 0) {
            // Single '*' must not cross directory separators.
            if (!double_star && (si < MAX_PATH_LEN) && str[si] == '/')
                return 0;
            // Advance the string pointer for the '*' and retry.
            star_si++;
            if (star_si >= MAX_PATH_LEN)
                return 0;
            si = star_si;
            pi = star_pi + (double_star ? 2 : 1);
            continue;
        }
 
        return 0; // no wildcard to backtrack to
    }
 
    // Loop exhausted – check if both pointers are at the end.
    char p = (pi < MAX_PATH_LEN) ? pat[pi] : '\0';
    char s = (si < MAX_PATH_LEN) ? str[si] : '\0';
    return (p == '\0' && s == '\0') ? 1 : 0;
}
 
// ─── Filter verdict ──────────────────────────────────────────────────────────
//
// Returns 1 (emit) or 0 (drop) for the given pathname.
//
// Decision matrix:
//   mode          matched   verdict
//   ──────────    ───────   ───────
//   DISABLED       -        emit
//   APPROVER       yes      emit
//   APPROVER       no       drop   ← events dropped in kernel, never see userspace
//   DISCARDER      yes      drop
//   DISCARDER      no       emit
//
static __always_inline int openat_filter_verdict(const char *pathname)
{
    // 1. Read the current filter mode.
    __u32 k = 0;
    __u32 *modep = bpf_map_lookup_elem(&filter_openat_mode, &k);
    if (!modep || *modep == FILTER_MODE_DISABLED)
        return 1;  // filtering disabled → always emit
    __u32 mode = *modep;
 
    // ── Phase 1: Exact-match lookup  (O(1)) ───────────────────────────────────
    __u8 *exact = bpf_map_lookup_elem(&filter_exact_paths, pathname);
    if (exact)
        goto matched;
 
    // ── Phase 2: LPM prefix lookup  (O(log n)) ────────────────────────────────
    // Build the trie key: prefixlen = bit-length of the pathname.
    struct lpm_trie_key trie_key = {};
    __u32 path_len = 0;
 
    #pragma unroll
    for (int i = 0; i < MAX_PATH_LEN; i++) {
        if (pathname[i] == '\0')
            break;
        path_len++;
    }
    trie_key.prefixlen = path_len * 8;
    bpf_probe_read_kernel(trie_key.data, sizeof(trie_key.data), pathname);
 
    __u8 *prefix = bpf_map_lookup_elem(&filter_prefix_paths, &trie_key);
    if (prefix)
        goto matched;
 
    // ── Phase 3: Wildcard pattern scan  (O(k * m)) ────────────────────────────
    // k = number of wildcard patterns, m = average pattern length.
    // Patterns are sorted by userspace (most specific first) for early exit.
    #pragma unroll
    for (__u32 i = 0; i < MAX_WILDCARD_FILTERS; i++) {
        struct wildcard_entry *entry =
            bpf_map_lookup_elem(&filter_wildcard_patterns, &i);
        if (!entry || !entry->active)
            continue;
        if (glob_match(entry->pattern, pathname))
            goto matched;
    }
 
    // ── No match ──────────────────────────────────────────────────────────────
    return (mode == FILTER_MODE_DISCARDER) ? 1 : 0;
 
matched:
    return (mode == FILTER_MODE_APPROVER) ? 1 : 0;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in ff24bc6. Agreed that file events will dominate the stream, and matches is the operator that makes the prefilter useful for them.

One important change versus the draft: I had to keep plain glob semantics rather than the path-aware ones. matches evaluates through wildcard.Match(pattern, path, true), and matchCaseSensitive in pkg/util/wildcard/wildcard.go treats * as "zero or more of any byte" with no special handling for /, and has no ** form at all, so ** there is just two consecutive stars and behaves like one. If the kernel stopped * at a separator, then a rule like file.path matches '/tmp/*/evil' would match /tmp/a/b/evil in userspace but get dropped in the kernel, which is exactly the silent false negative this PR is trying to make impossible. So glob_match is a byte-for-byte port of the userspace matcher instead, and TestGlobStarSpansPathSeparators pins that case.

Two situations hand the decision back to userspace rather than answer it wrongly, both returning "keep":

  • ? against a byte >= 0x80. Userspace decodes a rune and advances by its width, while the kernel walks bytes, so the two disagree on multi-byte input. Note that * is byte-wise even in the userspace UTF-8 path (matchIdx++), so only ? is affected.
  • An exhausted step budget. The algorithm backtracks in O(n*m), so the loop is capped at GLOB_MAX_STEPS to stay verifiable. /tmp/* against a long path costs roughly one step per byte, so the cap is generous in practice.

I left discarders out, since the draft's mode matrix includes them and they need their own soundness argument. Everything here is approver-only.

Two limits are enforced in userspace rather than silently truncated, because dropping a pattern would narrow the policy: a type needing more than APPR_GLOBS patterns, or any pattern that does not fit the 256-byte value, is not prefiltered at all and falls back to default-allow. imatches is also still unsupported, since folding in the kernel would have to agree with unicode.ToLower on every rune userspace folds.

On verifier cost: patterns are scanned only after the exact-match and LPM lookups miss, and clang keeps the matcher as a real loop, so sys_exit_openat sits at about 1450 instructions. I could not load the programs locally to confirm, since the objects are built for x86 and my machine is arm64, so the privileged CI job is the real check here. If the verifier objects, GLOB_MAX_STEPS and APPR_GLOBS are the two dials.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Correction to my previous reply: the in-kernel matcher does not survive the verifier, so I replaced it in ec9013d with a rewrite that does. CI caught it, and I then reproduced it locally by building the objects for arm64 and loading them in a privileged container, which gave a much faster loop than pushing to CI.

What the verifier said, in two stages. At eight patterns it hit the jump-sequence ceiling:

The sequence of 8193 jumps is too complex.
processed 68711 insns (limit 1000000) total_states 786

Reducing the bounds does not fix it, it only changes which limit you hit. At two patterns over 64 steps:

BPF program is too large. Processed 1000001 insn
processed 1000001 insns (limit 1000000) max_states_per_insn 24 total_states 29668

That is 128 loop iterations exhausting the entire million-instruction budget. The cause is backtracking itself: every byte comparison is a data-dependent branch, and with pi, si, star_pi and match_si all live and varying per iteration the verifier cannot prune equivalent states, so it walks an exponential path tree. This is not a tuning problem, and I do not think any faithful port of matchCaseSensitive is verifiable. Directory-aware globbing would verify far more cheaply, which is likely why it looked attractive, but only because it prunes the paths that make it wrong.

So the kernel no longer matches globs at all. Userspace rewrites the patterns that have an exact kernel equivalent and leaves the rest alone:

Pattern Rewritten to
/etc/passwd exact-match hash
/tmp/*, /tmp/** LPM prefix /tmp/
* nothing, it constrains nothing
/tmp/*/evil, *.so, /tmp/?vil not rewritten, type stays default-allow

The trailing-star rewrite is exact rather than conservative, because a trailing * spans every remaining byte including /, so matches '/tmp/*' and startswith '/tmp/' accept precisely the same set. TestTrailingStarRewriteMatchesOperator asserts that against wildcard.Match directly.

This still covers the case you raised. /tmp/*, /var/tmp/* and /dev/shm/* are exactly the trailing-star shape, so file events under a rule like that now get filtered in the kernel. Mid-pattern stars and suffix patterns such as *.so fall back to userspace. If those turn out to matter in practice, a suffix map is probably the next tractable step, since fixed-length comparison from a known offset does not backtrack, and I would rather add that with its own soundness argument than approximate it now.

Side benefit: dropping the matcher took sys_exit_openat from about 1450 instructions back down to 1305, and the whole approver_file_glob map is gone.

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 from ff24bc6 to ec9013d Compare September 22, 2026 08:48
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