Skip to content

Latest commit

 

History

History
278 lines (228 loc) · 15.6 KB

File metadata and controls

278 lines (228 loc) · 15.6 KB

Limitations

This project is a proof of concept. Its single claim is:

A tamper-evident audit trail for Linux process execution events. If a stored event is modified, deleted, or reordered and the chain is not rebuilt, the verifier detects that the cryptographic chain is no longer valid.

Everything below is what that claim does not cover. An honest limitations list is part of the deliverable, not an appendix to it.

Trust and the local host

1. Root can stop the agent. The agent is an ordinary process. Root can kill it, unload the eBPF program, block its network path, or never start it. Nothing here prevents that, and the chain says nothing about executions that were never captured.

What survives is the past, with one qualifier that has to travel with the claim: events already delivered to the collector cannot be altered undetectably by anything short of rewriting the whole chain, and catching that needs a checkpoint recorded beforehand (limitation 5). Root on the collector host can rewrite the log; what root cannot do is make a rewritten log match a checkpoint somebody else already wrote down.

2. Kernel or root compromise defeats future capture. An attacker with kernel privileges can detach the tracepoint or feed it false data. eBPF observes the kernel; it does not defend it.

3. The monitored host is not assumed trustworthy — and that is why the collector re-validates. The agent computes the chain, and the collector recomputes it before accepting anything. A compromised agent can therefore stop producing events or produce false ones going forward, but it cannot rewrite what the collector already stored, because that would require the collector to accept a sequence number and prev_hash it has already moved past.

4. There is no agent identity. Any process that can reach the collector can submit events for any hostname on the allowlist, as long as they form a valid chain. Nothing authenticates the sender; bind the collector to loopback or a trusted network. Agent identity and signed events are not in this version.

allowed_hosts and max_hosts bound what an unauthenticated peer can create — host identities, each costing a file and an inode — but they do nothing about what it can submit for a host that is allowed. With no allowlist configured, any reachable peer can still create hosts up to max_hosts.

Storage

5. An unkeyed hash chain cannot detect a wholesale rewrite. This is the most important limitation in this document, and it is inherent to the design rather than an implementation gap. Every digest is a plain, public SHA-256. Anyone who can rewrite the log can edit an event, recompute that event's hash and every prev_hash/hash after it, keep the sequence numbers consecutive, and produce a file that audit-verify reports as VERIFIED. Nothing inside the file can distinguish that from the truth, because everything inside the file is exactly what the attacker just wrote.

What the chain does guarantee, unconditionally, is that a partial edit is caught: changing an event without rebuilding the rest, deleting one, reordering two, or cutting the head off the file all break checks that cannot be repaired without rewriting everything after them.

Closing the rest requires a value the writer of the log does not control. audit-verify -expect-hash <hash> -expect-events <n> compares the log against the checkpoint a previous verification recorded; store it somewhere else — a ticket, a CI artifact, another host — and a rewrite stops being invisible. Both halves are needed: a hash alone only fits a log that never grows, and a count alone attests nothing and is refused. The report says Trusted anchor: NOT CHECKED when you have not done this, precisely so a passing result is not mistaken for more than it is. Ed25519-signed checkpoints would make the anchor self-describing, and immutable remote storage would remove the writer's ability to rewrite at all; neither is in this version.

6. The collector's storage is not WORM. data/<hostname>.audit.jsonl is opened append-only with mode 0600, but any process that can write to the directory — root included — can rewrite the file with a text editor. This provides detection, not prevention, and detection of a full rewrite is subject to limitation 5 above.

7. Tail truncation is not caught by the chain alone. Deleting the last N events leaves a shorter chain that is internally consistent. Modification, deletion in the middle, reordering and head truncation are all caught by the chain; dropping the tail is caught only by a checkpoint, through the event count it recorded.

8. A checkpoint attests only up to its own position. Events appended after it are chained to the anchored history but are not themselves anchored: someone who can write to the log can still append plausible events after the last checkpoint, and only a newer checkpoint will bound that. Record one as often as the log matters — every successful audit-verify run prints one.

9. The collector holds state in memory. It rebuilds per-host state from the last line of each log on startup, so a restart is safe. But if the log file is tampered with while the collector is stopped, it resumes from whatever the file now says — and only an anchored audit-verify run will notice.

10. An indeterminate write is reconciled, not prevented. If an append fails after possibly reaching the file, the collector marks the host for reconciliation and rebuilds its position from disk before accepting anything else — syncing the file and the directory first, so an acknowledgement after recovery means what it means at any other time. Restart takes the same path. Combined with tolerating a re-delivered tail, that makes a retried event land exactly once.

What none of it survives is a filesystem or device that loses data it has already reported as fsynced, and the guarantee has not been tested against real power loss — only against the code paths that lead there.

11. A damaged log quarantines its host, and only that host. If a host's log tail cannot be trusted — a torn trailing write, a missing final newline, a record that is not in canonical form, or one that fails its own hash — that host's events are refused with 503 until the file is repaired or archived and the collector is restarted. Every other host keeps being collected.

Quarantine is sticky on purpose, and it is not self-healing: the host is not being audited for as long as it lasts, and nothing but the collector's log and /healthz will say so. Alert on /healthz reporting degraded.

Transport

12. /healthz names the hosts it has quarantined. The endpoint is unauthenticated like everything else here, and reports the number of hosts being collected plus, when degraded, which ones are out of service. On a collector reachable beyond loopback that discloses part of the monitored inventory. The signal is deliberately useful to an operator; if that trade is wrong for a deployment, put the endpoint behind the same boundary as ingest.

13. HTTP is not confidential and not authenticated. Events travel as plain JSON over plain HTTP. Anyone on the path can read the events — including command lines — and inject their own. Hash chaining makes injected events detectable only if they break the chain; an attacker who can see the traffic can also construct events that extend it. Run the collector on loopback or a trusted segment; there is no transport security here.

Delivery and state

14. Events are lost when the collector is unreachable, and the loss is invisible in the log. There is no local spool. The agent retries transient failures with exponential backoff (send_attempts, send_backoff), but an event it cannot deliver is discarded. The chain then rewinds, so the next event takes the sequence number the dropped one had: the stored log stays a valid, gapless chain and contains no evidence that anything went missing. The only record of the loss is the agent's own output (dropped= in the shutdown summary), which lives on the monitored host and is therefore not trustworthy evidence either.

This is a deliberate trade. The alternative — advancing the chain past an undelivered event — leaves the agent permanently out of step with the collector, because the collector accepts exactly one sequence number at a time; a single lost packet would end collection for that host until an operator intervened. Losing one event beats losing all subsequent ones. Durable spooling, which avoids both, is not in this version.

15. An event the collector committed but could not acknowledge stops the agent. The rewind above is correct only when the collector did not store the event. If it did and the agent never learned so — the response was lost, the collector restarted between the fsync and the 202, the outage outlasted the retry budget — the agent is behind the collector and every later event is rejected as a sequence gap.

It cannot resynchronize automatically: the agent would have to be told the collector's exact chain position, and with no agent authentication that is an oracle anyone who can reach the collector could use to force this state on purpose. So the agent gives up after three consecutive chain disagreements and exits non-zero. The host stops being audited either way; the exit status is what makes that visible to a supervisor instead of silent. Run the agent under something that restarts on failure and alerts on it. Automatic resynchronization needs agent authentication, which this version does not have.

16. The agent's sequence resets on restart. Sequence state lives in memory. A restarted agent starts a new chain at sequence 1 with the zero prev_hash. The collector refuses to splice that onto an existing log and answers 409, naming the file to archive; the operator rotates it and the new run starts a new file. Sequence state is not persisted.

17. The kernel can drop events under load. If user space cannot drain the ring buffer fast enough, the eBPF program increments a drop counter and discards the execution. The agent reports the total on shutdown. Those executions are absent from the trail; the chain over what was captured remains valid.

Coverage

18. Only execve is audited. No file access, no network connections, no chmod/chown, no privilege transitions, no execveat. A verified log proves nothing about anything other than the process executions it contains.

19. argv is truncated at 1 KiB and the executable path at 256 bytes, and the record does not say so. Longer command lines are cut short. A path that does not fit loses its tail — exactly the part the command name comes from — so the name falls back to the kernel's comm, which is itself capped at 15 characters. Both truncations are counted and logged by the agent, but neither is a field of the audit event: the schema is fixed by the hash, so the flags live in the agent's own log only.

A truncated record contains only whole arguments — the partial one at the cut is dropped rather than stored as if it were complete — so the record is never a description of a command line that did not run. But it is a description of less than what ran, and nothing in the record marks it as abridged. Any process controls its own argv, so any process can deliberately push a payload past the 1 KiB boundary and have it absent from the trail. Carrying the truncation flag in the record is the real fix and needs a format-version bump; it sits on the roadmap next to signed checkpoints. Until then, treat an execution with ~1 KiB of arguments as a candidate for scrutiny.

20. Very short-lived processes may be missed. The probe reads argv out of the process's memory at exec time. If those pages are not resident, the read fails and the event is recorded with an empty argument list rather than being dropped.

Data handling

21. Command arguments frequently contain secrets. Tokens passed as flags, connection strings, and passwords on the command line all end up in the audit log verbatim, and the log is hashed, shipped and stored as-is. Treat data/*.audit.jsonl as sensitive. There is no redaction — redaction would also have to happen before hashing to remain verifiable.

22. Arguments are stored in an escaped text form, not as raw bytes. Linux arguments are byte strings and need not be UTF-8, but the audit event is JSON. Non-UTF-8 bytes are therefore encoded as \xNN and backslashes are doubled before hashing, which is lossless and reversible (event.DecodeText) but means the stored text is not byte-identical to what the kernel saw. Consumers that compare arguments literally must decode first.

23. Timestamps come from the monitored host and are not authoritative. The wall-clock timestamp in each event is whatever the agent's clock said. A host with a wrong or deliberately manipulated clock produces events with wrong timestamps, and the chain will happily verify. Sequence numbers, not timestamps, establish order. Trusted timestamping is out of scope. (The field must at least be an RFC3339 timestamp — that is validated — but its value is only as trustworthy as the host that produced it.)

24. Hostnames are self-reported. The hostname in an event is what the agent claims. It is sanitized before being used as a file name, and it is covered by the hash, so a stored record's hostname cannot be altered on its own — but nothing proves the claim was true when it was made. What is checked: every record in a log must name the same host, and a file called <host>.audit.jsonl must contain that host's records, so relabelling a log by renaming it is detected without needing a checkpoint. Sanitization accepts both letter cases, which is correct on Linux, where Host and host are two files and two independent chains. On a case-insensitive filesystem they would share one file and produce a log that can never verify; Linux is the only supported platform for the collector.

Toolchain

25. The Go standard library ships inside these binaries. The toolchain is a runtime dependency, not just build tooling. go.mod requires Go 1.26.6, because earlier releases carry reachable net/http vulnerabilities — including one that allows a cleartext HTTP/2 request to bypass the collector's header timeout. make vulncheck is clean on 1.26.6 and should stay part of the release check: this floor rises whenever the standard library does.

The same applies to the eBPF library, whose BTF parser runs on data the kernel hands it. Treat a govulncheck finding there as a release blocker rather than a dependency-hygiene chore.

On a builder without network access, a go directive above the installed toolchain fails rather than downloading one. Ship the required toolchain to such builders, or set GOTOOLCHAIN=local and install Go 1.26.6+ directly.

Storage safety

26. Filesystem protections assume the collector owns its data directory. Host logs are opened through a descriptor for the data directory, so a path cannot resolve outside it; symlinked, hard-linked and non-regular logs are refused; and modes looser than 0600 — including on the directory itself — are tightened at startup or on first use. What remains is a narrow race: a local user who can write to the data directory between the symlink check and the open could still aim one host's log at another path inside that directory. The directory tightening is what closes the door on that; do not run the collector with a data directory other users can write to.

Scope

27. This is not a production audit system. It is a reference implementation of one idea, kept small on purpose. It should not be the sole audit mechanism in a regulated environment. See SECURITY.md.