Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

siglae

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:

  1. 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.
  2. diff - how do two hosts running "the same thing" actually differ?
  3. 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.

The one contract

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.

Usage

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.

Profile format

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.

Directives

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.

classify semantics

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.

diff semantics

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.

The probe trust stance

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:

  • collect refuses any profile containing probe directives unless --allow-exec is given, and the refusal lists every probe.
  • collect --dry-run prints 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.

The masking guarantee

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.

Measured vs merely compiled, per platform

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

Windows verification checklist (manual, real host)

CI cannot spin a Windows guest; before trusting a new build on Windows, run through this on a real host.

  1. siglae collect -p PROFILE with a win-uninstall glob known to match an installed product: count and per-entry name/version/ install_date/publisher agree with Apps & Features.
  2. win-service against a real service: present=true, start_type agrees with services.msc, dependencies listed.
  3. win-service against a nonsense name: present=false.
  4. file against C:\Program Files\... paths with spaces (quoted in the profile): present/type/mtime/readonly correct.
  5. conf-shape / conf-dir / cert against real files: same facts as an equivalent Linux layout.
  6. A probe directive: refused without --allow-exec, listed by --dry-run, executed with --allow-exec, output masked.
  7. Paste a dump into a diff against a Linux dump of the same product: platform variance lands under normalize labels, not drift.
  8. os: os:family=windows, and os:build/os:version/os:arch agree with winver and the registry (ProductName may lag - the build number is authoritative).
  9. hardware: hw:cpus/hw:mem_total_kb/hw:disk_root agree with Task Manager and the system drive's properties.
  10. hardening: sec:firewall/uac/secure_boot/rdp agree with Windows Security, Get-NetFirewallProfile, and Confirm-SecureBootUEFI.

Building

# 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).

Testing

Three layers:

  1. 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).
  2. cargo test --test roundtrip -- --ignored (or scripts/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.
  3. 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.

Dependencies

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 via windows-service, so it adds no new crate - only the two feature modules used, behind the crate's single, encapsulated unsafe blocks.

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.

Non-goals for round one

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.

About

Fingerprint installed server software from observable host facts

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages