etminan
Plugin API

One contract, any ticket system, any pager.

Etminan integrates with things it can't build support for itself — whatever change-management or ticket system a customer already runs, and whatever paging/chat/ webhook target their on-call process already uses. Rather than hardcoding a fixed list of integrations, every one of these is a plugin: a separately-vetted external script or executable, in any language, that satisfies a documented, category-specific contract. This page is that contract, with a full worked example.

01 — Why plugins, not Rust integrations

Nobody is switching ticket systems for an attestation tool.

A customer's ticket system is Jira, ServiceNow, GitLab Issues, Request Tracker, or something built in-house — and their on-call process already pages through PagerDuty, Slack, or a webhook into some other tool. Etminan will never support all of these as first-party Rust code, and it shouldn't try to: every hardcoded integration is a permanent maintenance burden and an HTTP client the verifier didn't need. Instead, adding an integration is: write a script, allowlist it (name, path, pinned content hash), reference its name in one environment variable. No Rust code, no verifier rebuild, ever — true today and stays true as more categories are added later.

02 — Security model

The same checks, every single time, for every category.

Implemented once, in verifier/src/plugin_exec.rs, shared by every plugin category so this is never re-implemented — or accidentally weakened — per category. Before executing any plugin, every time, never cached from a prior run:

Open with O_NOFOLLOW

Refuses a symlink swapped in after any earlier check — never re-resolves the path a second time before exec, closing the window a naive check-then-exec would leave.

Confirm ownership and permissions

Must be a regular file, owned by uid 0, not group- or world-writable — the same bar sudo/SSH already apply to a config or key file.

Verify the pinned hash

Hashes that exact open file and compares it against the pinned SHA-256 in the category's allowlist file. A modified plugin — accidentally or maliciously — is refused, not silently run with different behavior.

Execute the verified descriptor, sandboxed

Runs that same already-verified file descriptor via /proc/self/fd/<n>, under a bounded timeout — an unresponsive plugin can't hang a whole check/run cycle — as a separate, unprivileged ETMINAN_PLUGIN_USER (default etminan-verifier-plugin), not sharing etminan-verifier's own user. The first three steps prove a plugin's bytes are exactly what was approved — integrity, not that an approved plugin is safe to run. A maliciously authored plugin that passes every check above still shouldn't be able to read baseline.db or the operator's signing key just because it was allowed to execute — this is execution sandboxing, closing that gap. Requires CAP_SETUID/CAP_SETGID (granted via the systemd unit's AmbientCapabilities=); invoked outside that unit, this degrades automatically to running as etminan-verifier's own user, with a warning, rather than breaking every interactive invocation.

Any failure at any of these steps — missing file, wrong owner, writable by someone else, hash mismatch, timeout, non-zero exit, malformed output — is treated as "this plugin didn't check out": logged, and excluded from that cycle's results. It is never a crash, and it must never be silently ignored either: every category feeds its verification outcome into etminan-verifier plugins verify (an on-demand check) and into run's automatic per-cycle check, turning a broken plugin into an ordinary, always-visible finding — not a line in stderr someone has to be watching for.

A category's allowlist file (change-source-plugins.conf, notify-plugins.conf, and any future category's own file) is never a directory scan: a plugin that isn't listed there by name is never run, no matter what's on disk.

A plugin's output is validated too, not just its execution. Stdout/stderr are capped at 1 MiB — a resource-exhaustion plugin can't grow etminan-verifier's memory without limit. Every free-form field a plugin returns (a change-source's id/summary/status, a notify channel's rendered formatted text) has control characters — what an ANSI/terminal-escape sequence is built from — stripped and its length bounded before it's stored or displayed, so a malicious plugin can't use op review's terminal output, an email body, or another channel's payload as an injection vector. A url field is kept only if it's a plausible http(s):// link with no control characters or whitespace — dropped otherwise.

Where the pinned hash comes from: a signature, not just trust

Every check above runs regardless of how a plugin's hash got into the allowlist file in the first place — by default, that's an operator computing sha256sum by hand and copying it in, which is correct but pure trust-on-first-use. etminan-verifier plugins install/update <name> (verifier/src/plugin_catalog.rs) adds a second, cryptographic layer in front of that: it fetches a catalog manifest, verifies a detached Ed25519 signature over it against a trust anchor compiled into the etminan-verifier binary itself — no keyring to import, no subprocess call to gpg — and only then trusts the SHA-256 it lists for that plugin, downloads it, and writes the allowlist line. The signing key is a dedicated, single-purpose Ed25519 keypair used for nothing else — not the GPG key that signs release tarballs, not any operator's own signing key — so compromising it only ever lets an attacker forge catalog entries, never reach baseline approvals, the audit log, or a release. Not yet live in practice: this verification code and the trust anchor are shipped, but nothing yet publishes a real, signed catalog manifest for it to fetch — see the plugin directory for current status. Until that exists, hand-pinning the SHA-256 yourself (above) is the only path, and remains fully supported either way — the catalog is an added convenience and an added guarantee, never a replacement for it.

03 — Common invocation shape

What's true of every plugin, in every category.

Any language
A standalone executable — shell, Python, a compiled binary, anything directly executable (#!/bin/sh, an ELF binary, etc.). The verifier never cares what's inside, only what the file hashes to.
Config via environment only
Secrets and config are read only from the plugin's environment, never from argv. Command-line arguments are visible in the process list to any local user; environment variables passed to a child process are not. The verifier clears its environment before exec (env_clear) and passes the plugin only the ETMINAN_* namespace plus basic system vars (PATH, HOME, LANG/LC_*, TZ, TMPDIR, TERM, USER, LOGNAME, SHELL) — so pick a distinct ETMINAN_<X>_* prefix for your plugin's config. Anything the verifier happens to run with outside that set never reaches the plugin, so an unrelated secret can't leak to an approved-but-later-compromised plugin.
Exit code is the verdict
0 means it ran successfully; non-zero means "didn't do its job this cycle," with a clear message on stderr. A plugin must never print a partial or best-guess result and exit 0.
What differs per category
What the plugin receives as input and what it's expected to produce — the shape of "its job." See the categories below.
04 — Categories

Two today; a future one adds a row, not a redesign.

CategoryInputOutputReference plugin(s)
change-source argv: --host <id> --anchor <RFC3339 timestamp> JSON array on stdout: [{"id","summary","status","url"}, ...], [] for "checked, nothing found" deploy/change-sources/{request-tracker,freeitsm}.sh
notify JSON array on stdin: [{"host_id","text","severity","kind","formatted"}, ...] nothing read from stdout; exit code is the only signal deploy/notify-plugins/{pagerduty,slack,webhook}.sh

Each category has its own full walkthrough: change-source correlation and notification channels. A future category adds a row to this table and its own short doc — the security model and invocation shape above don't change.

Why change-source takes structured input via argv but notify takes it via stdin: a change-source plugin's whole job is to go query an external system using the host/time it's given, so a couple of scalar values as flags is natural and lets a plugin be tested by hand trivially. A notify plugin's input is a list of findings whose text is free-form and attacker/operator-influenced — passing that through argv would mean shell-escaping arbitrary text into command-line arguments, exactly the kind of injection surface this project avoids elsewhere. JSON on stdin sidesteps that entirely.

05 — Worked example

Adding Slack as a notify channel, end to end.

An abridged version of the reference plugin shipped at deploy/notify-plugins/slack.sh — the real script additionally uses each finding's pre-rendered formatted field, escapes Slack mrkdwn control characters (&/</>) in the message text, and supports an optional ETMINAN_SLACK_TEMPLATE whole-message layout. It satisfies the notify category's contract exactly: no arguments, findings arrive as JSON on stdin, config comes from the environment, exit code is the only signal read back.

deploy/notify-plugins/slack.sh
#!/bin/sh
# Reads ETMINAN_SLACK_WEBHOOK_URL from the inherited environment —
# never from argv. Requires curl and jq.
set -eu

: "${ETMINAN_SLACK_WEBHOOK_URL:?ETMINAN_SLACK_WEBHOOK_URL not set}"

# the findings JSON array arrives whole, on stdin
findings=$(cat)

# one digest message per invocation, not one per finding
text=$(echo "$findings" | jq -r '
  ["etminan-verifier: " + (length | tostring) + " finding(s):"]
  + (map("  [" + .host_id + "] (" + .severity + ") " + .text))
  | join("\n")
')

payload=$(jq -n --arg text "$text" '{text: $text}')

curl -fsS --max-time 20 \
  -H "Content-Type: application/json" \
  -X POST "$ETMINAN_SLACK_WEBHOOK_URL" \
  -d "$payload" >/dev/null

Note what it deliberately does not do: no argument parsing (the contract for this category says none), no retry loop (a non-zero exit is the verifier's cue to log "this channel didn't fire this cycle" and move on, never to block or retry), and one Slack message per invocation rather than one per finding — a noisy cycle with many findings should still be a single message, not a flood.

Deploying it

Install the script, root-owned

$ install -o root -g root -m 0755 slack.sh /etc/etminan-verifier/notify-plugins/slack.sh

Compute its pinned hash

$ sha256sum /etc/etminan-verifier/notify-plugins/slack.sh
8dc10f47d8598059339b5a3545896b4e5c91f4ea8513a33fdfb3384d5bd49b96 slack.sh

Allowlist it

Add one line to /etc/etminan-verifier/notify-plugins.conf — name, path, pinned hash, whitespace-separated:

slack /etc/etminan-verifier/notify-plugins/slack.sh 8dc10f47d8598059339b5a3545896b4e5c91f4ea8513a33fdfb3384d5bd49b96

Enable it and set its config

In /etc/etminan-verifier/verifier.env:

ETMINAN_NOTIFY_CHANNELS=slack
ETMINAN_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/xxxx
# optional: only page this channel on critical/warning findings
ETMINAN_NOTIFY_SLACK_SEVERITIES=critical,warning

Verify before trusting it

$ etminan-verifier plugins verify
slack: OK (allowlisted, root-owned, hash matches)

Confirms the plugin checks out without waiting for a real finding to fire it — this same check also runs automatically at the start of every run cycle.

Restart is never required for a config-only change — etminan-verifier is invoked fresh per op check/run, not a long-lived daemon reading env vars once at startup, so the next scheduled run (or a manual plugins verify) picks up new config immediately.

06 — Writing your own plugin

Same six steps, for any category, any language.

Pick a category

Read its row in the table above, and its own doc page for a worked example.

Write the script

Satisfy that category's input/output contract, and read whatever config it needs from its own environment — pick a distinct ETMINAN_<X>_* prefix so it doesn't collide with another configured plugin.

Install it root-owned

Somewhere not group/world-writable, e.g. /etc/etminan-verifier/notify-plugins/my-plugin.sh.

Allowlist it

In that category's .conf file: <name> <path> <sha256sum output>. Recompute the hash every time you touch the file.

Enable it

Add its name to that category's enable-list environment variable (ETMINAN_CHANGE_SOURCES or ETMINAN_NOTIFY_CHANNELS).

Verify it

$ etminan-verifier plugins verify

Confirms the plugin is allowlisted and passes every check above, without waiting for a real check/run cycle to find out the hard way.

07 — Installing a certified plugin from the catalog

The six steps above, collapsed into one command — for plugins Etminan itself vouches for.

Steps 3–5 above (download, hash it yourself, hand-copy the hash into a .conf file) are the manual path — always available, and the only path for a plugin you or a third party wrote yourselves. For a reference plugin Etminan itself publishes and vouches for, plugins install collapses those into one command, and "installed via this command" is itself the guarantee that the plugin is genuinely Etminan's, unmodified, and safe to run:

$ etminan-verifier plugins list
# what's in the catalog, and what's installed
$ sudo etminan-verifier plugins install pagerduty

plugins install verifies the whole catalog's Ed25519 signature against a dedicated, single-purpose key compiled into the etminan-verifier binary (not a GPG key, not any operator's key) before trusting a single entry in it, then downloads the plugin, re-verifies its content hash against the catalog's pinned value, writes it into /etc/etminan-verifier/<type>-plugins/, and appends the correct .conf entry — exactly the file this page's own verify-then-exec model already checks at execution time, so a catalog-installed plugin is held to the identical bar as a hand-installed one. It must run as root, for the same reason step 3 above says to install a plugin root-owned in the first place.

It deliberately stops short of one thing: it never edits ETMINAN_CHANGE_SOURCES/ETMINAN_NOTIFY_CHANNELS for you, since those live in the verifier's own systemd unit environment, not a file this command manages — it prints the exact line to add and reminds you to restart etminan-verifier.service, step 5 above, still a deliberate, explicit action. plugins update <name> re-checks the catalog and re-pins an already-installed plugin to whatever version it currently lists — only when you run it, never automatically or in the background.

Not yet usable in practice: the client side above is real and shipped, but nothing publishes a real, signed plugins-catalog.json at its default URL yet — see the Plugin Directory for current status per plugin. Until that exists, the manual six-step path above remains the only real route.

08 — Verifying your configuration

Never a silent failure — even for the alerting mechanism itself.

$ etminan-verifier plugins verify

Checks every plugin currently referenced by any category's enable-list variable: confirms it has an allowlist entry and passes the full security check above (ownership, permissions, content hash) — without executing it. Exits non-zero and prints exactly what's wrong if anything fails.

This is also run automatically, silently-in-the-success-case, at the start of every run cycle — a failure there is turned into an ordinary warning-severity finding (resource "verifier", not a real host id) that goes through the same print/email/notify/audit-log path as any other finding, specifically so a broken notification channel doesn't get to hide its own breakage: if even one channel still works, or the local structured log/print output, the problem is visible.