Skip to content

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:

  1. 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.
  2. 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 bar sudo and SSH already apply to a config or key file.
  3. 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.
  4. 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, default etminan-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:

/etc/etminan-verifier/notify-plugins.conf
# 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 setgroupssetgidsetuid 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 rendered formatted text) 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 that is_control() alone misses. A compromised host can't smuggle injection-capable bytes as far as baseline.db, a SOC terminal (baseline review), a SIEM, an email body, or another channel's payload.
  • URL validation. A url field is kept only if it's a plausible http(s):// link with no control characters or whitespace — a javascript:/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 distinct ETMINAN_<X>_* prefix for your plugin so it doesn't collide with another)
  • LC_*, and PATH, 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.

slack.sh (abridged reference plugin)
#!/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:

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

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

etminan-verifier plugins verify

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

etminan-verifier plugins verify

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