linprov 0.2.0

eBPF mark-of-the-web for Linux: tag network-touched files and enforce who can exec them.
linprov-0.2.0 is not a library.

linprov

eBPF-based mark-of-the-web for Linux. Every file written by a process that touched the network gets tagged with a provenance xattr; every execve of a tagged file is logged, and — optionally — blocked unless the path is on an explicit allowlist.

How it works

Three sleepable BPF LSM hooks plus one cleanup tracepoint:

Hook What it does
socket_connect When a PID connect()s to a non-loopback AF_INET/AF_INET6 address, mark the PID as network-touched in an LRU hash map. Loopback connects (127.0.0.0/8, ::1) are skipped by default — pass --mark-localhost or LINPROV_MARK_LOCALHOST=1 to include them (e.g. for the smoke tests, which use a local HTTP server).
file_open If the opener is network-touched and the file is opened for write, write the OriginRecord into a BPF_MAP_TYPE_INODE_STORAGE map keyed on the file's inode, and emit a ringbuf event with the path.
bprm_check_security On every exec, look the inode up in INODE_MARKS first; if absent, fall back to the bpf_get_file_xattr kfunc. If either source has the mark, emit a ringbuf event — and in enforce mode, return -EPERM for paths not on the allowlist.
sched_process_exit (tracepoint) Reap the network-touched PID entry on task teardown.

Userspace consumes the ringbuf, applies the security.bpf.linprov.origin xattr (the kernel restricts bpf_set_dentry_xattr to LSM hooks that natively take a trusted dentry, which file_open isn't), and — in enforce mode — seeds an in-kernel hash map of permitted paths.

The two mark sources play different roles:

  • INODE_MARKS is the same-boot fast path. Synchronous in file_open, so by the time the very next execve runs, the mark is already visible to bprm_check_security. Closes the race window where a freshly downloaded binary could exec before userspace landed the xattr.
  • The xattr is the durability layer. Survives daemon restart, reboots, and inode cache eviction. Written off-band by userspace; read in-kernel as fallback.

Either source produces the same OriginRecord — enforcement / logging doesn't care which fired.

Requirements

  • Linux 6.5+ kernel with BPF LSM enabled (CONFIG_BPF_LSM=y, bpf in the active lsm= boot parameter). Confirm with:
    cat /sys/kernel/security/lsm  # must contain `bpf`
    
    On Pop!_OS / Ubuntu with systemd-boot:
    sudo kernelstub -a "lsm=$(cat /sys/kernel/security/lsm),bpf"
    # then reboot
    
  • vmlinux BTF (/sys/kernel/btf/vmlinux) — needed for LSM hook resolution.
  • Rust nightly (pinned via rust-toolchain.toml).
  • The userspace daemon runs as root (BPF program load + LSM attach + security.bpf.* xattr writes all need it).

Install

cargo install linprov

Needs bpf-linker available on the host (cargo install bpf-linker). Uses an aya fork published as aya-friday-* on crates.io — pulled in automatically as a regular dependency.

Build from source

cargo build --release

Tests

# Unit tests + doctests (no kernel needed):
cargo test --workspace

# Smoke suite (needs root + BPF LSM kernel; see tests/README.md):
cargo build
sudo ./tests/smoke/run-all.sh

Run

linprov is structured as three subcommands.

linprov setup

First-time install. Feature-checks the kernel (≥ 6.5, bpf in active lsm=, vmlinux BTF), writes a default config + empty allowlist under /etc/linprov/, and drops a systemd unit. After:

sudo linprov setup
sudo systemctl daemon-reload
sudo systemctl enable --now linprov.service
journalctl -u linprov.service -f

linprov run

The daemon itself. Reads /etc/linprov/config.toml by default; CLI flags + env vars override. The systemd unit calls linprov run --config /etc/linprov/config.toml. Three modes:

  • observe (default): mark files, log marked execs, never block.
  • soak: same as observe plus appending one allowlist rule per PROVENANCE-EXEC. --soak creator_process,creator_uid,… (also settable as soak = [...] in the config) controls which dims each emitted rule AND-joins.
  • enforce: block any marked execve whose origin doesn't match a rule. -EPERM from security_bprm_check; the shell sees Operation not permitted and $? is 126.

Logs land in the file set by log_file = ... in the config (default /var/log/linprov.log once setup has run), or stderr if no log_file is configured.

linprov upgrade

After cargo install --force linprov lays down a new binary, run:

sudo linprov upgrade

That does systemctl daemon-reload + systemctl restart linprov.service and warns if the unit's ExecStart doesn't match the binary you just installed (re-run linprov setup --force --binary <path> if so).

Sample log lines for observe / enforce:

PROVENANCE-EXEC target=/usr/local/bin/foo landing=/tmp/foo pid=12345 \
  comm=zsh origin={v:3,…,comm:curl,path:/usr/bin/curl}
BLOCKED-EXEC target=/tmp/sketchy landing=/tmp/sketchy pid=12346 comm=zsh \
  origin={v:3,…,comm:curl,path:/usr/bin/curl} (LSM verdict -1)

Allowlist format

One rule per line. # starts a comment; blank lines are ignored. Each line is one rule whose <dim>=<value>;<dim>=<value> conditions AND together. Multiple lines OR: a marked execve is permitted if any single rule's conditions all match.

# uid 1000 downloading with curl is fine, anywhere
creator_uid=1000;creator_comm=curl

# uid 1000 may exec firefox-dropped binaries that ended up in ~/.local/bin
execution_uid=1000;creator_comm=firefox;target_folder=/home/user/.local/bin
dim example matches if …
target_filename /usr/bin/foo the executed binary's path equals this
target_folder /opt/installed/ the executed binary lives under this prefix (any depth)
landing_filename /tmp/foo the file's download path (where it was first written) equals this
landing_folder /tmp/ the file's download directory matches at any depth
creator_process /usr/bin/curl the full exe path of the writer matches
creator_comm curl the 16-byte comm of the writer matches
creator_uid 1000 the writer's UID matches
execution_uid 0 the UID running the execve matches

target_* reflects the file's location at execve time; landing_* is where it was first written. They diverge when the file is moved between download and execve — e.g. curl -o /tmp/foo http://…; mv /tmp/foo ~/.local/bin/foo; ~/.local/bin/foo has landing_filename=/tmp/foo and target_filename=~/.local/bin/foo.

Folder rules must end in / (userspace normalizes). All path-shaped values are bounded to 64 bytes by the BPF FNV walk; longer rules are rejected at parse time.

Up to 32 rules per allowlist (BPF verifier budget — bump MAX_RULES and rebuild for more).

creator_process is populated by userspace via readlink /proc/$pid/exe when handling the file-open event. If the creator process exits before userspace lands the augmented xattr, rules requiring creator_process won't match for that file — use creator_comm (always populated by BPF) as the fallback dim.

Inspecting the xattr by hand

getfattr -d -m '.*' /path/to/file
# security.bpf.linprov.origin=0sAgAAAA...

The value is the binary OriginRecord (v3 layout):

version u32 | pid u32 | ts_boot_ns u64 | comm[16] |
creator_uid u32 | _pad u32 | creator_path[256] | landing_filename[256]

The daemon's log lines already format it. Earlier-version xattrs from prior linprov builds are ignored (treated as unmarked).

Roadmap

See ROADMAP.md.

Repository layout

linprov/         userspace daemon (clap, tokio, aya)
linprov-ebpf/    BPF programs (no_std, aya-ebpf, inline asm for kfuncs)
linprov-common/  types shared between the two
tests/smoke/     end-to-end tests against a real kernel
.github/         CI workflows

License

Userspace crates: dual-licensed under MIT or Apache-2.0 at your option. See LICENSE-MIT and LICENSE-APACHE at the repo root.

The BPF program (linprov-ebpf) declares Dual MIT/GPL in its license ELF section so the kernel verifier accepts it as GPL-compatible — required for the bpf_d_path and bpf_get_file_xattr helpers, which are gpl_only. The source itself is the same MIT-OR-Apache-2.0 as the rest of the workspace; the GPL token is a license-compatibility statement to the kernel, not a relicense.