░░
▒██▒
▓████▓
▓ ▓██████▓ ▓
▓█▓░ ▒██████▒ ░▓█▓
██████▓▒▓██▓▒▓██████
▒███████▓▒▒▓███████▒
░▒▓█████░░█████▓▒░
▒████▒
██
▒▒
█▀▀ █▄ █ █▀█ █▀▄ █ ▄▀█
██▄ █ ▀█ █▄█ █▄▀ █ █▀█ asks your deployed services what version they are running, then checks
those versions against vendor lifecycle calendars and tells you what is
already dead, what is dying, and where your fleet has drifted apart.
You describe your services once. Enodia handles the rest.
docs.enodia.sh · full docs, config reference, and per-product probe notes.
Status: 2.2. The full pipeline (collect → inventory → evaluate → render), 123 probes, CVE correlation against BDU ФСТЭК, NVD and vendors' own advisories and, for twelve Linux distributions, per installed package,
enodia cve updatefor the databases,settings.yaml, and the release/packaging pipeline are all implemented and used against real production infrastructure. SeeCHANGELOG.mdfor release history, or the latest release for downloads.
schemaVersion: 1
targets:
- id: jira-main
product: jira
address: https://jira.example.com
- id: gitlab-main
product: gitlab
address: https://gitlab.example.com
credentials: gitlab-token
- id: web01
product: debian
address: web01.example.com
credentials: web01-ssh
credentials:
gitlab-token:
kind: token-header
header: PRIVATE-TOKEN
value: "${GITLAB_TOKEN}"
web01-ssh:
kind: ssh-key
username: enodia
private_key_file: /etc/enodia/keys/web01$ enodia check
ID PRODUCT PATCH LIFECYCLE BRANCH SEVERITY REASON CVES
jira-main jira behind active newer_lts warn - -
gitlab-main gitlab behind eol newer fail - -
web01 debian current active latest ok - -Save this as enodia.yaml next to the binary — see "File locations" below
for every name and directory enodia actually searches, and the exact order.
| Tool | Tracks upstream releases | Knows what you deployed | Lifecycle dates |
|---|---|---|---|
eol CLI |
— | — | yes |
| nvchecker | yes | — | — |
| Renovate / Dependabot | yes (dependencies) | — | — |
| what's-up-docker | yes (containers only) | containers only | — |
| Uptime Kuma | — | up/down only | — |
| Enodia | yes | yes | yes |
The gap Enodia fills is the join: inventory of the live fleet, matched against lifecycle data.
Two phases, deliberately separable. Collection talks to your services; evaluation talks to the internet. They almost never have network access to the same places.
collect → inventory.jsonl → evaluate → assessment → render
For air-gapped environments this is the whole point:
# inside the closed network — no internet needed
enodia collect --config config.yaml -o inventory.jsonl
# anywhere else — no access to your services needed
enodia check --from inventory.jsonlThree orthogonal axes, not one verdict. A branch can be perfectly healthy while a newer major exists — Confluence 10 LTS is alive and supported even though 11 shipped. Collapsing that into a single status throws away the information you actually wanted.
| Axis | Values |
|---|---|
| Patch | current · behind · ahead · unknown |
| Lifecycle | active · security · eol · unknown |
| Newer branch | latest · newer · newer_lts · unknown |
Facts and judgement are separate. The inventory records what was observed. Severity is computed on top, from policy you control. Export the facts and apply your own rules if ours do not fit.
Time is a parameter. Evaluation never calls the system clock itself — it
always takes asOf explicitly, sourced from the inventory's own collection
timestamp (inventory.jsonl's collectedAt). Re-running check --from an
old inventory evaluates it as of when it was collected, not today, so
results and tests both stay deterministic instead of silently drifting with
the calendar.
Probes are compiled in. One product, one file, one entry in an explicit
registry. Adding support means a new release, not a plugin ABI. For anything
in-house, product: generic takes a parser spec straight from your config.
Transport isn't assumed to be HTTP. Most probes speak HTTP, but Redis,
PostgreSQL, MySQL and MongoDB speak their own wire protocols directly, and a
growing set — every Linux distro, the BSDs, macOS, OPNsense — is reached over
SSH instead: enodia runs one identifying command (cat /etc/os-release,
sw_vers, ...) and reads the answer, the same way an HTTP probe reads a JSON
body. kind: ssh-key and kind: password credentials cover both auth shapes;
host key verification reuses the same pin_sha256/insecure fields TLS
targets already have.
A credential's kind has to match its transport. HTTP products read
basic, bearer or token-header; SSH probes read ssh-key or
password; Redis, PostgreSQL and the other wire-protocol probes read
password. A user/password pair for a web UI or REST API (RouterOS,
Harbor, Jenkins, a BMC) is therefore kind: basic, not kind: password.
config validate and every run reject a mismatch and name the kinds the
product does accept (D48).
check and export both render one of four focuses, picked with --view
(or settings.yaml's render.default_view when the flag isn't passed —
see "Reporting" below). Each view is a different slice of the same
inventory + assessment data, not a different data source.
compact (the default) — one row per target: the three axes, overall
severity, and why, if anything needs attention. This is what plain
enodia check shows, as in the example above.
lifecycle — when each target's lifecycle actually ends:
$ enodia check --from inventory.jsonl --view lifecycle
ID PRODUCT LIFECYCLE EOL SUPPORT-ENDS DAYS-TO-EOL
jira-main jira active 2026-12-05 - 338
gitlab-main gitlab eol 2025-01-16 2024-11-21 -350drift — installed version against the latest release in the same
cycle:
$ enodia check --from inventory.jsonl --view drift
ID PRODUCT CURRENT LATEST CYCLE PATCH CVES
jira-main jira 10.3.1 10.3.25 10.3 behind 5
gitlab-main gitlab 17.5.0 17.5.5 17.5 behind 255fleet — version spread and reachability across every instance of a
product, grouped instead of listed one row per target. The offline-only
view: it needs nothing but the inventory itself, no lifecycle resolver, no
internet access at all. Two failed instances of the same product with
different failure kinds (auth vs. unreachable) get their own rows, not a
shared (unknown) bucket:
$ enodia check --from inventory.jsonl --view fleet
PRODUCT VERSION STATUS COUNT INSTANCES
gitlab (unknown) auth 1 gitlab-2
gitlab 18.2.1 ok 1 gitlab-1
jira (unknown) unreachable 1 jira-staging
jira 10.3.1 ok 1 jira-3
jira 10.3.2 ok 2 jira-1, jira-2export --format json/prometheus ignore --view entirely — they always
carry every observation and assessment; views only shape the table and the
HTML report (see "Reporting").
Each release carries a
.deb, .rpm, .apk and Arch's .pkg.tar.zst (linux/amd64+arm64)
alongside the raw archives, plus an official container image:
sudo dpkg -i enodia_linux_amd64.deb # Debian/Ubuntu
sudo rpm -i enodia_linux_amd64.rpm # Fedora/RHEL
apk add --allow-untrusted enodia_linux_amd64.apk # Alpine
sudo pacman -U enodia_linux_amd64.pkg.tar.zst # Arch
docker run --rm \
-v /etc/enodia:/config:ro \
ghcr.io/epicmorg/enodia:1 check --config /config/config.yamlThe same image is also published to docker.io/epicmorg/enodia and Quay
— same tags (latest, 1, and the exact version, e.g. 1.0.0-0), pick
whichever registry you already pull from. Its Dockerfile lives outside
this repo, in the
EpicMorg/docker
monorepo alongside the house images and several other products this
project's own probes cover — still the official image, just built and
published on that repo's own schedule rather than in this repo's release
pipeline (see docs/DECISIONS.md D17).
All four packages install the binary at /usr/bin/enodia, man pages for
every command under /usr/share/man/man1/ (man enodia, man enodia-collect, ...), and create a dedicated, unprivileged enodia system
user — nothing in this package needs root to run, so enodia serve under
systemd shouldn't get any more than it does. Three empty directories are
created and chowned to that user: /etc/enodia (root:enodia, 0750 —
readable by the service via group membership, not writable by it — for your
enodia.yaml/settings.yaml/credentials.yaml; the package never writes a
config into it, a missing one is meant to be a loud error), and
/opt/enodia//var/enodia (enodia:enodia, 0750) for whatever the
service writes at runtime.
Or, for the raw archive rather than a package:
curl -sSL https://raw.githubusercontent.com/EpicMorg/enodia/master/install.sh | sh # Linux/macOS
irm https://raw.githubusercontent.com/EpicMorg/enodia/master/install.ps1 | iex # Windows| OS | Arch | Minimum version |
|---|---|---|
| Linux | amd64, arm64 | Kernel 3.2 or later — Debian 8+, Ubuntu 14.04+, RHEL/CentOS 7+ all comfortably qualify |
| Windows | amd64, arm64, 386 | Windows 10 / Windows Server 2016 or later |
| macOS | amd64, arm64 | macOS 12 Monterey or later |
| Android (Termux) | arm64 only | Android 7+ (Termux's own floor) — a separate enodia_android_arm64 build, not the Linux one (see docs/DECISIONS.md D20); amd64/386/arm aren't built yet (D20/ROADMAP). Rooted devices (Magisk/KernelSU) may need su to run it at all, due to an open upstream bug, termux-exec#40 — not something fixable from this side, see D20 |
These are the Go 1.26 toolchain's own floor (confirmed against
go.dev/wiki/MinimumRequirements
directly, not assumed), not something enodia adds on top — building from
source with a newer Go raises the macOS floor further (1.27 requires macOS
13 Ventura), since that's a toolchain decision, not a project one.
CGO_ENABLED=0 (see .goreleaser.yaml) means the binary is fully static
and never links libc at all: on Linux, only the kernel version matters, not
which distro or glibc version is underneath.
enodia export --format html writes a single self-contained file. Point nginx
at it and refresh it from cron or a systemd timer.
There is no built-in web server and no refresh button, by design: a button that polls your entire fleet on every click is a self-inflicted denial of service. Collection runs on a schedule; the page shows the latest snapshot and states plainly when it was taken.
An optional settings.yaml holds personal display defaults: which format
export uses when --format isn't passed, which table view check/export
use when --view isn't passed, and how export --format html renders — see
"File locations" below for every name and directory it's searched in, and why
a missing one is never an error the way a missing enodia.yaml is. For
example:
schemaVersion: 1
export:
# used whenever `export` is run without --format; the built-in default
# stays json either way
default_format: html
render:
# compact (default) | lifecycle | drift | fleet
default_view: fleet
html:
# inline (default, fully offline) | cdn (loads Bootstrap/Bootswatch)
assets: cdn
# none (no stylesheet at all) | default (plain Bootstrap) | any of
# Bootswatch's 26 real themes: brite, cerulean, cosmo, cyborg, darkly,
# flatly, journal, litera, lumen, lux, materia, minty, morph, pulse,
# quartz, sandstone, simplex, sketchy, slate, solar, spacelab,
# superhero, united, vapor, yeti, zephyr
theme: lumen
# auto (default: races jsdelivr and cdnjs, uses whichever answers
# first) | jsdelivr | cdnjs
cdn: auto
# optional: restrict the export to one view instead of all four
# view: fleetEvery html.* field here only matters for export --format html; the
default enodia check table is unaffected by any of them except
render.default_view. See docs/DECISIONS.md D19 for the full reasoning,
including why a corrupted or unrecognised theme saved in a viewer's browser
resets to this file's html.theme, not to some hardcoded name.
With html.assets: cdn, export --format html gives each row a Bootstrap
contextual class — red for a failed instance, green for a reachable one (see
"Views" above for the plain data), in whatever Bootswatch theme is
configured, not a hardcoded color enodia has to maintain per theme. Here's
the fleet view's rows from the same data:
<table class="table table-striped table-hover table-sm align-middle">
<thead><tr><th>PRODUCT</th><th>VERSION</th><th>STATUS</th><th>COUNT</th><th>INSTANCES</th></tr></thead>
<tbody>
<tr class="table-danger"><td>gitlab</td><td>(unknown)</td><td>auth</td><td>1</td><td>gitlab-2</td></tr>
<tr class="table-success"><td>gitlab</td><td>18.2.1</td><td>ok</td><td>1</td><td>gitlab-1</td></tr>
<tr class="table-danger"><td>jira</td><td>(unknown)</td><td>unreachable</td><td>1</td><td>jira-staging</td></tr>
<tr class="table-success"><td>jira</td><td>10.3.1</td><td>ok</td><td>1</td><td>jira-3</td></tr>
<tr class="table-success"><td>jira</td><td>10.3.2</td><td>ok</td><td>2</td><td>jira-1, jira-2</td></tr>
</tbody>
</table>Optional. enodia can match every probed version against local
vulnerability databases: BDU ФСТЭК and NIST NVD, distributions' own
security data and some vendors' own. Point enodia.yaml at the files;
enodia cve update downloads them, or fetch them yourself. check,
collect and serve never download anything.
cve:
bdu:
path: /var/lib/enodia/cve/bdu/vulxml.zip # https://bdu.fstec.ru/files/documents/vulxml.zip
nvd:
path: /var/lib/enodia/cve/nvd # a directory of nvdcve-2.0-<year>.json.gz
debian:
path: /var/lib/enodia/cve/debian.json # https://security-tracker.debian.org/tracker/data/json
oval:
path: /var/lib/enodia/cve/oval # vendor OVAL files, one per release (see below)
alpine:
path: /var/lib/enodia/cve/alpine # https://secdb.alpinelinux.org/<branch>/{main,community}.json
mariadb:
path: /var/lib/enodia/cve/mariadb.md # https://mariadb.com/docs/server/security/cve/community-server.md
atlassian:
path: /var/lib/enodia/cve/atlassian.json # https://api.atlassian.com/vuln-transparency/v1/products
postgresql:
path: /var/lib/enodia/cve/postgresql # https://www.postgresql.org/support/security/ (+ /<major>/ pages)
nginx:
path: /var/lib/enodia/cve/nginx.html # https://nginx.org/en/security_advisories.htmlenodia cve update # every cve.*.path in the active config
enodia cve update --from inventory.jsonl # also the OVAL releases, Alpine branches and
# PostgreSQL majors that inventory's hosts need
enodia cve update --dry-run # list what would be fetchedIt fetches into each configured path what that entry reads: BDU's .zip,
NVD's yearly files (this year, last year and any year not on disk yet;
--all-years refreshes all of them — NVD rebuilds every yearly file
daily), the Debian tracker's .json, MariaDB's, Atlassian's, nginx's
pages and PostgreSQL's main page. For OVAL, Alpine and PostgreSQL's
per-major pages it fetches the releases already in those directories,
the ones the --from inventories need, and any given with
--oval ubuntu:noble, --alpine v3.22 or --postgresql 13. So
oval.path, alpine.path and nvd.path must be directories, bdu.path
a .zip and debian.path a .json; a postgresql.path that is a file
gets the main page only.
Each file is requested If-Modified-Since its copy on disk, downloaded
beside it, loaded by the same code check uses, and only then moved over
the old copy: an unchanged file costs one request, and a failed or broken
download never replaces a working one. The exit status is 1 if any file
failed; the others are still updated. Run it from cron; the next check
or serve collection picks the new files up.
TLS is verified against the system's trusted roots. bdu.fstec.ru's chain ends at the Russian Trusted Root CA, which most systems don't carry, and the server doesn't send its intermediate — add both, or turn verification off:
cve:
update:
ca_file: /etc/enodia/russian-trusted.pem # added to the system roots; PEM (one or many) or DER
ca_dir: /etc/enodia/ca # every certificate file in it, likewise
tls_skip_verify: false # true: verify nothing, for every downloadThe Root CA and "Russian Trusted Sub CA" (2024) are published at
http://nuc-cdp.digital.gov.ru/cdp/rootca_ssl_rsa2022.crt and
http://nuc-cdp.digital.gov.ru/cdp/subca_ssl_rsa2024.crt.
Each block works alone. bdu.path is the export as published (.zip),
or the .xml inside it, or a .tar.gz. nvd.path is one file or a
directory of NVD's yearly files (.json, .json.gz or .json.zip, from
https://nvd.nist.gov/feeds/json/cve/2.0/). Relative paths resolve against
the config file's own directory. The block is read from whichever
enodia.yaml the run uses (see "File locations").
On Windows any of these work: C:\enodia\cve\nvd unquoted,
'C:\enodia\cve\nvd' in single quotes, C:/enodia/cve/nvd, or a UNC path.
Avoid double quotes — YAML turns \t and \n inside them into a tab and a
newline, so "C:\temp\nvd" is rejected at load with a hint.
The first run after a database file changes parses it in full — about a
minute for all of NVD plus BDU — and caches the result in the OS cache
directory (~/.cache/enodia/cve, %LocalAppData%\enodia\cve). Every later
run reads the cache in under a second.
debian.path is the Debian Security Tracker's JSON export (.json,
.json.gz or .json.zip). It is matched per installed package, not per
release: the debian probe also reads the host's source packages
(dpkg-query) and running kernel (uname -v) in the same SSH round trip.
A package is flagged when Debian has already fixed a CVE in a newer version
than the one installed, so each finding is something apt upgrade (and,
for the kernel, a reboot) would close. CVEs Debian hasn't fixed yet are
left out. The tracker only covers releases its security team still
supports (bookworm, trixie, testing, sid), so older hosts get no package
findings. The file parses in about a second, so it isn't cached.
oval.path is one vendor OVAL file or a directory of them, as published
(.xml or .xml.bz2), one per release in your fleet. ubuntu,
linuxmint, rhel, rocky-linux, almalinux, oracle-linux,
astra-linux and redos targets are matched per installed package the
same way:
| Targets | File |
|---|---|
| Ubuntu, Linux Mint (its Ubuntu base) | https://security-metadata.canonical.com/oval/com.ubuntu.<codename>.usn.oval.xml.bz2 (not the oci. variant) |
| RHEL, and Rocky Linux | https://security.access.redhat.com/data/oval/v2/RHEL<N>/rhel-<N>.oval.xml.bz2 |
| AlmaLinux | https://security.almalinux.org/oval/org.almalinux.alsa-<N>.xml.bz2 |
| Oracle Linux | https://linux.oracle.com/security/oval/com.oracle.elsa-ol<N>.xml.bz2 |
| Astra Linux SE | https://dl.astralinux.ru/astra/oval/<1.7|1.8>_x86-64/oval-definitions-alse-<1.7|1.8>.xml |
| RED OS | https://redos.red-soft.ru/support/secure/<7.3|8.0>/redos.xml |
Rocky's own OVAL file is refused: it is far from complete, so Rocky hosts are matched against Red Hat's. Parsed OVAL is cached like BDU and NVD.
alpine.path is Alpine's secdb, a file or a directory of the main.json
and community.json files of each branch in your fleet (download them
under distinct names, e.g. v3.20-main.json). alpine-linux targets
are matched per origin package.
mariadb.path is MariaDB's own table of fixed CVEs, the Markdown page
above saved as is. It lists, for every CVE, the release that fixes it in
each maintained series, so a mariadb target is judged on its own
series: 10.11.19 is not flagged for a CVE fixed only in 11.4 and up. A
series that had already ended when a CVE was fixed elsewhere is flagged,
with the newer release to move to. It merges with BDU and NVD (which also
cover mariadb): for a CVE MariaDB's table knows, its verdict wins,
because BDU's and NVD's per-series ranges often reach into series the bug
never existed in. CVEs the table doesn't list yet still come from BDU and
NVD.
atlassian.path is Atlassian's vulnerability transparency export, the
JSON that URL returns, saved as is. It lists Jira, Confluence, Bitbucket
and Bamboo releases (Server and Data Center) with the CVEs each is
affected by and the release that fixes each, third-party dependencies
included. A target is judged within its own branch: Jira 10.3.26 is not
flagged for a CVE Atlassian lists only for 10.1 and 11.3. A branch with no
fix listed after an affected release is flagged to its end. It merges with
BDU and NVD like MariaDB's table: for a release Atlassian lists, its
verdict wins on every CVE it tracks; a release newer than the file keeps
BDU's and NVD's findings.
postgresql.path is the PostgreSQL project's security page saved as
HTML, or a directory of such pages. The main page names, for every CVE,
the supported majors it affects and the release that fixes each. A major
no longer supported isn't named there; save its own page
(/support/security/13/) into the same directory to cover it. A major
that had ended before a CVE came out, when the CVE reaches back to the
oldest major still supported then, is flagged with no fix. For a major
the pages name, the project's verdict replaces BDU's, whose PostgreSQL
ranges have no lower bound ("до 18.5") and flag every older major's
latest release.
nginx.path is nginx's security advisories page saved as HTML. Each
advisory lists the vulnerable versions and, per branch, the first fixed
release ("1.31.6+, 1.30.5+"). A stable release with the fix (1.30.5) is no
longer flagged by a range written up to the mainline fix ("до 1.31.0").
Branches that never got the fix stay flagged. Advisories for
nginx/Windows only are skipped.
A Proxmox VE host gets package findings as a second, SSH debian target
alongside its API proxmox one.
Findings show up as a CVES count in check's compact and drift views and
as a per-CVE list in export --format html, with Debian, OVAL and
Alpine findings grouped per package (linux 6.12.107-1 → 6.12.111-1, linked to
the advisory that fixes it, with its CVE list folded).
export --format json carries every finding with its source. Which
products are matched, and why some deliberately aren't, is in
docs/DECISIONS.md D30–D35, D42–D46 and D50.
Both files are found the same way: an explicit path always wins and must
exist (a typo must be an error, never a silent fall-through to some other
file), then a search — first match wins outright, nothing is merged from
several found files. Location beats naming: a match in the current directory
always wins over one in $XDG_CONFIG_HOME, which always wins over one in
/etc/enodia/, regardless of which name matched where.
enodia.yaml (--config <path> or $ENODIA_CONFIG for an exact file):
./enodia.yaml./enodia.yml./config.yaml./config.yml./.enodia.yaml./.enodia.yml./.config.yaml./.config.yml$XDG_CONFIG_HOME/enodia/enodia.yaml(~/.config/enodia/enodia.yamlif$XDG_CONFIG_HOMEis unset)$XDG_CONFIG_HOME/enodia/enodia.yml$XDG_CONFIG_HOME/enodia/config.yaml$XDG_CONFIG_HOME/enodia/config.yml/etc/enodia/enodia.yaml/etc/enodia/enodia.yml/etc/enodia/config.yaml/etc/enodia/config.yml
Finding nothing at all is an error: a config that can't be found is worth failing loudly over, since it usually means the wrong file (or none) is about to be used.
settings.yaml (--settings <path> or $ENODIA_SETTINGS for an exact
file) — same idea, with three differences: it also checks a plain settings.
name (not just enodia.settings.), it also checks the directory containing
the running executable (not just cwd — the actual "next to the binary" case,
which matters most on Windows: install.ps1 puts enodia.exe in
%LOCALAPPDATA%\enodia and adds that to PATH, so cwd is rarely the install
directory), and finding nothing at all is not an error — every field just
falls back to its built-in default, since this file is entirely optional.
enodia.yaml does not get this executable-directory step: it carries
credentials, so it stays out of shared/portable install directories on
purpose.
./enodia.settings.yaml./enodia.settings.yml./settings.yaml./settings.yml./.enodia.settings.yaml./.enodia.settings.yml./.settings.yaml./.settings.yml<directory of the running executable>/settings.yaml<same>/settings.yml$XDG_CONFIG_HOME/enodia/settings.yaml(~/.config/enodia/settings.yamlif$XDG_CONFIG_HOMEis unset)$XDG_CONFIG_HOME/enodia/settings.yml/etc/enodia/settings.yaml/etc/enodia/settings.yml
html.assets: cdn loads Bootstrap and, unless html.theme: none, a
Bootswatch theme of it — both MIT licensed — from jsdelivr or cdnjs at the
moment someone opens the report in a browser. Neither is bundled into this
repository or into any release artifact; every CDN-mode report credits both
by name with a link to their license in its own footer. The default
html.assets: inline mode loads nothing external at all — see "Reporting"
above.
Enodia holds credentials to your infrastructure. Consequences, all deliberate:
- Credentials never appear in the inventory, in exported reports, or in logs.
- HTTPS is tried before HTTP. Credentials are never sent over plain HTTP unless you explicitly opt in per service.
- TLS verification is on by default. Custom CA and certificate pinning are
supported so that
insecure: truestays a last resort — and services checked without verification are flagged in the report. - Secrets live in a separate
credentials.yamlor environment variables, so your service inventory can be committed to git and your secrets cannot.
Found a hole? See SECURITY.md.
Adding a product is one file plus one line in the registry, and a recorded
vendor response in testdata/ so it stays honest. See
CONTRIBUTING.md.
Contributions require signing the CLA — the bot handles it on your first pull request. This exists so the project can be offered under commercial terms alongside the AGPL; you keep the copyright to your work.
Enodia is licensed under AGPL-3.0-or-later. See LICENSE.
If the AGPL does not fit your situation, a commercial licence is available — contact <developer@epicm.org>.
Enodia, "she of the wayside", is an epithet of Hecate: torchbearer, keeper of crossroads. Fitting for something that lights up what is decaying and stands where you choose which way to upgrade.