- Go 51%
- TypeScript 37.2%
- HTML 3.8%
- CSS 3.3%
- Makefile 2.9%
- Other 1.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| cmd/ninho | ||
| design | ||
| docs | ||
| internal | ||
| migrations | ||
| testdata | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .golangci.yml | ||
| .version | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
| sqlc.yaml | ||
ninho
The server that receives, stores, and displays corvo's audit events.
A single Go binary, with the web UI embedded inside it, and a PostgreSQL behind it. Nothing else needs to be installed on the server.
The full documentation is in docs/: a tutorial, how-to guides, reference, and explanation of why it is built this way. What follows here is the short version.
The contract for what arrives here is corvo's design/api-contract.md, and it
drives this server's design. Status codes carry the weight. A 400 returned
because the database is down pulls the batch out of the agent's queue and
drops it into dead-letter, and that audit data doesn't come back.
Infrastructure failure here is always 503.
Build
make build # runs Vite and then go build, producing ./ninho
The result is a single static file to copy onto the server. Building needs Go 1.23 or newer and Node 20 or newer. Running needs none of that.
Run
export NINHO_DSN="postgres://ninho:password@localhost:5432/ninho?sslmode=disable"
export NINHO_INGEST_TOKEN="the same token as in corvo.yaml"
./ninho
Migrations are applied at startup, and the partitions for the current month and the following two are created and rechecked every hour.
On the agent side, corvo.yaml points here:
endpoint:
url: https://ninho.example.com/api/ingest
token_file: /etc/corvo/token
gzip: true
Check it with corvo check, which sends a synthetic probe: it gets a 2xx and
is not persisted.
Configuration
Everything through environment variables.
| variable | default | what it's for |
|---|---|---|
NINHO_DSN |
(required) | The database's owner: applies migrations and creates partitions. |
NINHO_INGEST_DSN |
NINHO_DSN |
Ingest connection. In production, a user with the ninho_ingest role. |
NINHO_READ_DSN |
NINHO_DSN |
Read connection. In production, a user with the ninho_reader role. |
NINHO_ADDR |
:8080 |
Listen address. |
NINHO_INGEST_PATH |
/api/ingest |
Path corvo calls. |
NINHO_INGEST_TOKEN |
empty | The bearer token the agent sends. Empty accepts any agent, and the server warns at startup. |
NINHO_PARTITION_MONTHS |
3 |
How many months of partitions to keep created ahead of time. |
NINHO_RETENTION_MONTHS |
0 |
How many months of data to keep, current month included. Older monthly partitions are dropped. 0 disables retention. |
NINHO_LDAP_URL |
empty | ldaps://dir.example:636 or ldap://dir.example:389. Empty leaves the UI open. |
NINHO_LDAP_BIND_DN |
(required with LDAP) | Service account that looks up the user. The search is never anonymous. |
NINHO_LDAP_BIND_PASSWORD |
(required with LDAP) | Its password. |
NINHO_LDAP_BASE_DN |
empty | Where to search, e.g. ou=people,dc=example,dc=com. |
NINHO_LDAP_FILTER |
(uid=%s) |
%s receives the typed username, escaped. |
NINHO_LDAP_GROUP_DN |
empty | If set, only members of this group can sign in. A posixGroup (memberUid) or a groupOfNames/groupOfUniqueNames (member, uniqueMember). |
NINHO_LDAP_INSECURE |
false |
Accepts ldap:// without StartTLS. Development only: without this, the password travels in the clear. |
NINHO_SESSION_KEY |
random | Key that signs the session cookie. Without it, sessions drop on every restart, and two instances never agree on one. Required, and identical, on every instance behind a load balancer. |
NINHO_SESSION_HOURS |
12 |
Session lifetime. |
NINHO_READ_TOKEN |
empty | Optional bearer token to read the API from a script, bypassing the directory. |
Authentication
There is no user registry in ninho: OpenLDAP decides who gets in. Sign-in does the double bind (the service account looks up the DN, and the following bind uses the typed password), and what stays in the browser is a signed HttpOnly cookie. The service account is mandatory, the lookup never falls back to an anonymous search, and the server refuses to start without it.
With NINHO_LDAP_GROUP_DN set, a third step asks the group object whether it
lists the user, and only members sign in. That search runs as the service
account again, because whoever just signed in is not guaranteed to be allowed
to read the group. The uid compared against memberUid comes from the
directory entry, not from what was typed, so the check keeps working with a
NINHO_LDAP_FILTER that matches on mail or cn.
With no NINHO_LDAP_URL, the UI and the read API stay open. That's the
development mode, and startup logs a warning. In production, configure the
directory or put ninho behind something that authenticates.
Ingest is independent of this: it only checks NINHO_INGEST_TOKEN.
Separation of powers in the database
The migrations create the ninho_ingest role (only INSERT on the trail,
plus the UPDATE the agent upsert needs) and ninho_reader (only
SELECT). They're born NOLOGIN and passwordless, because a password in
versioned SQL is a leaked password. Create the users and grant the roles:
CREATE USER ninho_app LOGIN PASSWORD '...'; GRANT ninho_ingest TO ninho_app;
CREATE USER ninho_web LOGIN PASSWORD '...'; GRANT ninho_reader TO ninho_web;
Then point NINHO_INGEST_DSN and NINHO_READ_DSN at them, leaving
NINHO_DSN as the owner. The trail stays append-only even if the endpoint
token leaks, which is exactly the scenario this separation exists for.
If the database owner can't create roles, the migration warns and moves on: create them by hand with a superuser.
The screens
- Overview: events per hour over the last 24 h, the most frequent
exe,auid, and hosts, and the agents gone silent. - Events: the table, with filters by host,
auid,record_type,result, a time range, and command search. Optional live tail. - Detail: the resolved fields plus the full raw JSON.
- Session: everything a login did, in order.
- Agents: one per host, with the current silence.
Two of those behaviours come straight from the contract.
The command is displayed and searched through process.args, never
through process.title. The kernel truncates title at exactly 128 bytes,
mid-word and with no mark that a cut happened, and the end of a long command
is where the destination and the dangerous flag sit.
Times appear in UTC and in the local zone. @timestamp from two different
hosts is not comparable, because the clocks diverge. Order by the receive
instant instead.
Agent gone silent
A wrong event is on screen, where somebody eventually notices it. An event that stopped arriving looks exactly like a machine where nothing happened, and taking the agent down is the cheapest way for an attacker to go unnoticed. Wire this threshold into monitoring before anything else.
The known limitation is in the contract: corvo only sends a POST when it has an event to deliver, so on an idle machine the silence is legitimate, and the server can't tell the two cases apart. Use a generous threshold and cross-check it with what you know about the machine.
Development
cp .env.example .env # adjust NINHO_DSN and the token, just once
make dev # loads the .env on its own, runs Go and Vite together
make test # the tests. The integration ones spin up a Postgres 18 with podman
make generate # regenerates the database access code with sqlc
make pg-up # starts a disposable Postgres 18, for the development .env
make pg-down # stops that Postgres, data included
make help # the other targets
The SQL is hand-written in internal/store/query/, and sqlc generates the
access code in internal/store/db/. That generated code is checked in, so
whoever compiles the binary doesn't need sqlc installed.
The suite goes after the failures that actually lose data: corvo's golden
batch persisted and read back, the corvo check probe answering 2xx without
writing anything, the database being down returning 503 and not 400, and
resending the same batch not duplicating a row. Plus the gzip bomb rejected at
decompression, timestamps with variable fractional precision, and a thin event
(a lone avc, with empty user and blank session) being accepted.
Limits
Accepts bodies of up to 8 MiB compressed and 32 MiB after decompression. The
ceiling applies to the gzip output, not Content-Length, because the
compressed body's size says nothing about the decompressed one.
No record_type is validated against a list, and no field inside event is
required: the set of types is decided by each host's filter and changes
with a SIGHUP.
Out of scope
No user registry, no multi-tenancy, no cache, no alerting system. TLS is terminated by the reverse proxy in front.