Fingerprint installed server software from observable host facts.
Long-lived server products get installed by generations of installers: rpm one era, tarball another, different trees, different config shapes. A support engineer meeting a host needs three answers:
- classify - which installer generation put this here, and what lineage is it (fresh install vs upgraded-from-legacy)? Including provenance: when the install began life and how it upgraded over time.
- diff - how do two hosts running "the same thing" actually differ?
- collect - gather the facts once, in a form safe to paste into a ticket.
siglae is one static binary per platform. The Linux build targets musl, so glibc versioning never matters; the Windows build is a plain .exe. The tool assumes NOTHING about what is installed on the host - that is the premise.
collect emits sorted KEY=VALUE lines: UTF-8, LF, one fact per line.
classify and diff operate ONLY on those lines and attach no meaning to
any key. All product knowledge enters through a profile; the engine knows
no product.
Flat text is the canonical output: diffable with plain diff, greppable,
and readable in a ticket with no parser.
Every dump is self-describing through a small siglae:* block: version
and os, the profile basename and profile_sha (sha256 of the profile
text) it was collected with, and declared/present - how many directives
the profile asked about versus how many found their target. A dump that looks
full but reports present far below declared was collected against the
wrong host or a mis-pathed profile; the caller decides what ratio is
suspicious. What counts as found is per directive: a file or directory that
exists, a glob that matched, a database that answered, a probe that emitted at
least one fact. The host-describing collectors (os, hardware, hardening)
are always found - the host itself is their target - so present has a floor
above zero rather than starting empty.
siglae collect -p PROFILE [--allow-exec] [--dry-run] [-o FILE]
siglae classify -p PROFILE DUMP
siglae diff DUMP_A DUMP_B [-p PROFILE]
siglae check -p PROFILE
siglae --version
A DUMP of - is read from standard input, so a collect can pipe straight
into a classify without staging a file:
siglae collect -p PROFILE | siglae classify -p PROFILE -.
check parses a profile and reports what it declares without touching the
host; it is the quickest way to validate a profile. collect --dry-run also
validates, and additionally prints every command it would run.
Exit codes:
| command | 0 | 1 | 2 |
|---|---|---|---|
| collect | collected | - | error or refused |
| classify | classified | unknown or ambiguous | error |
| diff | no drift | drift found | error |
| check | valid | - | parse error |
collect is read-only with respect to the host and performs no network
I/O of its own (the db collector talks to a local PostgreSQL through
psql). A missing file, package, tool, or database is a fact, not an
error.
Line-based, hand-editable, diff-friendly. A support engineer who meets a
weird host adds a signature line, not code. # starts a comment. Tokens
with spaces (Windows paths) are double-quoted. Directives may be scoped
with [linux] / [windows] sections; [any] returns to unscoped, and
out-of-scope sections are skipped silently.
A complete fictional example:
# Grommet Server fingerprint profile.
pkg grommetd
file /opt/grommet/bin/grommetd sha256
glob /opt/grommet/plugins/*.so
unit grommetd.service
cert /etc/grommet/tls/server.pem
conf-shape /etc/grommet/grommet.conf legacy ^legacy_
conf-shape /etc/grommet/grommet.conf placeholder CHANGE_ME$
conf-dir /etc/grommet/conf.d *.conf
db grommetdb grommet_schema_history version installed_on
mask *TOKEN*
[windows]
win-uninstall Grommet Server*
win-service grommetd
file "C:\Program Files\Grommet\grommetd.exe"
[linux]
port 8443
user grommet
group grommet
# The escape hatch: gather what no built-in collector can.
probe grommet.build /opt/grommet/bin/grommetctl --build-info
[any]
signature rpm-era/fresh pkg:grommetd:manager=rpm
signature rpm-era/fresh pkg:grommetd:installed=true
signature rpm-era/fresh db:grommetdb:owner~^grommet_app$
signature rpm-era/fresh user:grommet:system=true
signature rpm-era/upgraded-in-place pkg:grommetd:installed=false
signature rpm-era/upgraded-in-place file:/opt/grommet/bin/grommetd:present=true
signature rpm-era/upgraded-in-place db:grommetdb:owner=grommet
normalize era-naming conf:/etc/grommet/grommet.conf:match:legacy
normalize cert-mint cert:*
normalize install-time file:*:mtime
provenance package-installed pkg:grommetd:install_time
provenance first-migration db:grommetdb:history:first_date
provenance latest-migration db:grommetdb:history:latest_date
Ready-to-adapt starter profiles live in examples/: a JVM
application server and a PostgreSQL-backed service, both fully fictional. Copy
one, rename it for your own software, and adjust the paths and signatures.
| directive | facts it produces |
|---|---|
pkg NAME |
pkg:NAME:manager (rpm/dpkg/none), installed, version, install_time |
file PATH [sha256] |
file:PATH:present, type, mode+owner (unix), readonly (windows), mtime; for a regular file also size, and sha256 when the flag is given |
glob PATTERN |
glob:PATTERN:count, names |
unit NAME |
unit:NAME:present, enabled, remain_after_exit; systemd=absent when there is no systemctl |
cert PATH |
cert:PATH:present, not_before, not_after (PEM or DER) |
conf-shape PATH LABEL REGEX |
conf:PATH:present, keys, match:LABEL - the regex runs against normalized key=value entries |
conf-dir PATH GLOB |
confdir:PATH:present, count, names (regular files only) |
db NAME [TABLE VCOL DCOL] |
db:NAME:reachable (true once a query runs, false when it cannot - no psql, timeout, connection or query failure), then present, owner, and from the history table: history:count, first_version, first_date, latest_version, latest_date, span_days. reachable=false never gets confused with present=false (the db is definitively absent) |
db-server ENGINE |
server-level facts, distinct from db NAME: dbserver:engine, reachable, server_version, server_build, encoding, tuning:*, databases, dump_version, installed_versions. ENGINE is postgres (the only one today); keys are engine-neutral |
java |
java:present, version, runtime (the build/distributor line). Cross-platform |
os |
os:id, family, version, pretty, arch. Linux adds kernel, virt, container, timezone; Windows adds build. Reads os-release/uname on Linux, the registry on Windows |
proxy |
proxy:present, kind (apache-httpd/apache-apache2), version, tls, upstream |
jvm [MATCH] |
jvm:present, pid, heap_min, heap_max, gc, flags, system_props (property NAMES only, never values); plus supervisor:kind. MATCH disambiguates a multi-JVM host. Linux-only (reads /proc) |
hardware |
hw:cpus, mem_total_kb, disk_root. nproc/df/meminfo on Linux, Win32 (GlobalMemoryStatusEx, GetDiskFreeSpaceExW) on Windows |
hardening |
Linux: sec:selinux, apparmor, apparmor_profiles, firewall, ntp_synced. Windows: sec:firewall, uac, secure_boot, rdp. sec:firewall is shared across both |
port PORT |
port:PORT:listening; when listening, protocols (tcp/tcp6) and scope (loopback/any/specific). TCP only, read from /proc/net. Linux-only |
user NAME |
user:NAME:present; when present, uid, gid, shell, system (uid below 1000). Read from /etc/passwd. Linux-only |
group NAME |
group:NAME:present; when present, gid. Read from /etc/group. Linux-only |
win-uninstall GLOB |
winuninst:GLOB:count and per match: name, version, install_date, publisher |
win-service NAME |
winsvc:NAME:present, start_type, dependencies |
probe PREFIX CMD... |
probe:PREFIX:exit plus one fact per key=value stdout line; other lines become raw.N. Counts toward present only when it emitted at least one fact - a missing script still runs its interpreter, so the exit code alone proves nothing |
mask GLOB |
adds a masking pattern (see below) |
signature GEN/LINEAGE KEY=VALUE or KEY~REGEX |
one expectation; lines with the same name accumulate |
normalize LABEL KEYGLOB |
diff rule: differences on matching keys are expected variance |
provenance LABEL KEY |
classify prints LABEL: value when the dump has KEY |
The db history contract is generic: the profile names the history table
AND its two columns - one that names the version, one that carries the
applied-at timestamp - so the engine holds no schema knowledge and any
migration tool's table works. For example, a Flyway host would use
db appdb flyway_schema_history version installed_on; a Liquibase host
db appdb databasechangelog id dateexecuted. Tools whose tables carry no
timestamps (single-version-column trackers) cannot support history
reconstruction - the data is not there - but db NAME alone still records
existence and owner. Migration history, where it exists, survives every
upgrade, which makes it the best provenance record an install has.
history:first_version and latest_version are exact only when the boundary
timestamp is unique; when a batch of migrations shares one applied-at instant,
the version reported there is the lexically first/last of the tied rows, which
is not necessarily chronological. The engine holds no version-ordering
knowledge by design, so it cannot do better; the count and the dates are
unaffected. db
connects through psql with its default local authentication (peer or
PG* environment); run collect as a user with database access, or read
the failure fact.
A signature fits when at least one expectation matched, none contradicted, and none were missing. The fit matching the most facts wins (it is more specific); an exact tie is reported as ambiguous. Nothing fitting is reported as unknown with the closest candidate and exactly which facts contradicted it. There is never a bare label: matched, contradicted, and missing facts are always listed.
"Most facts matched" is a heuristic for specificity, not a proof: a signature padded with trivially-true expectations can out-count a genuinely distinct one, so write competing signatures to the same grain. When two truly tie the verdict is loudly ambiguous rather than an arbitrary pick, so a mis-ranking surfaces instead of hiding.
Every difference is drift unless a normalize rule explains it, in which
case it is reported as expected variance under that rule's label. Drift
prints first. Unclassified differences are drift by design - the tool
never quietly explains a difference away.
Run diff without -p and no normalize rules load, so every difference is
drift and the summary reads 0 expected variance - which is indistinguishable
from "rules ran, matched nothing". To keep that honest, diff prints a note
to stderr when no profile is given, and names the profile the dumps were
collected with (from siglae:profile) when they agree on one.
A corollary: file:PATH:mtime differs between any two independently installed
hosts, so a bare diff reports every mtime as drift. mtime is a genuine
fingerprint for classify (install generation), but noise for a host-to-host
diff - absorb it with a normalize install-time file:*:mtime rule when you
want drift to mean something more than "installed at different times". The
same applies to file:*:size for logs or state files that legitimately grow.
probe makes a profile executable code. That is its purpose - it lets a
profile fingerprint software the built-ins never anticipated with no
engine change - and it is also a real risk: running a profile you did not
write means running its commands.
Therefore:
collectrefuses any profile containingprobedirectives unless--allow-execis given, and the refusal lists every probe.collect --dry-runprints every command a profile would run (probes and the built-in subprocess queries) without executing anything.
Read a stranger's profile - or at least its --dry-run output - before
you --allow-exec it. When a probe pattern recurs across profiles,
promote it to a built-in collector; that is the intended evolution path.
Values of facts whose key matches a mask pattern are recorded as
set,len=N, never the value. Defaults (case-insensitive, matched against
the whole key): *PASS*, *PWD*, *SECRET*, *TOKEN*, *CRED*,
*BEARER*, *_KEY, *APIKEY*, *ACCESSKEY*, *PRIVATEKEY*, *PRIVKEY*.
Profiles add more with mask. Masking is applied at the source, inside the
collector sink, so it covers every collector including probe output. A dump
is safe to paste into a ticket.
*_KEY stays underscore-anchored so an ordinary keys count or a keyid
is not mistaken for a secret; the un-delimited spellings (apikey,
accesskey, privatekey) are listed explicitly to close that gap without
swallowing them. Over-masking a non-secret is harmless (its value was never
sensitive); leaking a secret is not, so the set errs toward masking.
Note the mechanism: masking is keyed on fact KEYS. Collectors are designed
so secret material only ever enters a dump under a telling key (probe
output, config keys); conf-shape deliberately records counts, never
values.
Linux collection is measured: unit tests cover every collector against fakes and real temp trees, and the container round trip runs the real binary against three systemd specimens with a real rpm, a real deb, and a real PostgreSQL with seeded migration history.
Windows collection is honest about its status:
| collector | status |
|---|---|
| file / glob / conf-shape / conf-dir / cert / db / db-server / java / proxy / probe | same code on both platforms; measured by the Linux suite (fakes + temp trees), not re-measured on Windows |
| jvm / port | Linux-only (read /proc); measured by the Linux suite against temp /proc trees |
| user / group | Linux-only (read /etc/passwd, /etc/group); measured by the Linux suite; also smoke-tested live against real system accounts |
file sha256 |
same code on both platforms; the SHA-256 is pinned to the NIST test vectors and the file wiring is measured against temp trees |
| win-uninstall / win-service shaping | measured by unit tests with fixture sources, on every platform |
| os / hardware / hardening shaping | measured by unit tests with fixture sources, on every platform |
| registry adapters (winreg: uninstall, os, hardening), SCM adapter (windows-service), Win32 FFI (hardware memory/disk) | compiled and clippy-clean on the msvc target; verified live on a real Windows 11 host |
file readonly attribute |
compiled; verified live on a real Windows 11 host |
CI cannot spin a Windows guest; before trusting a new build on Windows, run through this on a real host.
siglae collect -p PROFILEwith awin-uninstallglob known to match an installed product: count and per-entry name/version/ install_date/publisher agree with Apps & Features.win-serviceagainst a real service:present=true,start_typeagrees with services.msc, dependencies listed.win-serviceagainst a nonsense name:present=false.fileagainstC:\Program Files\...paths with spaces (quoted in the profile): present/type/mtime/readonly correct.conf-shape/conf-dir/certagainst real files: same facts as an equivalent Linux layout.- A
probedirective: refused without--allow-exec, listed by--dry-run, executed with--allow-exec, output masked. - Paste a dump into a diff against a Linux dump of the same product:
platform variance lands under
normalizelabels, not drift. os:os:family=windows, andos:build/os:version/os:archagree with winver and the registry (ProductNamemay lag - the build number is authoritative).hardware:hw:cpus/hw:mem_total_kb/hw:disk_rootagree with Task Manager and the system drive's properties.hardening:sec:firewall/uac/secure_boot/rdpagree with Windows Security,Get-NetFirewallProfile, andConfirm-SecureBootUEFI.
# Linux (static, runs on any x86_64 Linux including air-gapped minimal hosts)
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
# Windows (build on a Windows box or CI with MSVC)
cargo build --release --target x86_64-pc-windows-msvc
The musl binary is fully static (static-pie, ~2.4 MB stripped). The
Windows code compile-checks from Linux with
cargo check --target x86_64-pc-windows-gnu; the Windows adapters are
verified by building the msvc binary and running it against a real host (the
checklist above).
Three layers:
cargo test- profile parsing, masking, classify and diff logic, every collector against canned fixtures and temp trees, and end-to-end CLI tests through the built binary, including the masked-secret assertion (no dump in the suite may contain a planted secret value).cargo test --test roundtrip -- --ignored(orscripts/roundtrip.sh) - the Linux container round trip: builds the musl binary, stands up three fictional-product specimens in systemd-capable containers (rpm-era fresh, rpm-era upgraded-in-place with live PostgreSQL migration history, deb-era modern), collects in each, and asserts classify names each lineage, provenance reconstructs the upgraded install's life, the probe gathers a fact only it can, and diff separates platform variance from planted drift. Requires docker.- Windows: shaping logic is unit-tested everywhere; live adapters follow the manual checklist above.
The full validate gate: cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings, cargo test, cargo deny check, and the
container round trip.
Stdlib first; each crate earns its line:
regex- profile-supplied patterns (conf-shape, signature~) need a real regex engine.glob- filesystem globbing plus glob matching for masks, normalize rules, and display names.x509-parser- pure-Rust PEM/DER parsing for certificate validity; a parsing crate is justified where a crypto stack is not.winreg(Windows only) - typed access to the registry: the uninstall hive, OS identity (CurrentVersion), and hardening posture (firewall/UAC/Secure Boot/RDP).windows-service(Windows only) - the smallest maintained crate that queries SCM service config.windows-sys(Windows only) - raw Win32 for physical memory (GlobalMemoryStatusEx) and disk space (GetDiskFreeSpaceExW), which have no std or registry source. Already in the tree viawindows-service, so it adds no new crate - only the two feature modules used, behind the crate's single, encapsulatedunsafeblocks.
No async runtime, no serde. Errors are values; subprocess, calendar, and
SHA-256 (for file ... sha256) code is dependency-free on purpose - the
hash is a content fingerprint pinned to the NIST vectors, not a crypto
stack, the same call the certificate parser makes.
No preflight/readiness checking (that belongs to a product's own installer
tooling), no remote execution or SSH orchestration, no report server, no
JSON output mode (flat text is the contract; JSON can come later without
breaking it), no macOS. port covers TCP only, and port/user/group
are Linux-only this round - the UDP and Windows (netstat / SAM) paths are
deferred, the same way jvm began Linux-only.