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 four 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

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.

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.

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 own inherited environment — whatever etminan-verifier's own verifier.env set — never from argv. Command-line arguments are visible in the process list to any local user; environment variables inherited by a child process are not.
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.sh
notify JSON array on stdin: [{"host_id","text","severity"}, ...] 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.

This is the actual reference plugin shipped at deploy/notify-plugins/slack.sh — not a simplified sketch. 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 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 — 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.