Skip to content

Baseline review & drift

When a Genuine cycle finds a file whose hash isn't in the approved baseline, it does not decide anything — it files the change as pending review. Drift is proposed, never auto-accepted. This chapter covers the reviewer's job: reading a pending entry, approving or rejecting it, annotating it, and suppressing genuinely-noisy paths with exclusions. Every one of these lands as an Ed25519-signed, audited action.

There is no auto-approve path in Etminan, and there never was one — the whole design rests on a human making each trust decision explicitly.

Every baseline decision is a daemon action

Approve, reject and exclude all go the same way: etminan-verifier op <cmd> hands the action to etminan-verifierd, which authenticates you by peer-UID (SO_PEERCRED), checks your role and host scope, holds the only signing key and signs the record on your behalf. There is no key file and no root fallback. See RBAC.

The review queue

# Every host with pending items:
etminan-verifier baseline review

# One host:
etminan-verifier baseline review --host web-01

Every pending entry is exactly one of two kinds, and the entry tells you which:

  • (new file, no prior baseline) — this path has never been part of an approved baseline on this host. A genuinely new file.
  • (was <old-sha256>) — a previously-approved path whose content hash no longer matches what was last approved. An existing, trusted file changed.

There is no third bucket: a file whose hash matches what's already approved never becomes a pending item — it's silently counted as unchanged. Entries older than the review SLA (ETMINAN_PENDING_REVIEW_SLA_HOURS, default 24h) are marked [SLA EXCEEDED] and, via run, escalated to a warning-severity finding — pending review is never a silent, indefinite state.

Reading an entry

[42] host=web-03 path=/usr/local/bin/agentd
     ⚠ An existing trusted file changed and is NOT owned by any installed
     package — this pattern is a common sign of tampering. Investigate
     before approving.
     No approved change ticket found near this host for this time window
     across any configured system — verify this wasn't unplanned.
     detail: sha256=3dcbe799... (was a1b2c3...) owner=uid:0/gid:0 mode=0755
     size=48213 mtime=2026-07-21T14:02:11+00:00 first_seen=2026-07-21T14:05:00+00:00

Each entry leads with a one-sentence plain-language verdict so a generalist operator can act without escalating to a security architect. Depending on what's configured, up to three context lines follow:

  1. Package ownership — e.g. "Likely a legitimate install — matches package nginx v1.24.0", or the loud warning above when a changed trusted file is owned by no package.
  2. Change-source correlation (if configured) — whether an external change/ticket system has a record explaining the change, e.g. "Matches change RT#4821 (resolved)…" or "No approved change ticket found…". See Change sources.
  3. Package transaction / signature trust (when a package match exists) — whether that exact version arrived via a real authenticated transaction, e.g. "✓ installed via an authenticated apt transaction…" or, on rpm, "✓ signed with trusted key…". A mismatch is a loud "⚠ … — treat with suspicion."

Only path + sha256 are cryptographic

Everything on the detail: line beyond path and sha256 — owner, mode, size, mtime — is best-effort context for a human, from a plain lstat() on the agent side. It is never part of the trust decision. What matters is that path + sha256 came from the signed, replayed IMA measurement. The (was …) field is what distinguishes a modification from a new file, and all the correlation lines are informational — none of them gate or auto-approve anything.

The drift decision

flowchart TD
    P[Pending review item] --> Q{New file or<br/>changed file?}
    Q -->|new file, no prior baseline| C{Legitimate?}
    Q -->|was old-sha256| C
    C -->|yes, expected change| A[op approve host<br/>daemon-signed batch → new baseline]
    C -->|no, confirmed incident| R[op reject host --path --hash<br/>fires alarm immediately]
    C -->|legitimate + recurs every cycle| E{Same path,<br/>re-approved repeatedly?}
    E -->|yes, content never security-relevant| X[op exclude create --path --reason]
    E -->|no| A
    A --> D[Signed batch appended<br/>to hash-chained audit log]
    R --> D
    X --> D

Approve

Approval is the one everyday trust action the daemon performs. You name the host; the daemon authenticates you by peer-UID, checks your role, and signs the batch on your behalf, attributing it to your operator label:

# Per host — all its pending items in one daemon-signed batch:
etminan-verifier op approve web-01

op approve takes no --key and no --reason: the operator identity and the audit-log allow record supply the attribution the old signed reason used to. An operator may approve only within its host scope; the daemon denies (and audits the deny) anything outside it. See RBAC.

Approving one specific hash

When a path has two pending hashes and you mean to approve one of them, pass --matching <path> --hash <sha256> together — either both or neither; giving one without the other is refused rather than guessed at. Day to day, approve per host with op approve <host>.

Reject

Rejecting a measurement is declaring a confirmed incident — it fires the full alarm path immediately, exactly like a run finding, not a soft warning. Like approve, it goes through the daemon: peer-UID identity, the operator role, and a daemon signature over the record. Use the exact path and hash from baseline review, not from memory. It's idempotent: retrying an already-rejected entry does not double-record it.

etminan-verifier op reject web-01 --path /usr/local/bin/fds-proxy --hash <sha256>

Exclusions — suppressing recurring false alarms

Approving the same path over and over (a log rotated hourly, a cache file, an auto-generated artifact whose hash legitimately changes every cycle) is a different problem than approve solves — approve re-baselines one hash and the path comes right back next cycle once its content changes again. When you find yourself re-approving the same path with no meaningful change in what it represents, create an exclusion instead. Exclusions are daemon-mediated like every other trust change; exclude list is a plain read:

etminan-verifier op exclude create --path /var/log/myapp/current.log \
  --reason "rotates hourly, content never security-relevant"
etminan-verifier op exclude list
etminan-verifier op exclude revoke --id 3 --reason "no longer needed"

An exclusion suppresses review, not measurement — the IMA policy still measures the path every cycle exactly as before; a match just goes to the excluded_log table instead of pending_review, so there is a permanent, auditable record of every measurement an exclusion ever hid, not a silent drop. baseline review never shows anything an active exclusion matched; exclude list is where you audit which paths are currently suppressed and why.

A trailing * is a plain prefix match

create --path accepts an exact path or a trailing-* prefix. A trailing * previews how many currently-approved measurements it would affect and requires --confirm yes. Note it is a plain string prefix — /etc* also matches an unrelated sibling like /etcxyz/anything, not just paths under /etc/; write /etc/* if that's what you mean. Prefer narrow, exact paths. Without --host, a rule applies to every enrolled host; pass --host <id> to scope it to one. --reason is required on revoke too — re-exposing whatever a rule hid is as consequential as creating it.

Verifying the signatures

Every approve, reject, exclude create/revoke, and the full audit-log hash chain can be re-checked in one pass:

etminan-verifier baseline verify-signatures

This re-checks every approval batch's signature, every exclusion rule's create/revoke signature, every rejection's signature, and the audit-log chain — whether the action was signed by the daemon on an operator's behalf or, on a store predating 0.10, by the retired operator-key path. Rows written before signing existed for their action are reported as "unsigned, predates this feature" rather than a failure. It also confirms each past action was authorized for its actor's role at that action's time — see RBAC. Exits non-zero if anything fails. Safe to run on demand or on a schedule alongside run.

Enterprise: four-eyes on approval

Enterprise edition

With ETMINAN_DUAL_CONTROL_ACTIONS including baseline-approve (or default), an op approve does not apply on one operator's action. Instead it records a pending co-sign request over the exact canonical payload; a second, distinct operator must co-sign the same payload — as their own daemon-authenticated op approve <host> — before it applies. See Dual control and RBAC.

  • The attestation loop — how a Genuine cycle produces these pending items.
  • RBAC — the daemon roles and scopes that gate op approve.
  • Enrolling a host — the first, bulk baseline approval.
  • Change sources — the ticket-correlation line in a review entry.
  • Audit log — where every one of these decisions is permanently recorded.

The first review is not like the others

The first time you open op review on a newly enrolled host you will not see a handful of changes. You will see everything the kernel measured that the baseline does not yet cover — on a stock Debian 13 host that is roughly two thousand items, of which about seven hundred are manual pages, read wholesale by man-db's package triggers.

That is not a fault. IMA has no path filter of its own, so the policy measures broadly and the narrowing happens here, on the verifier. But it does mean the list is split into two halves that deserve very different attention, and the output says so:

CHANGED SINCE APPROVED — 8 item(s). A file whose approved hash no longer matches.
Read these first: each one is a change somebody made, or did not.
...

NEVER SEEN BEFORE — 2164 item(s). No prior baseline covers these, so nothing
changed: the baseline does not reach them yet.
...

2172 item(s) pending review: 8 changed since approved, 2164 never seen before.

Read the first section. Bulk-approve the second once you are satisfied the host was clean when it was enrolled — that is what establishing a baseline means, and until you do it every later drift arrives buried.

Excluding the noise, deliberately

For paths whose content is never security-relevant, op exclude create takes them out of review permanently. deploy/recommended-exclusions.sh prints a starting set — manual pages, package documentation, locale and timezone data — with the reason for each. It does not apply them, and that is on purpose: an exclusion nobody chose is a blind spot nobody knows about, which is why the command can be placed under four-eyes.

What an exclusion costs

A file planted under an excluded path is still measured by the kernel, still replayed into PCR 10, and still recorded in excluded_log. It is simply not shown to you. That is defensible for read-only documentation, because such a file is not executable — and the moment anything executes it, BPRM_CHECK measures it on a path that is not excluded, where you will see it.

Defensible is not free. Do not extend the list to anything executable, to configuration, or to a path a service reads at startup.