Overview

How Voltro handles security — reporting a vulnerability, the supply-chain gates (dependency audit, inbound-license allowlist, SBOM), supported versions, and why self-hosting keeps your data yours.

Security is a first-class concern, not an afterthought. This page is the public summary of how we handle it; the machine-readable policy also ships as a SECURITY.md inside every @voltro/* package.

Reporting a vulnerability

Please report suspected vulnerabilities privately — never in public issues or channels.

  • Email support@voltro.dev with the subject SECURITY.
  • Include the affected package(s) and version(s), the impact, and a minimal reproduction if you have one.

You'll get an acknowledgement within 3 business days, an assessment once we've reproduced the issue, and a coordinated disclosure timeline agreed with you — with credit in the release notes if you'd like it. We don't run a paid bug-bounty program yet, but we genuinely value responsible disclosure.

Procedures are default-DENY

Every wire-exposed procedure must declare who may call it. A *.query.ts / *.mutation.ts / *.action.ts / *.stream.ts that declares neither guards: nor openAccess: is refused at boot — by voltro dev, by voltro serve, and by voltro doctor as a preflight.

Before this, guards: defaulted to "allowed": a discovered procedure with no guard was callable by any authenticated session. Nothing in the code said so, and the only structural check was a report a human had to run.

guards: [{ scope: 'invoices:read' }]          // the caller must hold a scope
openAccess: 'public pricing, no caller data'  // anyone may call it — and why
internal: true                                // not on the wire at all

openAccess takes a reason rather than a boolean on purpose: without an explicit way to declare an endpoint open, the only way to satisfy the gate is to invent a guard, and the guard people invent is one every caller already holds. That reads as protection and enforces nothing.

The whole app can opt out with one field — security: { defaultDeny: false } in app.config.ts. There is no environment variable for it: the only direction anyone reaches for is off, and an env var is how that becomes permanent in one CI job with no diff to review. Full detail — including how plugin routes are covered per-request in the dispatch spine rather than by the boot gate — in Authorization.

A decode failure on a guarded procedure is NOT a guard verdict

The payload is decoded before the handler runs, so a guard on a procedure with a malformed payload never gets the chance to refuse. Calling one with an incomplete payload therefore answers with a decode error, not a ScopeError — and reading that as "the guard is not applied" is the wrong conclusion in the dangerous direction.

That conclusion is easy to reach while auditing a guard, and flipped back the moment they sent a complete payload. So a guarded procedure says it in the failure:

workAreas.create input (guarded — the guard did NOT run: the payload failed to
decode first, so this says nothing about access)
└─ ["type"] └─ is missing

Measure a guard with a VALID payload. The ordering itself is not fixable at that seam — the request never reaches the handler or the auth middleware, so "evaluate guards first" means replacing the protocol layer rather than annotating a schema. An openAccess: declaration is not an enforced guard and gets no such sentence.

Outbound HTTP is SSRF-guarded by default

The HttpClient your handlers yield* refuses internal targets:

  • loopback, RFC-1918, CGNAT and link-local addresses — including the 169.254.169.254 cloud-metadata endpoint
  • the hostnames localhost, *.internal, *.local
  • any non-http(s) scheme

Every redirect hop is revalidated, not just the URL you passed. A public URL that 302s to the metadata endpoint is the actual attack; checking only the initial target catches none of it.

This matters because a caller-supplied URL is ordinary product surface — a scraper, a webhook-registration form, an importer, a "test this connection" button. Those reach HttpClient with whatever the user typed.

Handling a refusal

A blocked request fails on the error channel, as an HttpClientError.RequestError whose cause is the SsrfBlockedError. So the handler that supplied the URL can decide what it means:

yield* enrich(userSuppliedUrl).pipe(
  Effect.catchTag('RequestError', (e) =>
    e.cause instanceof SsrfBlockedError
      ? Effect.succeed(none)          // an optional enrichment: skip it
      : Effect.fail(e)),              // a real transport failure: keep failing
)

Two things worth knowing if you are upgrading from a version before this was a failure:

  • It used to be a defect (Effect.die), so code that handled it did so with Effect.catchAllDefect. That arm no longer fires. If you also have an Effect.mapError above it, the rejection now reaches mapError FIRST — and a delivery that classified an SSRF block as terminal in the defect arm will be reclassified as whatever mapError returns. A deployment hit exactly this: a permanent condition became retryable and burned the full backoff budget re-attempting a request that can never succeed. Nothing in the type system can see that, because the error channel already carried HttpClientError.
  • description names the policy (SSRF policy: blocked private/reserved address …), which is what you want in a log line; the cause is what you want in a branch.

Allowing a target on purpose

// app.config.ts
export default defineApiConfig({
  http: { allowHosts: ['*.svc.cluster.local', 'billing.internal'] },
})

An entry may be an exact host, a *.suffix wildcard (which does not match the apex — one that did would silently widen your exception), or host:port when only one port should be reachable.

There is no boolean off-switch, deliberately: "we call one internal service" and "we do not check URLs" are different postures, and a boolean cannot tell them apart six months later.

In tests, allow the stub — don't mock the guard

Use the same allowHosts to reach a local stub server (['127.0.0.1:8787']). That keeps the test on the real guarded code path with a narrow exception. Mocking the guard away instead means the production path is never exercised — which is exactly how this framework's own webhook delivery once ended up behind a NODE_ENV check.

What this does NOT cover

DNS is not resolved. A public hostname that resolves to a private address (DNS rebinding) still passes. That vector needs network-layer egress control; it is stated here rather than silently implied.

Where the transport settings live

Everything in the next three sections — the origin check, which proxies may forward a client IP, and the response headers — is one security: block in app.config.ts, read identically by voltro dev and voltro serve:

// app.config.ts
export default {
  security: {
    // Cross-site protection for every state-changing request. Default 'same-origin'.
    originGuard: 'same-origin',
    // Extra origins to accept — a split web/api deployment.
    allowedOrigins: ['https://app.example.com'],
    // Whose x-forwarded-for to believe. Empty (the default) ignores the header.
    trustedProxies: ['private'],
    // Security response headers. On by default; 'strict' applies the api policy to HTML too.
    headers: { mode: 'default' },
  },
}

Every field has a safe default, so an app that writes none of this is already protected. Each also has an environment override, listed with its section below — use those to correct a deployment you cannot rebuild, and the config file for everything else. A security decision that exists only as an exported variable on one machine is invisible to code review.

Cross-site requests are refused

Every request that can change state is checked — anything but GET, HEAD and OPTIONS — plus every WebSocket upgrade, which is a GET: the rpc socket's /ws AND every raw gateway path a *.ws.ts file mounts. That is deliberately a rule about the METHOD rather than a list of paths, because a mutation reaches your api by more than one road: POST /rpc, every REST route projected from a publicApi: mutation, everything in apiConfig.restRoutes, and POST /v1/api-keys. They all resolve the caller through the same auth chain, so they are all reachable with the session cookie automatically attached.

A browser request is accepted when its Origin matches the Host it was addressed to, or is in your allowlist. Anything else gets 403 origin not allowed — including Origin: null, which is what a sandboxed iframe sends, and a request with no Origin whose Sec-Fetch-Site says cross-site.

Before this, framework-wide CSRF protection rested entirely on the SameSite=Lax cookie default — one caller-overridable attribute that still permits top-level-navigation POST, and that does nothing at all for a bearer/JWT flow.

Reads are not checked: a GET cannot be a cross-site write, and checking it would break every link into your api.

Raw WebSocket gateways are guarded before the upgrade

A defineWebSocket gateway mounts its own upgrade path beside the rpc socket, and the listener treats it exactly like the rpc upgrade:

  • Origin-checked BEFORE the upgrade. An upgrade is a GET, so the state-changing rule above never covers it — every gateway path therefore joins the upgrade origin guard's set, and a cross-origin upgrade is 403. That closes cross-site WebSocket hijacking for every gateway, not only the framework's own socket.
  • Authenticated BEFORE the upgrade. auth is required with no default: 'subject' resolves the caller through the SAME auth chain as rpc/SSR and answers 401 while the request is still plain http — no socket ever exists for an unauthenticated caller — and the connection is bound to the credential's expiry (application close code 4001, so a foreign client can re-auth and reconnect). 'public' is a decision somebody wrote down, not a default anyone fell into.

The web listener's /form/* endpoint is checked the same way

<AutoForm>'s no-JavaScript fallback POSTs to /form/<mutationTag> on the web listener — mounted on both boot paths, voltro dev and voltro start. That is a state-changing surface too, and it is classified by the same classifyRequestOrigin as the api rpc listener: a cross-origin form POST gets the same 403.

Behind the check, the endpoint forwards the request server-side to the api as POST /rpc — auth middleware, guards and rpc interceptors run identically to the normal rpc path, with the session resolved from the cookie exactly as an SSR render resolves it.

One boundary, stated explicitly: @voltro/plugin-ratelimit's pre-auth IP shield runs only on the api listener and never sees /form/*. The ratelimit rpc interceptor does see every write, because the endpoint reaches the api as POST /rpc like any other mutation.

A split web/api deployment must declare its origins

If your web app is served from a different origin than your api, say so — or every mutation, every REST write and every socket from that page will 403:

// app.config.ts
export default {
  security: {
    allowedOrigins: ['https://app.example.com', 'https://admin.example.com'],
  },
}

Same-origin apps (the default layout, and anything behind one ingress) need no change. security: { originGuard: 'off' } disables the check; a misspelled value falls back to enforcing, never to off.

Environment overrides, for a deployment you cannot rebuild: VOLTRO_ALLOWED_ORIGINS (comma-separated) and VOLTRO_ORIGIN_GUARD=off. If you embed the runtime yourself, the same values are RpcServerOptions.security.originGuard / .allowedOrigins.

Server-to-server callers are exempt, and that is deliberate

A request carrying neither Origin nor Sec-Fetch-Site did not come from a browsing context, so it is allowed. That covers:

  • SSR loadersvoltro dev / voltro start render pages server-side and their loaders call the api from node, which attaches no origin header. Without this exemption, server-side first paint would break on every page with a loader.
  • mobile SDKs, curl, and any other service calling your api.

A CSRF attack needs the victim's ambient credentials, and only a browser attaches those — a browser cannot be made to omit Origin on a cross-origin POST or a WebSocket handshake. An attacker's own server can POST without one, but it has no session to ride: that is simply an unauthenticated request, and auth and guards still apply to it.

Origins are compared by authority (host + port), not scheme. Behind a TLS-terminating ingress your app sees plain http while the browser reports https://…, and there is no unforgeable way to learn the external scheme.

Local development is not a special case you have to configure

When both the origin and the Host are loopback (localhost, 127.0.0.1, *.localhost), the request is accepted. That is the voltro dev layout: the web dev server proxies the api with changeOrigin: true, so the api sees Host: localhost:4000 while the browser correctly reports Origin: http://localhost:5190. Strictly compared those differ, and every dev session would lose its websocket.

Both sides must be loopback. A production api on api.example.com still refuses Origin: http://localhost:5190 — a browser cannot be made to claim a loopback origin it is not on.

Testing a dev server from a phone on your wifi is the one case that needs a line. The page is then http://192.168.1.5:5190, which is not loopback, so the socket is refused and the api logs refused cross-site request naming the origin. Add it while you test:

// app.config.ts
export default {
  security: {
    allowedOrigins: ['http://192.168.1.5:5190'],
  },
}

The carve-out is deliberately not widened to "both sides are private addresses": on a self-hosted internal deployment that would accept any other host on the same network, which is a different claim than "the attacker already runs code on this machine".

Routes that a third party legitimately POSTs to

A few surfaces are outside the check, because their caller cannot be a browser riding your user's cookie:

  • The inspect surface (/_voltro/inspect/*) — token-gated, and meant to be read cross-origin by the local and cloud dashboards.
  • Incoming webhooks (*.webhook.tsx) — authenticated by the sender's signature, which the framework refuses to boot without.
  • Plugin routes that declare it. First-party examples: @voltro/plugin-sso-saml (/saml — the IdP makes the browser form-POST a signed assertion, which IS a cross-site POST), @voltro/plugin-storage's upload routes (a signed upload ticket, plus their own CORS allowlist), @voltro/plugin-billing's webhook and @voltro/plugin-scim (bearer-only).

If you write a plugin HTTP route in that category, declare it:

import type { PluginHttpRoute } from '@voltro/protocol'

const route: PluginHttpRoute = {
  method: 'POST',
  path: '/partner/callback',
  // The signature on the body is the authority — not the session cookie.
  originGuard: 'exempt',
  handle: async (req) => {
    if (!verifySignature(req)) return { status: 401 }
    return { status: 204 }
  },
}

Apply exactly one test before you write that line: if an attacker's page makes a browser send this request with your user's cookies attached, does anything happen? If the answer is "no — it still needs a signature, a bearer token or a signed ticket the attacker does not have", the route is exempt. Otherwise it is not. The exemption covers the route's whole path prefix, not the sub-paths its handler branches on.

Shared (isr) renders never see credentials

An isr page's HTML is cached and served to every visitor inside its revalidate window — a shared artefact. The framework therefore strips credential material before the render runs: the cookie jar (except voltro:locale), authorization, and every x-voltro-* header never reach an isr page's loaders, ctx.query, or useServerRequest(). A subject-reading loader gets the anonymous answer instead of caching one user's data for everyone, and the strip applies identically under voltro dev and voltro start. Details: Render modes → isr renders are anonymous.

The client IP comes from the socket, not from a header

x-forwarded-for is a request header, so any client can write it. Voltro ignores it unless you declare which proxies are allowed to forward — the address used for rate limiting, geo-blocking and audit records is socket.remoteAddress, the one value nobody upstream of the kernel can forge.

If you run behind a load balancer, ingress or CDN and rate-limit or geo-block per IP, declare it:

// app.config.ts
export default {
  security: {
    trustedProxies: ['private'],        // RFC1918 + CGNAT + link-local + unique-local
    // ['loopback']                     — local / docker-compose
    // ['10.0.0.0/8', 'fc00::/7']       — explicit CIDRs
    // ['2']                            — trust two hops
    // ['*']                            — any peer; only when your ingress
    //                                    OVERWRITES rather than appends
  },
}

The environment override is VOLTRO_TRUSTED_PROXIES, comma-separated (VOLTRO_TRUSTED_PROXIES=10.0.0.0/8,fc00::/7).

With a list configured, the peer that opened the connection must itself be trusted, and the chain is walked right-to-left past every declared proxy — so a client that prepends a fake hop cannot push the resolved address further left. The same setting decides whether x-forwarded-proto is believed, which is what gates HSTS below.

Without it, a per-IP limiter still works; it just counts every request against your proxy's address instead of the caller's. Embedders pass the same list as RpcServerOptions.security.trustedProxies.

The same address reaches your plugin routes

A plugin HTTP route receives it as req.remoteAddr — already resolved through the policy above. Use that, never req.headers['x-forwarded-for']. The built-in auth routes do: what @voltro/plugin-auth and @voltro/plugin-auth-social write into sessions.ipAddress is the resolved address, so the column a breach investigation reads cannot be chosen by the caller.

import type { PluginHttpRoute } from '@voltro/protocol'

const route: PluginHttpRoute = {
  method: 'POST',
  path: '/partner/callback',
  handle: async (req) => {
    // Resolved once per request; `undefined` only when there is no socket
    // address (a unix socket, or a hand-built request in a test).
    await audit(req.remoteAddr ?? null)
    return { status: 204 }
  },
}

Security headers ship by default

Every response from the api listener carries:

content-security-policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
x-frame-options: DENY
referrer-policy: no-referrer
x-content-type-options: nosniff
strict-transport-security: max-age=15552000; includeSubDomains   (https only)

default-src 'none' is safe here because this listener serves the api — rpc JSON, the socket upgrade, inspect routes, webhooks, plugin routes — not your web app's HTML, which is a different listener. A text/html response from a plugin route gets a relaxed policy instead (frame-ancestors 'none'; base-uri 'none'; object-src 'none'), so a docs page still renders while clickjacking and base-tag injection stay closed.

HSTS is sent only over https and deliberately never carries preload: preload is effectively irreversible for a domain, so it has to be your decision. Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy are not defaulted either — guessing them breaks a legitimate cross-origin dashboard — but you can add them.

A route that sets a header itself always wins; the framework only fills gaps.

The whole policy is one field, including an extra bag for headers the framework does not default:

// app.config.ts
export default {
  security: {
    headers: {
      mode: 'default',                              // 'off' | 'default' | 'strict'
      hsts: 'max-age=31536000; includeSubDomains',  // or false to drop it
      extra: { 'permissions-policy': 'geolocation=()' },
    },
  },
}

mode: 'strict' applies the api policy to HTML too. That is the documented trade: it blanks @voltro/plugin-openapi's /docs page, which loads its viewer from a CDN. Choose it when this listener serves no HTML you own.

Environment overrides: VOLTRO_SECURITY_HEADERS (off|default|strict), VOLTRO_CSP, VOLTRO_CSP_HTML, VOLTRO_HSTS — each accepting off to drop just that one. Embedders pass the same object as RpcServerOptions.security.headers.

A nonce-based CSP for the web app's HTML

The headers above are the api listener's. For the web app's rendered HTML, middleware.ts can mint a per-request cspNonce: the framework stamps nonce="…" onto every script tag of that render — the state script, the deferred registry, the shell's bundle tags, and React's own bootstrap/settle scripts — while the policy header itself stays the middleware's to set via responseHeaders, carrying the same nonce. An isr page combined with cspNonce refuses the render: a cached nonce is a lie the browser enforces. Mechanics + examples: Middleware → cspNonce.

Incoming webhooks must verify their caller

An incoming webhook is a public POST that runs your application code. A defineIncomingWebhook({...}) that declares no signature scheme now refuses to boot, naming the endpoint.

export default defineIncomingWebhook({
  id: 'orders.paid',
  provider: stripeWebhookProvider(),   // HMAC scheme + replay window + idempotency
  payload: OrderPaid,
  handler: async (ctx) => { /* the caller is already verified here */ },
})

Four ways to satisfy it:

Declaration Means
provider: stripeWebhookProvider() a preset brings the scheme, replay window and idempotency key
signature: { _tag: 'hmac', … } a hand-declared scheme for a sender with its own convention
verification: 'provider' your handler verifies with the provider's own SDK
verification: 'none' deliberately public — a gateway or IP allow-list owns the boundary; logged as a warning on every boot

A signature-verified webhook reads its shared secret from an env var named after its id:

VOLTRO_WEBHOOK_SECRET_ORDERS_PAID=<the value the sender holds>

A missing secret is now a 503, not a skipped check. It used to run the handler unverified, so forgetting one variable silently converted a verified webhook into an open one — in production, with nothing in the code to review. The framework never invents this value: the sender holds the other half, so a generated secret would authenticate nobody.

voltro doctor tells you before a deploy does

The preflight reports the same verdict the boot reaches:

incoming webhook verification
  ✓ signature / provider verified            2
  ! deliberately public (none)               1
      internal.sync — `verification: 'none'`; a gateway / IP allow-list owns this URL
  ✗ nothing verifies the caller              0

An endpoint in the last row exits non-zero — that app does not start. The middle row never fails; it is there so a deliberately public URL is visible in a review instead of only in a boot log. Both rows are in voltro doctor --json under webhookVerification.

The body cap applies EVERYWHERE — and answers 413 either way

The 8-MiB body cap covers every surface that reads a request body: POST /rpc (as it always did, VOLTRO_MAX_RPC_BODY_BYTES), plugin HTTP routes, REST routes and incoming webhooks — whose read is also binary-clean now (it used to round-trip through UTF-8, corrupting binary payloads). The non-rpc cap is http.maxBodyBytes in app.config.ts (env VOLTRO_MAX_BODY_BYTES), with per-route overrides on defineRestRoute({ maxBodyBytes }) and on a webhook handler's maxBodyBytes — fat provider payloads are the normal case there. Routes sharing a path share one body read, so the widest override in the group applies to the group.

The limit is enforced while the body streams, so a Transfer-Encoding: chunked request with no Content-Length is cut off at the cap rather than buffered without bound. A declared oversize length is still rejected up front, so an honest client gets its 413 without uploading anything.

Both shapes end in the same response:

Request Response
Content-Length over the cap 413 Payload Too Large, before the upload
Transfer-Encoding: chunked over the cap 413 Payload Too Large, once the counter crosses the cap
Either, under the cap handled normally

The refusal is logged server-side under the voltro:security scope, with the cap and the number of bytes read before the server stopped (never the body's real size — it is not read).

Uploads ride separate plugin routes with their own limits.maxBytes, and are unaffected.

The global HTTP interceptor is fail-closed

The pre-auth plugin hook (onHttpRequest) wraps every request, which makes it a place people put security gates — rate limits, geo-blocks, bot detection. A gate that crashes must not become an open door, so a throwing interceptor is a 500 plus a log line; the request does not continue. It used to: the failure was swallowed and the request flowed on, silently, which is the worst possible failure mode for exactly the code this hook attracts.

An interceptor that wants to tolerate its OWN outage — protection with a dependency, like a rate-limit counter in Redis — catches internally and degrades loudly. @voltro/plugin-ratelimit's httpShield does exactly that: a Redis outage means unlimited-with-a-warning, never a self-inflicted API outage. The full authoring guidance is in the plugin contract.

Response compression — and where BREACH sits

Buffered responses are compression-negotiated on both boot paths: brotli preferred, gzip as the fallback, a client's q=0 respected. Only an allowlist of compressible types is touched (text/*, application/json, application/javascript, application/xml, image/svg+xml), nothing under 1 KiB is compressed, and Vary: Accept-Encoding is set on every compressible type — including responses that go out uncompressed, so caches never mix encodings. It covers the api's plugin/REST/webhook responses AND the HTML voltro start serves. Streams and SSE are never compressed. Config:

// app.config.ts
export default {
  http: { compression: { enabled: true, minBytes: 1024 } },
}

The BREACH position is structural, not a tuning knob: POST /rpc responses are NEVER compressed — that is where session-authenticated reads flow, the exact combination (secret + attacker-reflected input + compression

  • observable length) the attack needs. The residual risk is an authenticated REST route that carries a secret next to reflected caller input in one response; an app with such a route sets http.compression.enabled: false.

One cache note: an isr page's cache holds ONE uncompressed entry and compresses per hit (brotli q4, single-digit milliseconds) — caching a variant per encoding would have multiplied the memory for no measurable win.

Logs are redacted by default

Every logger surface — createLogger, makeLogger, LoggerLayer — installs a redactor with no configuration, and it runs before formatting and before the sink fan-out, so a masked value reaches neither stdout nor any downstream sink (the CLI buffer, logship, Datadog).

import { createLogger } from '@voltro/logger'

const log = createLogger({ scope: 'orders' })
log.info('request', { headers: req.headers, body: input })
// → headers.authorization = [redacted]
//   headers.cookie        = [redacted]
//   body.newPassword      = [redacted]
//   headers['user-agent'] = curl/8      ← untouched

Three rules, all on:

  • Key names (DEFAULT_REDACT_KEYS) — matched exactly on a normalised key, so apiKey, api_key, API-KEY and Api Key are one key. Credentials, bearer/API tokens, authorization / cookie / set-cookie / x-api-key, session and second-factor values, and the payment / government identifiers (creditCard, cvv, iban, ssn, …) that must never reach a log index.
  • Key fragments (DEFAULT_REDACT_KEY_PARTS) — substring matches for the compound names real code writes: newPassword, oldPassword, stripeApiKey, userAccessToken.
  • Value shapes — a credential is masked regardless of its key: Bearer …, Basic …, a JWT, sk_ / pk_ / whsec_-prefixed keys, GitHub / Slack / AWS key ids, a PEM private key.

There is deliberately no entropy heuristic. A trace id, a content hash, a git sha and a base64 thumbnail are all long and high-entropy — a redactor that eats the fields an incident is read through gets switched off wholesale, which costs more than the gap it closed.

Adding to the list, and why nothing subtracts

redactKeys adds; it does not replace:

createLogger({ redactKeys: ['patientId', 'policyNumber'] })

There is no way to remove a built-in key. The only reason to un-mask password is to read it while debugging, and the answer to that is to log a non-secret projection — not to publish the value to stdout and to whatever log shipper the deployment happens to have. A subtraction knob would also apply to every dependency logging under that key, including plugins you never read, which is a blast radius an app cannot assess.

The deliberate escape hatch is redact, a transform you write and own — and it runs after the built-in redactor, so it can mask more and structurally cannot unmask:

createLogger({ redact: (record) => ({ ...record, fields: { ...record.fields, region: 'eu' } }) })

If a log line you relied on went quiet, rename the field rather than reaching for an exemption. traceId, requestId and cookieName are all untouched — the framework's own session diagnostic was renamed from cookie to cookieName for exactly this reason.

Auditing what your log tables actually hold

voltro db scan-credentials
voltro db scan-credentials --table my_events:actor

Counts rows in which a credential-shaped name appears anywhere in the stored value — token, secret, password, apikey, credential, privatekey — plus any table you name with --table <name>[:<column>]. Exit code 1 on an unexplained hit, so CI can gate on it.

By default it scans every column that can physically HOLD one:

Table Columns
_voltro_audit_log subject, actor, scope, metadata, input, outcome, subjectId
_voltro_row_history data, actor, scope, subjectId

input and outcome are a procedure's arguments and result — the likeliest place of all for a token to arrive by accident, since nobody puts a credential on a subject on purpose and plenty of code passes one as an argument. _voltro_row_history.data is a full-row snapshot, so a credential column on any of your tables is in there, and it outlives deleting the source row.

Every hit line names the needles that matched, with the number of rows each appears in:

✗  _voltro_audit_log.outcome — 69 of 149 row(s) contain a credential-shaped NAME (anywhere in the value)
       matched (rows per needle, may overlap): token (61), secret (12)
       examined 69 of 69 matched row(s): 61 as a JSON KEY, 8 only inside a redaction marker

Those counts overlap and do not sum to the hit count — a row holding both a token and a secret is counted by both needles. Without the breakdown the advice underneath (purge them AND rotate the credentials) is neither executable — purge what, rotate which — nor refutable: a column mentioning the word token in prose reads identically to one holding a live one.

Hits are explained, not just counted

The predicate is a substring match over the whole serialized column, so a match is a match on a name, wherever it sits. The third line above is a bounded second pass that reads the matched rows back and says what actually matched — a JSON key, or only a redaction marker. Values are never printed and never logged.

A redaction marker is the framework's own record that a credential was deliberately not stored: _omitted (@voltro/plugin-row-history, the column names left out of a row snapshot) and __redacted (@voltro/plugin-audit). A target whose every matched row is one of those is reported as explained, and does not fail CI:

~  _voltro_row_history.data — 69 of 149 row(s) contain a credential-shaped NAME (anywhere in the value)
       matched (rows per needle, may overlap): token (69)
       examined 69 of 69 matched row(s): 0 as a JSON KEY, 69 only inside a redaction marker
       EXPLAINED — every match is `_omitted` / `__redacted`, the framework's own
       record that a `.sensitive()` column was left OUT. Nothing to purge, nothing to rotate.

The bar for that is deliberately high: every matched row examined, every one of them a marker and nothing else. A target where the read-back was capped (at 500 rows), or where one row is a real key, or where one row is not parseable as JSON, stays a finding and still exits 1. A partially-explained target is still a target.

Why this exists. A team with .serverOnly().sensitive('secret') columns and correctly-redacting plugins got 69 rows flagged, with purge them AND rotate the credentials underneath, against the framework's own proof that nothing was stored. The shape mattered more than the one key name: the more columns an app classifies correctly, the more markers it writes, and the redder the scan turns. A false-positive rate that rises with the care you take gets the tool muted — and a muted scan is worth less than none, because its silence still reads as evidence.

If you ran this on 0.30.0, 0.30.1 or 0.30.2, run it again. Those releases scanned subjectId on both tables — a flat opaque id that cannot hold a credential — and none of the blob columns above. The command ran cleanly, printed a scanned count beside a hit count, and exited 0 having never looked where credentials are. It came from fixing a crash: the default column had been the literal subject for every table, _voltro_row_history has no such column, and the fix replaced the name on both tables instead of the one that was wrong. A clean answer from a scan that looked in the wrong place is worse than the crash it replaced. voltro update prints this as a manual step on the way to 0.31.0.

It is a command and not a documented query on purpose. The same check once shipped as SQL you were asked to run yourself, in its postgres spelling (subject::text ILIKE '%token%'). On MySQL/MariaDB the natural translation is a bare LIKE — and against the utf8mb4_bin collation the migrator emits for a json() column, LIKE is case-sensitive. So '%token%' does not match jiraToken, and almost every JSON key that carries a credential is camelCase. A team ran the translated query over 141 rows, got 0, and nearly reported themselves clean; 117 of those rows held a working credential. Every dialect now casts to its own text type before lowering, in code you do not have to translate.

A 0 here never means two things. Each line prints the number of rows scanned beside the number of hits. An empty table says so in words rather than reading as clean, a missing table reports as missing rather than as zero, and a run that examined nothing exits 2.

Run it on every environment. A development database is not a sample of production.

If it finds something: purge the rows and rotate the credentials — assume anything written to a log table has been read — then move the credential off the Subject entirely with connectionCredentials(...), which keeps it in the framework vault.

If the finding is a credential column of your own sitting in plaintext, .encrypted() only protects rows written after you add it — voltro db encrypt-column converts the ones already there.

Supply-chain assurance

Every release passes automated supply-chain gates in CI before a single package is published:

  • Dependency audit. The production dependency graph is audited on every build (pnpm audit --prod); a HIGH or CRITICAL advisory fails the release. Known-but-unfixed-upstream transitives are pinned deliberately, not waved through.
  • Inbound-license allowlist. Every third-party runtime dependency's license is checked against an allowlist — a disallowed license fails the release. The aggregated attributions ship as THIRD-PARTY-NOTICES.md in each package.
  • SBOM. A CycloneDX Software Bill of Materials of the third-party runtime graph is generated for, and attached to, each release. If your procurement or security team needs it, request it at support@voltro.dev.
  • Release integrity. Publishing is gated behind organization 2FA and a narrowly-scoped, short-lived automation token, and is all-or-nothing (two-phase) so consumers never see a half-published release.

Supported versions

Voltro is pre-1.0 and ships in lockstep — all @voltro/* packages share one version. Only the latest published release receives security fixes. Pin exact versions in production and upgrade promptly.

Your data stays yours

Voltro is self-hosted: it runs next to your database, with your connection string, your backups, and your access controls. When you self-host, the framework never proxies your data through Voltro infrastructure — there is no phone-home and no hosted control plane in the request path; the runtime is a library that lives in your own deployment.

Managed Voltro Cloud is coming as a separate, opt-in deploy target. Choosing it means running your workload on Voltro-operated infrastructure under its own data-processing terms — a deliberate choice, not the default. Self-hosting remains the default and keeps the guarantees above.