repx is a Linux/eBPF prototype for reproducible build-process attestations with
DAG provenance. It traces a selected set of process and file events, captures file
identities while events are arriving, canonicalizes the observations into a
deterministic set of distinct operations, and commits that set with a Merkle root.
In output-rooted mode it additionally builds a content-addressed dataflow DAG
(artifact/process nodes with read/write/exec edges) and includes the DAG root
in the attestation.
The attestation answers a narrow question: did this command reproduce the same set of covered operations and selected outputs as a trusted baseline? It is not a complete record of every kernel action.
nix develop -c ./build.shThe resulting binary is target/release/repx. Loading the eBPF programs requires
root or equivalent BPF capabilities.
Trace a build and produce an attestation:
sudo ./target/release/repx trace \
--output-root dist \
-o repx-attestation.json \
-- make releaseVerify that a re-run produces the identical attestation root:
sudo ./target/release/repx verify \
--output-root dist \
-a repx-attestation.json \
-- make releaseVerification requires the exact command and explicit output-selection flags used during tracing. This prevents command and artifact policy from being silently inherited from an untrusted JSON file.
When a trusted root is distributed separately, pin it with
--expected-root sha256:... to detect replacement of the attestation file.
Use --artifact PATH for individual outputs, --dump-ops PATH to inspect
canonical operations, explain for a summary, and diff for added/missing
operation hashes and output changes.
Generate a signing key pair:
repx generate-key --private-key my.key --public-key my.pubSign an attestation into a DSSE envelope:
repx sign --key my.key --attestation repx-attestation.json --output signed-envelope.jsonVerify a signed envelope:
repx verify-signature --key my.pub signed-envelope.jsonUse repx stability --strict run-*.json to measure attestation-root stability,
process-root stability, and leaf-set Jaccard similarity across repeated traces.
The --strict flag exits unsuccessfully unless every attestation root matches.
The privileged nix run .#test-determinism harness applies this analysis to the
project's integration workload matrix, running each workload under repx trace
20 times (configurable via DETERMINISM_RUNS) and reporting root stability and
leaf-set Jaccard similarity.
sudo nix run .#test-determinism
sudo nix run .#test-determinism -- smoke # single workload
sudo DETERMINISM_RUNS=50 nix run .#test-determinismnix run .#soak repeats targeted workloads many times and compares attestation
roots. Attestations are written outside the traced workspace so trace artifacts
do not perturb the snapshot. On mismatch, artifacts (--dump-ops, --index,
repx diff, debug stderr) are saved to a timestamped directory.
sudo nix run .#soak -- forks 50
sudo nix run .#soak -- fd-churn 50
sudo nix run .#soak -- all 50Workloads: forks, fd-churn, rename-storm, unlink-after-write, cbuild,
watch. See docs/threat-model.md for the soundness boundary.
- File reads, tool binaries, mmap inputs, and close-time outputs are hashed when their events reach userspace. Only regular files are opened, using nonblocking handles; retained handles allow deleted temporary files to remain hashable.
O_RDWRcontributes both a read and a write. Writable private mappings are reads; only writableMAP_SHAREDmappings are classified as file writes.- Process attribution uses a PID-reuse-safe lifetime plus an exec epoch. Forked children inherit the active tool identity and open-descriptor state; a later exec creates a distinct process instance.
- Relative paths use a per-process working-directory cache inherited across fork,
allowing short-lived processes to retain stable final-path attribution after
their
/procentries disappear. Unobservedchdirchanges remain best-effort. - Go
importcfgcontrol files are parsed and their randomgo-build<digits>work-root prefixes are canonicalized. Dependency names, work-relative layout, stable paths, and all other control-file data remain committed. - Canonicalization produces a sorted set of distinct covered operations. Counts, ordering, timestamps, and process indices are diagnostic data, not part of the process-set root.
- Output-rooted attestations commit selected outputs and content-resolved dependencies. Unavailable transient intermediates remain visible in full-trace mode but are omitted from the output dependency walk. Missing, unreadable, and non-regular identities bind stable paths while normalizing recognized session and compiler temporary names.
- The top-level root binds the process-set root, command, output selection, output hashes, and (in v0.4.0) the DAG root.
- Event loss fails closed by default.
trace --allow-dropped-eventsis an explicit escape hatch for incomplete diagnostic attestations. - io_uring setup/enter syscalls set
io_uring_observedin trace metadata. Per-op io_uring attribution is not implemented; usetrace --strict-io-uringto refuse such traces. Full threat model: docs/threat-model.md. - The eBPF ring uses compact fixed-size events and a 32 MiB capacity to absorb bursty build-system workloads such as Bazel.
- Attestations can be signed with Ed25519 via DSSE envelopes (
repx sign/repx verify-signature). Unsigned attestations must be distributed through a trusted channel or checked with--expected-root.
v0.4.0 output-rooted attestations include a dag_root: a Merkle commitment to
a content-addressed dataflow DAG extracted from the canonical operation set.
This transforms repx from a reproducibility checker into a provenance system
that can answer "what inputs and tools produced this output?" without re-running
the build.
Nodes:
Artifact { content_hash }-- a file identified by its content hashProcess { process_index, tool_hash }-- a process instance identified by its executable binary hash and its logical index in the trace
Edges:
Read { artifact_hash, process_index }-- process read this artifactWrite { artifact_hash, process_index }-- process wrote this artifactExec { artifact_hash, process_index }-- process was exec'd from this binary
The DAG root is computed via a deterministic length-prefixed binary encoding
(SHA-256) that is independent of serialization library, field ordering, or JSON
representation. The dag_root is bound into the top-level attestation root
alongside the process-set root, command, and output selection.
A DAG smoke test is available to verify the provenance root is present and deterministic:
sudo nix run .#dag-smokeThe eBPF program discovers tracepoint field offsets at load time by parsing the
kernel's format files under /sys/kernel/debug/tracing/events/*/format. Raw
tracepoint formats are not covered by BTF/CO-RE, and field offsets vary across
architectures and kernel configurations.
When debugfs is unavailable, set REPX_FALLBACK_OFFSETS=1 to use built-in
x86-64 offsets (x86-64 only):
REPX_FALLBACK_OFFSETS=1 sudo ./target/release/repx trace ...Fallback offsets are not portable across architectures or kernel configurations. CI environments should mount debugfs instead of relying on fallback offsets.
| Behavior | Status |
|---|---|
openat, openat2, open, close, file-backed mmap |
Covered |
rename, renameat (old/new path tracking) |
Covered (distinct operation types) |
unlink, unlinkat |
Covered (distinct operation type) |
exec, fork, exit |
Covered (process tracking with PID-reuse safety and exec epoch) |
| Temp-file replacement | Final rename paths are attributed; rename and deletion are distinct operations |
dup*, inherited descriptors |
Fork-inherited descriptors are tracked; dup* remains incomplete |
sendfile, copy_file_range, io_uring |
Not covered |
| Network I/O and remote inputs | Not covered |
External relative-path reads in --watch mode |
May be missed by kernel prefix filtering |
External writes in --watch mode |
Resolved and prefix-checked in userspace before file observation |
| Paths longer than 4096 bytes | Truncated paths are flagged and fail-closed unless --allow-dropped-events |
--watch mode on busy hosts |
System-wide relative-path writes pass through the ring buffer for userspace filtering; expect higher overhead than fork-tree tracing |
An attacker who controls the build can deliberately choose an uncovered path. Treat the current prototype as a deterministic commitment over observed covered behavior, not proof that no malicious activity occurred.
Attestations embed content hashes for regular files. Missing, unreadable, or
non-regular files are identified by hashes of their absolute paths (or
workspace:<relative> paths when a workspace root is known), so verification
is reliable on the same machine but may differ across checkout locations.
cargo test --workspacePrivileged integration scenarios are documented in tests/README-testing.md.
Additional test harnesses (require root):
sudo nix run .#dag-smoke # DAG provenance root presence and determinism
sudo nix run .#test-determinism # Repeated-trace stability across workloadsMIT. See LICENSE.