Repository navigation
feat(ebpf): Add sound in-kernel approvers - #736
Conversation
fdd182a to
2184b50
Compare
ff24bc6 to
ec9013d
Compare
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.
bc88776 to
b68dd51
Compare
|
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 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 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.
b68dd51 to
edc5c12
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 21 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. 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.TestEngineIndexesSequenceByStringFieldTypespins the indexing assumption the sequence scoping relies on, andTestExtractFilePathListpins that a list of literal paths resolves entirely in the exact-match hash.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.