Plugin API¶
Etminan talks to your ticket system and your pager through external, allowlisted, hash-verified plugin scripts — one contract, any language, no verifier rebuild.
Etminan integrates with things it can't build support for itself: whatever change-management or ticket system you already run (Jira, ServiceNow, GitLab Issues, Request Tracker, an in-house tool), and whatever paging/chat/webhook target your on-call process already uses. Rather than hardcode a fixed list of integrations — every one a permanent maintenance burden and an HTTP client the verifier didn't need — each is a plugin: a separately-vetted external executable that satisfies a documented, category-specific contract.
Adding a plugin 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 as new categories are added later.
Where this fits
This chapter is the contract every plugin author reads. The concrete
plugins Etminan ships live in the Plugin directory;
the two categories have their own worked walkthroughs in
Notification channels and
Change-source correlation. The security model here is
implemented once, in verifier/src/plugin_exec.rs, and shared by every
category so it can never be re-implemented — or accidentally weakened — per
category.
The security model: verify-then-exec¶
A plugin is arbitrary code the verifier runs. The whole point of Etminan is that a compromised host can't forge a verdict, so the plugin model is held to the same operator-approved-only bar as everything else in the product (the AK-fingerprint enrollment ceremony, the pinned TLS fingerprint, signed baseline approval). Before every single execution — never cached from a prior run — the verifier runs four checks in order:
- Open with
O_NOFOLLOW. The plugin file is opened exactly once, refusing a symlink swapped in after any earlier check. The path is never re-resolved a second time before exec — closing the window a naive check-then-exec would leave. - Confirm ownership and permissions. It must be a regular file, owned by
uid 0, and not group- or world-writable (
mode & 0o022 == 0) — the same barsudoand SSH already apply to a config or key file. - Verify the pinned hash. The verifier 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. The verifier execs that same
already-verified file descriptor via
/proc/self/fd/<n>— not the path a second time — under a bounded 30-second timeout, as a separate, unprivileged user (ETMINAN_PLUGIN_USER, defaultetminan-verifier-plugin).
The four steps split into two guarantees. Steps 1–3 prove a plugin's bytes are
exactly what was approved — integrity. Step 4 is execution sandboxing: a
maliciously authored plugin that passed 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. Dropping the child to a dedicated unprivileged user closes exactly
that gap.
flowchart TD
A["run / check cycle<br/>or plugins verify"] --> B["load allowlist<br/>(change-source-plugins.conf /<br/>notify-plugins.conf)"]
B --> C{"name listed<br/>in allowlist?"}
C -->|no| X["refused — never run<br/>(no directory scan)"]
C -->|yes| D["open O_NOFOLLOW<br/>(one fd, no re-resolve)"]
D --> E{"regular file,<br/>uid 0,<br/>not group/world-writable?"}
E -->|no| X
E -->|yes| F{"SHA-256 of fd ==<br/>pinned hash?"}
F -->|no| X
F -->|yes| G["env_clear + pass only ETMINAN_* /<br/>system vars; drop to ETMINAN_PLUGIN_USER"]
G --> H["exec /proc/self/fd/n<br/>under 30s timeout"]
H --> I{"exit 0 &<br/>well-formed output?"}
I -->|no| Y["finding logged,<br/>excluded from this cycle"]
I -->|yes| Z["result sanitized,<br/>bounded, then used"]
X --> Y
Any failure is a visible finding, never a silent skip
Missing file, wrong owner, writable by someone else, hash mismatch, timeout,
non-zero exit, malformed output — any of these means "this plugin didn't
check out." It is logged and excluded from that cycle's results. It is
never a crash, and it must never be silently ignored: the outcome
feeds etminan-verifier plugins verify (on demand) and run's automatic
per-cycle check, which turns a broken plugin into an ordinary,
always-visible finding. See Verifying your configuration.
The allowlist is never a directory scan¶
A category's allowlist file — change-source-plugins.conf,
notify-plugins.conf, and any future category's own file — is the only source
of truth for what may run. A plugin that isn't listed there by name is never
run, no matter what's on disk. The file format is a hand-rolled
<name> <path> <sha256_hex>, one plugin per line, with # comments and blank
lines ignored:
# name path pinned sha256
slack /etc/etminan-verifier/notify-plugins/slack.sh 8dc10f47d8598059339b5a3545896b4e5c91f4ea8513a33fdfb3384d5bd49b96
A missing allowlist file is not an error — it means no plugins in that category are ever run, which is the correct default (every category is fully opt-in). The hash must be exactly 64 hex characters or the whole file is rejected on load.
The sandbox user¶
The dedicated user is what makes step 4 more than integrity. Dropping the child
process to ETMINAN_PLUGIN_USER means that as long as baseline.db and the
operator's signing key aren't readable by that user (your responsibility — the
project doesn't manage the key's permissions), a plugin literally cannot open
them, regardless of what code is in it.
| Aspect | Behavior |
|---|---|
| Default user | etminan-verifier-plugin |
| Opt out | ETMINAN_PLUGIN_USER= (explicitly empty) — plugins then run as the verifier's own user (useful for local dev) |
| Requires | CAP_SETUID/CAP_SETGID, granted via the systemd unit's AmbientCapabilities= (deploy/etminan-verifier.service) — the same narrowest-capability-not-root pattern the agent uses |
| Privilege drop | setgroups → setgid → setuid in a pre_exec hook (deliberately not Command::uid/gid, which never clear supplementary groups) |
| Process group | The child is its own group leader (setpgid), so a timeout kills the whole tree via killpg, not just a double-forked survivor |
Graceful degradation when the capability isn't held
Invoked interactively (sudo -u etminan-verifier etminan-verifier check …,
or during setup) the verifier holds neither capability, so the in-child
setuid fails with EPERM. The verifier detects exactly that and falls
back to running the plugin as its own user, with a warning — rather than
breaking every interactive invocation the moment a plugin is configured.
Likewise, a configured ETMINAN_PLUGIN_USER that doesn't resolve is a loud
warning, not a refusal to run.
Output is validated too, not just execution¶
A plugin's output is a threat surface. The verifier bounds and sanitizes it:
- Size cap. Stdout and stderr are each capped at 1 MiB — a resource-exhaustion plugin can't grow the verifier's memory without limit. The verifier keeps draining past the cap so a plugin can't wedge on a full pipe either.
- Control-character sanitization. Every free-form field a plugin returns (a
change-source's
id/summary/status, a notify channel's renderedformattedtext) has control characters stripped — the raw bytes an ANSI/terminal-escape sequence is built from — plus the Unicode bidirectional and zero-width "Trojan Source" code points thatis_control()alone misses. A compromised host can't smuggle injection-capable bytes as far asbaseline.db, a SOC terminal (baseline review), a SIEM, an email body, or another channel's payload. - URL validation. A
urlfield is kept only if it's a plausiblehttp(s)://link with no control characters or whitespace — ajavascript:/data:URI, a schemeless string, or an embedded newline is dropped, loudly, rather than stored.
The common invocation shape¶
Everything below is true of every plugin, in every category:
| Property | Contract |
|---|---|
| Any language | A standalone executable — shell, Python, a compiled binary — anything directly executable (#!/bin/sh, an ELF binary). The verifier only cares what the file hashes to. |
| Config via environment only | Secrets and config are read only from the environment, never from argv (argv is visible in the process list to any local user). |
| Exit code is the verdict | 0 = ran successfully; non-zero = "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. |
The environment a plugin receives¶
The verifier clears its environment before exec (env_clear) and passes the
plugin only the ETMINAN_* namespace plus the basic system vars a script needs
to run. Anything the verifier happens to run with outside that set (a cloud
credential, an unrelated token in its systemd unit) never reaches the plugin, so
one compromised plugin can't exfiltrate an unrelated secret.
Passed through:
ETMINAN_*(the plugin config namespace — pick a distinctETMINAN_<X>_*prefix for your plugin so it doesn't collide with another)LC_*, andPATH,HOME,LANG,LANGUAGE,TZ,TMPDIR,TERM,USER,LOGNAME,SHELL
Everything else — AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, DATABASE_URL,
SSH_AUTH_SOCK, … — is dropped.
Plugin categories¶
What differs per category is what a plugin receives as input and what it's expected to produce — the shape of "its job."
| Category | Input | Output | Reference plugins |
|---|---|---|---|
change-source |
argv: --host <id> --anchor <RFC3339 timestamp> |
JSON array on stdout: [{"id","summary","status","url"}, …]; [] = "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 |
Why argv for one and stdin for the other?
A change-source plugin's whole job is to query an external system using
the host/time it's given, so a couple of scalar flags are natural — and let
you test it by hand:
./plugin.sh --host web-01 --anchor 2026-07-21T00:00:00Z. A notify plugin's
input is a list of findings whose text is free-form and
operator/attacker-influenced; passing that through argv would mean
shell-escaping arbitrary text into command-line arguments — exactly the
injection surface this project avoids. JSON on stdin sidesteps it entirely.
A future category adds a row to this table and its own short chapter — the security model and invocation shape above don't change.
Writing a new plugin¶
The recipe is the same six steps for any category, any language.
1. Pick a category¶
Read its row above and its dedicated chapter (Notification channels or Change-source correlation) for a worked example.
2. Write the script¶
Satisfy that category's input/output contract, and read whatever config it needs
from its own environment with a distinct ETMINAN_<X>_* prefix. This abridged
notify plugin posts a single digest message to Slack per cycle — it satisfies
the contract exactly: no arguments, findings arrive as JSON on stdin, config
comes from the environment, exit code is the only signal read back.
#!/bin/sh
# Reads ETMINAN_SLACK_WEBHOOK_URL from the inherited environment — never argv.
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 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), and one message per invocation rather than one per finding — a noisy cycle should still be a single message, not a flood.
3. Install it root-owned¶
Somewhere not group/world-writable:
4. Allowlist it¶
Compute the hash and add one line to the category's .conf file — recompute the
hash every time you touch the script:
sha256sum /etc/etminan-verifier/notify-plugins/slack.sh
# 8dc10f47…b96 /etc/etminan-verifier/notify-plugins/slack.sh
# append to /etc/etminan-verifier/notify-plugins.conf:
# slack /etc/etminan-verifier/notify-plugins/slack.sh 8dc10f47…b96
5. Enable it and set its config¶
Add its name to the category's enable-list variable — ETMINAN_CHANGE_SOURCES
or ETMINAN_NOTIFY_CHANNELS — and set the plugin's own config, in the
verifier's environment (e.g. /etc/etminan-verifier/verifier.env):
ETMINAN_NOTIFY_CHANNELS=slack
ETMINAN_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/xxxx
6. Verify it¶
No restart for a config-only change
etminan-verifier is invoked fresh per check/run — it is not a
long-lived daemon that reads env vars once at startup — so the next
scheduled run (or a manual plugins verify) picks up new config
immediately.
Verifying your configuration¶
This checks every plugin currently referenced by any category's enable-list
variable: it confirms each has an allowlist entry and passes the full security
check above (ownership, permissions, content hash) — without executing it.
The output is one line per configured plugin, grouped by category, ending in
Everything verified. when clean, and exiting non-zero if anything fails:
change-source 'request-tracker' — OK
notify 'slack' — OK
notify 'pagerduty' — FAILED: plugin 'pagerduty' content hash mismatch (…) — refusing to execute
The alerting mechanism can't hide its own breakage
The same check runs automatically — silent in the success case — at the
start of every run cycle. A failure there becomes an ordinary
warning-severity finding (resource "verifier", not a real host id,
kind = plugin-failure) that goes through the same print / email / notify /
audit-log path as any other finding. So a broken notification channel
can't suppress the news of its own failure: if even one channel still works,
or the local structured log, the problem is visible.
Installing a certified plugin from the catalog¶
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, the catalog commands collapse those into one command,
and "installed via this command" is itself the guarantee 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 manifest's Ed25519 signature
against a dedicated, single-purpose trust anchor compiled into the
etminan-verifier binary — not the GPG release-signing key, not any operator's
key, and no keyring to import — before trusting a single entry. 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. That is exactly the file plugin_exec.rs 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 says to
install a plugin root-owned in the first place.
plugins install deliberately stops short of one thing: it never edits
ETMINAN_CHANGE_SOURCES/ETMINAN_NOTIFY_CHANNELS, which live in the verifier's
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,
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.
Nothing here ever updates a plugin automatically or in the background; an
unattended auto-update would undercut the whole point of a pinned hash.
Catalog not yet live
The client side (plugins list/install/update) is shipped and tested,
but nothing yet publishes a real, signed plugins-catalog.json at its
default URL, so these commands error on the fetch until that pipeline
exists. Until then the manual six-step path is the only route. See the
Plugin directory for per-plugin status.
See also¶
- Plugin directory — every plugin Etminan ships, with an honest status on each
- Notification channels — the
notifycategory, in full - Change-source correlation — the
change-sourcecategory, in full - SIEM output — structured finding export (Enterprise)
- Hardening the deployment — locking down the verifier host the plugins run on