Conversation
fdd182a to
2184b50
Compare
| return 1; | ||
| } | ||
|
|
||
| static __always_inline int approver_file_match(u32 gen, u32 type) |
There was a problem hiding this comment.
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;
}
There was a problem hiding this comment.
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_STEPSto 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.
There was a problem hiding this comment.
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.
ff24bc6 to
ec9013d
Compare
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.ApproverProvideris an optional capability onfilter.Filter. The rule compiler emits a platform-neutralfilter.ApproverPlan, bootstrap hands it to the source, and the CLI expression contributes its own plan throughSetFilter.RulesCompileResultstays a usage summary and is not read as predicate data.Supported shapes are positive
=,in,startswith, andmatchesover:ps.pid,evt.pidbpf_get_current_pid_tgid() >> 32against a hashfile.pathnet.dportsockaddrmatchesis 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?, andimatchesleave 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 noevt.nameorevt.categorypredicate 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, andcloneare never prefiltered. The process snapshotter consumes them to build identity, so dropping one would desynchronize process state from live tasks.ps.nameis deliberately not extracted. The only process name the kernel can read cheaply is the live taskcomm, whileps.nameis snapshot state captured atexecve. The two diverge underprctl(PR_SET_NAME)and whenever the snapshot falls back to an executable basename longer thancomm's 16 bytes, so approving oncommwould 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.
SetFiltercan run beforeOpen, 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.Engineindexes them by, so the approver and the engine cannot disagree about which types reach a rule. A destination port is only enforced forconnect, because that is the only event the kernel parses asockaddrfor; a port requirement on any other type keeps the rest of its policy instead of comparing against zero.ebpf.approver.dropsreports how many events the kernel rejected, alongside the existingebpf.ringbuf.drops.Tests
ps.namecannot narrow a policy.FuzzApproverNoFalseNegativesasserts that any event the userspace filter matches is also kept by the prefilter, across 15 expressions and 6 event types.TestApproverDecisionsMatchLoadedProgramsdrives realopenatcalls through the loaded programs and asserts the approved path arrives, the unapproved one does not, and the reject counter advances. The GoAllowsmodel and the BPF predicate are separate implementations, so this is what makes the property meaningful.TestEngineIndexesSequenceByStringFieldTypespins the indexing assumption the sequence scoping relies on.TestTrailingStarRewriteMatchesOperatorchecks the rewrite againstwildcard.Matchdirectly, andTestNonPrefixGlobsAreDefaultAllowpins that everything else widens instead of being approximated.TestModeArrayMatchesGeneratedMapchecks the Go mode-array stride against the generated map spec so it cannot drift fromEVT_TYPE_MAX.BenchmarkApproverReductionmeasures the drop ratio; a mixedopenatpath set yields 0.60.Objects were regenerated with Ubuntu 24.04 clang 18.
make test, the generation drift check,rules validate, and aGOOS=windowsbuild of the touched packages all pass.