Service signatures
Writing and distributing YAML signatures
Edit this pageOn this page
A signature is how Loomscope turns "port 3000 is open" into "this is Grafana". There are two sources — compiled and YAML — producing one internal type, so a custom signature is not a second-class citizen.
Why two sources
Core signatures are compiled into the daemon binary. The set is kept small on purpose: every one of them runs against every discovered port, so they are a performance and a safety surface.
Custom signatures are YAML files loaded at boot and reloaded on SIGHUP.
They exist because no vendor will ever fingerprint your in-house
applications, and rebuilding a scanner binary to identify one is not a
reasonable thing to ask.
Writing one
Drop a file into /etc/loomscope/signatures.d/ — or wherever
LOOMSCOPE_SIGNATURES_DIR points — and send SIGHUP.
version: 1
service:
id: internal-auth-service
name: Internal Auth Service
description: In-house OIDC provider
category: Web
vendor: ACME Corp
icon: https://internal.corp/icons/auth.svg
confidence_default: 85
pattern:
all_of:
- port: { protocol: tcp, number: 8443 }
- endpoint:
port: { protocol: tcp, number: 8443 }
tls: true
path: /healthz
expect: "auth-service v"| Field | Required | Notes |
|---|---|---|
version | Yes | Always 1 today |
service.id | Yes | Stable identifier. Changing it creates a different service |
service.name | Yes | What the UI shows |
service.description | No | One line |
service.category | Yes | See the category list below |
service.vendor | No | |
service.icon | No | An icon set reference or a URL |
service.confidence_default | Yes | 0–100. What a match is worth |
service.pattern | Yes | The matching rule |
Pattern variants
The pattern enum is identical in YAML and in Go, so a custom signature can express exactly what a compiled one can:
| Variant | YAML |
|---|---|
| Port only | port: { protocol: tcp, number: 8443 } |
| HTTP endpoint | endpoint: { port, path, expect, tls?, headers? } |
| Logical AND | all_of: [ … ] |
| Logical OR | any_of: [ … ] |
| Negation | not: … |
| Is a gateway | is_gateway: true |
| MAC vendor | mac_vendor: "Cisco" |
| Subnet type | subnet_type: dmz |
| Manual only | none: true |
none: true defines a service that discovery will never match — useful for a
service you assign by hand but want to appear consistently everywhere.
Default credentials
A signature can record what its product ships with:
service:
id: acme-badge-reader
name: ACME Badge Reader
default_credentials:
- username: admin
password: acme1234
note: The BR-200 ships with this and its web interface does not force a change.
reference: https://example.invalid/br200Nothing tries these. The claim is that the product ships with a documented login — a fact from the vendor's own page, true whether or not the deployment in front of you changed it. It is not a claim that this host accepts it, and Loomscope never attempts one: an authentication attempt against your estate lands in your logs, can lock an account, and on an appliance can lock the appliance. That is a different decision, with its own consent, and this is not it.
So a service page reads "ships with admin/admin — check whether this one was changed", never "accepts admin/admin".
note is required. Without it the interface can only say "default
credentials", which tells an operator to worry and not what to do.
An entry with no username and no password means the product ships with no authentication at all — Redis, for instance. That is a different finding from a known password, and it reads differently.
Three compiled-in signatures declare one today: Grafana (admin/admin),
Zabbix (Admin/zabbix, which unlike Grafana is not forced to change at
first sign-in), and Redis (open by default). The YAML field exists so an
estate full of appliances nobody upstream has written a signature for can
be covered without rebuilding the daemon.
mac_vendor
The daemon resolves the manufacturer from the first bytes of a host's MAC address, using the IEEE assignment registry compiled into the binary. It covers all three registries — MA-L, MA-M and MA-S — and the longest prefix wins, which matters more than it sounds: a quarter of all assignments are sub-allocations inside blocks IEEE holds itself, so a lookup that stopped at the first three bytes would confidently report "IEEE Registration Authority" instead of the manufacturer.
The comparison is a case-insensitive substring. The registry spells
Cisco Cisco Systems, Inc, so mac_vendor: "Cisco" is what you write.
The consequence is worth knowing before you rely on it: mac_vendor: "HP"
also matches Shenzhen HPTECH. Prefer the longest fragment that is
unambiguous.
It never matches when the vendor is unknown, and there are three ways for that to happen:
- The MAC is locally administered — assigned by a hypervisor, a container runtime or a phone randomising its address. There is no manufacturer to report.
- The daemon could not read a MAC at all. Check the daemon's capabilities on the daemons screen; a platform with no neighbour cache reader attributes no MAC addresses.
- The prefix is unassigned, or was assigned after the table in your daemon build was generated. The table is refreshed each release and never fetched at runtime, so an air-gapped install behaves identically.
Examples
A service identified by an endpoint response:
pattern:
endpoint:
port: { protocol: tcp, number: 9090 }
path: /-/healthy
expect: "Prometheus"Either of two ports:
pattern:
any_of:
- port: { protocol: tcp, number: 6379 }
- port: { protocol: tcp, number: 6380 }A port, but not on a gateway:
pattern:
all_of:
- port: { protocol: tcp, number: 53 }
- not: { is_gateway: true }Hardware identified by its MAC vendor:
pattern:
all_of:
- mac_vendor: "Axis Communications"
- port: { protocol: tcp, number: 80 }Categories
Web, Database, Monitoring, Networking, Storage, Security,
Messaging, Virtualization, Directory, Mail, Other.
Categories drive grouping and colour in the topology views, so picking a sensible one is worth thirty seconds.
Validation is strict, and fails at boot
An unknown field or an invalid enum value fails the file with a structured error in the logs. It is not silently ignored.
That is deliberate: a typo'd field in a permissive loader produces a signature that never matches, and nothing anywhere says why. A loud failure at boot is a worse first attempt and a much better tenth.
docker logs loomscope-daemon | grep signatureThe heartbeat reports how many core and custom signatures loaded, and the daemon list shows both counts — so you can confirm a new file took effect without reading logs at all.
Reloading
docker kill --signal=HUP loomscope-daemon
# or
sudo systemctl reload loomscope-daemonA reload does not interrupt a running scan. A file that fails validation leaves the previously loaded set in place rather than dropping it.
Confidence
confidence_default is what a match is worth on a scale of 0–100. Be
honest with it:
| Evidence | Reasonable range |
|---|---|
| Unique endpoint response, distinctive string | 85–95 |
| Endpoint response, generic string | 60–80 |
| Banner match | 50–70 |
| Port number alone | 20–40 |
Confidence propagates into vulnerability matching: an over-confident signature produces over-confident findings, and somebody will act on them.
Distributing signatures
Signatures are files, so distribute them however you distribute
configuration — a config-management role, a mounted ConfigMap, a volume
baked at image build. In Kubernetes, a ConfigMap mounted at
/etc/loomscope/signatures.d and a rolling restart is the usual shape.
Testing
The pattern engine has a table-driven test suite with golden YAML inputs and expected match/no-match outputs, and the same suite runs against both the Go and the YAML paths — which is what keeps the two implementations of the enum from drifting apart.