Security policy
Reporting a vulnerability
Please report security issues privately: do not open a public issue. Use the contact page, or the support channel on your purchase receipt, with:
- the affected version and component (the app, the worker, the synthetics runner or a probe agent),
- steps to reproduce,
- the impact you observed.
You'll receive an acknowledgement, and where a fix is warranted, a patched release within the version-1 update window.
Supported versions
Security fixes are published for the current major version (1.x). See the changelog for released versions.
Hardening your deployment
Vigil is self-hosted, so you own the deployment surface. The essentials:
-
Set a strong
BETTER_AUTH_SECRET:openssl rand -base64 32. -
Keep
ALLOW_PRIVATE_MONITOR_TARGETSunset orfalsein production so monitors cannot probe private networks (SSRF protection). - Terminate TLS in front of the app; only its port needs to be public. The database is never exposed to the internet in the shipped setup.
-
Keep
.envout of version control. It already is, via.gitignore.
What ships hardened by default: security headers, non-root container images, RBAC guards on every mutation, HMAC-signed webhooks, the outbound egress policy below, and a database-checked health endpoint.
Outbound requests, the egress policy
Vigil makes outbound requests on your behalf from a growing number
of places: monitor probes, the redirects those probes follow, the
requests an API journey issues, notification-channel deliveries to
twenty-five provider types, webhook delivery, the importer reading
your old monitoring account, runbook steps that call an endpoint,
and recovery triggers. Every one of them goes through one policy
on one of three channels (monitor,
webhook or recovery), so a rule learned
in one is a rule everywhere, and a new caller inherits the posture
rather than inventing one.
The floor, which no setting can lower. Cloud
instance-metadata addresses (169.254.169.254,
169.254.170.2, fd00:ec2::254,
metadata.google.internal), the whole link-local
range, the unspecified address, and reserved space are unreachable
on every channel, whatever else is configured. The check is on the
classified address, not on the text of the URL, so every
encoding of the same address is refused.
Private space, which is yours to decide, per channel.
-
ALLOW_PRIVATE_MONITOR_TARGETS, defaultfalse: whether monitor probes may reach RFC1918/CGNAT/ULA/loopback. -
ALLOW_PRIVATE_WEBHOOK_TARGETS, defaulttrue: the same, for organization webhook and channel delivery. -
ALLOW_PRIVATE_RECOVERY_TARGETS, defaulttrue: the same, for recovery triggers.
The last two default to allow because a self-hosted install
routinely posts to a receiver on its own network, and a recovery
hook on an internal address is the entire point of the feature.
Set them to false on a deployment where those URLs
are not typed by someone you trust.
Every redirect hop is a separate decision.
Redirects are followed by an explicit loop that resolves and
classifies each hop before issuing it; the HTTP client is never
asked to follow one. Webhook and recovery delivery follow none at
all. Credentials (Authorization,
Cookie, X-Vigil-Signature) are dropped
on any hop that crosses origins.
The address that was checked is the address that is
used.
The HTTP-family paths resolve DNS themselves and hand the resolved
address to the connection, keeping the original hostname for the
Host header and the TLS certificate check. There is
only one lookup, and validation is redone from scratch on every
request and every retry.
Approved exceptions are recorded. Whenever policy
permits a request into non-public space, an
egress.exception event is logged with the channel,
hostname, resolved address, its classification, the redirect hop,
and the URL with credentials and query string stripped.
Known residual risk
The non-HTTP check types (tcp,
tls-expiry, smtp, ping,
docker, and the database probes) resolve and classify
the target the same way, but then open their own socket through a
driver that performs its own lookup. A DNS server that changes its
answer between those two moments has a window there. It is small
and it is real, and closing it needs a pinned address threaded
through drivers that do not all expose one. The HTTP-family paths,
webhook delivery and recovery triggers do not have this window.
Automatic recovery, the safety model
Recovery is the one feature that makes an outbound, state-changing request to an address you supply, so it is deliberately the most constrained path in the product:
- Off until you turn it on, per monitor. No recovery request is ever sent unless you saved a recovery action and enabled it. A fresh install never calls out.
- Cloud metadata endpoints are blocked at input and again at execution. A recovery URL that is a metadata or link-local address is rejected when you save it, in any encoding. That is only the early answer: the hostname you saved has a DNS record you do not own forever, so the trigger resolves and classifies it again at the moment it fires, and refuses then too. Unlike monitor targets, recovery endpoints are allowed to be private or internal hosts on purpose.
-
It's your endpoint, and it can verify the caller.
Every trigger is signed (
X-Vigil-Signature, HMAC-SHA-256, same scheme as webhooks), so your receiver can reject anything that isn't Vigil before it acts. A one-file example receiver ships inexamples/recovery-receiver.mjs. - "Verified" means verified-in-time. Before firing, the worker re-probes to confirm the failure is still happening; after firing, it probes again before calling recovery a success. By default those probes run from the single host you deploy Vigil on, so it is verification in time and not across vantage points.
-
Remote probes do not widen this. A monitor
executed by probe agents reaches recovery through the same path
and the same guard: only a verdict of
downopens an incident and starts the chain. Insufficient quorum, an unavailable fleet and a controller timeout are allindeterminate, which opens no incident and therefore fires no trigger. A fleet that went to sleep can never restart your production database. - Recovery triggers still fire from the controller, never from a probe. A probe measures and reports; it holds no recovery configuration and has no code path that can send a signed trigger.
- Bounded and audited. Attempts are capped per incident (1-5) with a cooldown, capped per monitor per day (restart-loop guard), and every attempt is an immutable record with pre-check, delivery, verification and timings. Nothing recovery does is silent.
Remote probe agents
Commercial edition, off unless a monitor is assigned to one. The trust boundary is worth stating plainly, because a probe is a process on a machine Vigil does not control:
- A probe holds one credential, scoped to one probe and one organization, stored as SHA-256 and shown once at enrollment or rotation. There is no endpoint or audit row that can produce it again.
- A probe can do exactly three things: enrol once, ask for its own work, and answer its own jobs. Every lookup is scoped by both the probe id and the organization id.
-
A probe cannot page anyone. The results
endpoint writes rows and nothing else, for the same reason
/api/push/<token>does. The conclusion is drawn by the worker, from rows, on the controller's own clock. - A probe's clock is never trusted. Freshness, expiry and quorum are judged on the controller's clock.
- The agent applies its own egress policy, from its own environment. The controller cannot widen it remotely.
-
The agent holds no database credential. It
overwrites
DATABASE_URLat start-up before anything can read it. - Revocation is immediate and total, clearing both hashes and refusing on a separate flag. The probe's history is kept: deleting the agent to tidy the list would rewrite the evidence for every decision it took part in.
The residual risk, stated: anyone who can read the credential file
on a probe's volume is that probe. Give it a volume the host
protects, and revoke from the Probes page if it is lost. A
compromised probe can report false measurements about the targets
it was assigned, which is what the quorum is for and why the
default is majority rather than any.
Scripted synthetics, and the browser runner
A journey is data, never code. There is no
scripting surface at all: no expression evaluator, no template
with function calls, no regular expressions, and no
page.evaluate. What an operator writes is a list of
typed steps, and the only thing their text can become is a value.
The set of things a journey can be made to do is the set of step
kinds in the language, and it cannot be extended from outside the
repository.
API journeys execute inside the worker through the egress policy
above, so they have the same posture as the http
type. Browser journeys are executed by a
separate runner container, and Vigil closes the
gap the egress policy cannot cover on its own from both ends:
every host a journey may reach is resolved and classified by the
worker before dispatch, and the runner refuses a main-frame
navigation to any host that was not on that list.
The runner is internal, and that is enforced
The runner is not a remote agent and has no enrollment protocol. Unlike a probe agent, which is outbound-only and dials the controller, the worker dials the runner, so it has to be reachable, and what is reachable is a browser that renders whatever it is told to, receiving request bodies that carry the journey's credentials. Vigil therefore refuses unsafe deployments rather than warning about them: on loopback nothing is required and the kernel is the boundary; on a private network a shared token is required; on a public address https and a shared token are required.
The controller checks this before it builds the request body, so a journey's secrets are never transmitted on a connection that would have been refused. The runner itself refuses to start when it listens on anything but loopback with no token set. A container network is not an exemption. Tokens are compared in constant time against a SHA-256 of the presented value.
What a compromised runner has
One journey at a time and the values it was given. No database credential, no session secret, no organization id, no monitor list, and no route that reports what else exists. That is asserted by a test that walks the runner's real import graph, not merely claimed here.
Evidence, and what is deliberately not stored
Run evidence never contains a request header value, a cookie value, or an extracted value, only their names. A URL that a secret was interpolated into is stored without its query string. Every string that does reach evidence is scrubbed against the run's secret values in raw, percent-encoded, form-encoded, JSON-escaped and base64 forms, and the scrubber re-checks its own output and withholds anything it could not clean. Screenshots mask password inputs and every field a secret was typed into. Journeys are never dispatched to remote probes, because a run is recorded to a database a probe agent holds no credential for.
Reducing the trust surface
-
GITHUB_TOKENand any recovery/webhook receiver you build should have the narrowest scope that works. -
Grant the
viewerrole freely. It is read-only and never sees signing secrets or mutation controls.