Versus Incident handles incident data, webhook secrets, and on-call routing keys. We take security reports seriously and aim to acknowledge every report quickly.
Security fixes are issued against the latest minor release. Older minor versions are not patched.
| Version | Supported |
|---|---|
latest 1.x |
✅ |
older 1.x |
❌ — please upgrade |
0.x |
❌ |
Please do not open a public GitHub issue for security reports.
Use one of the following private channels:
- GitHub Security Advisory (preferred): https://github.com/VersusControl/versus-incident/security/advisories/new
- Email:
admin@versusincident.comwith the subject line[SECURITY] versus-incident: <short summary>.
When reporting, please include:
- Affected version(s) and deployment mode (Docker, Helm, source build)
- Reproduction steps or proof-of-concept
- Impact (information disclosure, RCE, privilege escalation, etc.)
- Any suggested fix or mitigation
You should expect:
- Acknowledgement within 3 business days.
- Initial triage and severity assessment within 7 business days.
- Fix or mitigation plan depending on severity:
- Critical / High → patch release as soon as a fix is verified.
- Medium → next scheduled release.
- Low → tracked publicly once a fix is available.
- A CVE / GHSA advisory for any vulnerability that affects published releases.
We are happy to credit reporters in the advisory unless you prefer to remain anonymous.
- Reports against unsupported versions (please upgrade and re-test).
- Issues that require an attacker to already control the host running Versus Incident, the Redis instance, or the configured AI provider.
- Findings that depend on misconfiguration explicitly documented as
development-only (e.g.
redis.insecure_skip_verify: true). - Denial-of-service via unbounded request volume to the public
/api/incidentsendpoint when no rate-limiting/proxy is deployed in front. Operators are expected to terminate TLS and rate-limit at the edge. - Denial-of-service via unbounded request volume to the hosted control
plane's per-org ingestion edge (
POST /o/{org-id}/api/incidents). As with/api/incidents, operators terminate TLS and rate-limit at the edge. The edge now requires a per-org ingest key (see hardening notes below); built-in per-org/per-IP edge rate limiting remains a tracked follow-up.
- Run Versus Incident behind an authenticated reverse proxy. The
/api/incidentsendpoint is intentionally unauthenticated so monitoring tools can post directly; restrict it at the network layer. - Set a strong root-level
gateway_secretif any admin endpoints (/api/admin/*or/api/agent/*) are exposed. Emptygateway_secretmeans the admin routes are not registered at all — never set it to an empty string and assume the endpoints are protected by something else. - Use TLS to Redis in production. Do not set
redis.insecure_skip_verify: trueoutside development. - Provide all tokens, webhook URLs, and routing keys via environment variables — never commit them to YAML.
- Enable redaction (
agent.redaction.enable: true, default) before pointing the agent at production logs. - The hosted control plane's per-org ingestion edge
(
POST /o/{org-id}/api/incidents) is gated by a valid, active org id and a per-org ingest key: the key is verified with a constant-time keyed-HMAC compare before the request is forwarded, so a missing or wrong key is rejected with401and never reaches a tenant's data plane. The key is 256-bit, shown to the customer exactly once at provisioning/rotation, and stored only as a keyed HMAC-SHA256 underINGEST_KEY_PEPPER— a database leak cannot recover raw keys without the pepper. Keep the edge behind a rate limiter until built-in per-org/per-IP rate limiting ships. It exposes no read surface — cross-tenant read isolation is enforced server-side (epic X3). Persistent deployments must setINGEST_KEY_PEPPER; otherwise an ephemeral pepper is used and issued keys stop verifying after a restart.
The private enterprise build (versus-enterprise/) adds role-based access
control and an append-only audit trail. Both are license-gated and dormant in
the community build; the OSS tree never imports the enterprise module.
- Data-driven roles. Four roles —
viewer<responder<admin<owner— map to permissions (incidents:view,incidents:act,tenants:manage,sso:manage,roles:manage,audit:view) via a single authoritative map. Handlers declare the permission they need (Require(permission)); they never branch on role. - Fail closed. An unknown/empty role grants nothing; a subject with no
assignment is denied (
403); no session is401. There is no implicit access. - Per-org isolation. Subject→role assignments persist through the X3
tenancy.NewOrgScopedstorage seam, so a role in org A can never be seen or used in org B. The role-assignment API is guarded by theroles:managepermission, resolved from the caller's org-bound SSO session.
- Append-only. The API surface exposes only append (in-process emitters)
and read-only query/verify — there is no update or delete route. Entries
are per-org scoped and persisted through the same
NewOrgScopedseam. - Tamper-evident. Each entry carries a SHA-256 hash chained to the prior
entry (length-prefixed fields, no concatenation collisions).
Verify/VerifyPersisteddetect any out-of-band edit of the stored log. - No secrets in entries. Security-relevant events are recorded by name
(e.g.
sso.config.changed) — never the secret value. The admin read APIGET /enterprise/api/audit/{org}is gated on theaudit:viewpermission resolved from the caller's org-bound SSO session. - Catalog:
sso.login.success/sso.login.failure,sso.config.changed,rbac.denied,rbac.role.assigned/rbac.role.unassigned,tenant.created/tenant.suspended/tenant.activated/tenant.deleted.
Audit entries OUTLIVE the request that produced them, so every request-derived
field (org, actor, target, request id, IP) is strings.Cloned at Log.Append
before it is retained. The regression guard
TestBufferAliasing_AuditEntryOrgNotRewritten builds a Fiber app without
Immutable:true so the clone is load-bearing, and was verified to FAIL when
the clone is removed and PASS once restored.
X5 introduces no new environment variables. It reuses the auto-generated SSO
session pepper (the X4 session the RBAC guard resolves): the role-assignment and
audit read APIs are gated on the caller's RBAC permission carried by that
session. The pepper is generated and persisted on first licensed boot rather
than env-configured, never hardcoded; a request with no session fails the admin
APIs closed (401), and an insufficient role is denied (403).
go build ./..., go vet ./..., and go test -race ./... are green in
versus-enterprise/. Audit immutability (no delete/update API + hash chain),
per-org isolation (RBAC RoleOf and audit Query proven not to cross orgs),
constant-time secret compares, and the buffer-aliasing guard were all
verified at the X5 gate.
We hash-pin every dependency in go.sum and record provenance for any
dependency that warrants explanation in our audit evidence (epic X7).
| Dependency | Type | Status | Provenance |
|---|---|---|---|
github.com/meguminnnnnnnnn/go-openai v0.1.2 |
indirect | Accepted | Transitive via cloudwego/eino-ext ACL layer; maintained fork of sashabaranov/go-openai (de-facto Go OpenAI client); hash-pinned in go.sum; no first-party import; reviewed and accepted by CEO 2026-06-08. |
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260608142157-58d993d5cdff |
direct | Accepted | Added by epic E12 (runbook-RAG find_runbook tool). Same vendor family as the already-trusted cloudwego/eino-ext/components/model/openai (chat-model seam); rides the identical eino-ext/libs/acl/openai → meguminnnnnnnnn/go-openai chain — introduces no new third-party vendor at the leaf. Hash-pinned in go.sum; reviewed and accepted at the E12 security gate 2026-06-09. |
Notes for eino-ext/components/embedding/openai:
- Why it is here: the embeddings seam for the
find_runbooktool —pkg/agent/ai/eino/embedder.go→cloudwego/eino-ext/components/embedding/openai→cloudwego/eino-ext/libs/acl/openai→meguminnnnnnnnn/go-openai. It is the embeddings sibling of the chat-model component already in use; its full transitive closure was already present in the module graph via the chat path (verified withgo list -deps), so no new third-party vendor entered the tree. - Pseudo-version note: pinned to an untagged commit pseudo-version
(
v0.0.0-20260608142157-…) rather than a semver tag like themodel/openaisibling (v0.1.13). Theh1:/go.mod h1:hashes are present and identical in bothversus-incident/go.sumandversus-enterprise/go.sum, so the build remains reproducible. Flagged for the Core Engineer to bump to a tagged release when one is published (non-blocking). - Decision: accept at the E12 gate — same vendor/provenance as the existing Eino model seam, no new attack surface beyond the already-reviewed chat-completion egress.
Notes for meguminnnnnnnnn/go-openai:
- Why it is here: pulled in by our Eino-based agent framework —
pkg/agent/ai/eino/chatmodel.go→cloudwego/eino-ext/components/model/openai→cloudwego/eino-ext/libs/acl/openai→meguminnnnnnnnn/go-openai. No first-party code imports it directly; it is marked// indirectin bothversus-incident/go.modandversus-enterprise/go.mod. - Hash pin (verified): present and identical in both
versus-incident/go.sumandversus-enterprise/go.sum:h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
- Decision: keep rather than evict — removing a transitive dependency of the CloudWeGo Eino ACL layer risks breaking the agent framework. Provenance is recorded here so the dependency is auditable and explained.
Reviewed and passed at the Security release gate on 2026-06-10
(go test ./... -race, both modules green).
- Tenant read isolation (X3): every read and write through the enterprise
data plane is org-scoped; a cross-org read or delete returns "not found"
with no existence leak, and per-org blobs are namespaced so two tenants never
share storage. A
strings.Cloneguard in both the org resolver and the org-scoped storage wrapper stops fiber's reused request buffer from rewriting a stored org id. This is the confidentiality control behind the SOC 2 multi-tenancy evidence (epics G7 / X7). - Secret hygiene (X3, M4): all credentials — session key, gateway secret,
Postgres DSN, Google OAuth client secret — are read
from environment variables only (no hardcoded literals), compared in constant
time where applicable, and never written to logs (startup logs print
non-secret configuration only). Session tokens are 256-bit and stored only as
an HMAC-SHA256 digest keyed by the session key, so a leak of the session
table is not replayable; passwords are bcrypt (
DefaultCost) and never logged. - Outbound TLS: every control-plane outbound call (enterprise API, Google
OIDC) uses the shared client with
MinVersion: TLS 1.2;http.DefaultClientis never used. - Per-org ingestion edge — authentication closed, rate limiting deferred:
POST /o/{org-id}/api/incidentsnow requires a per-org ingest key (epic M4-FU1). The key is 256-bit, returned to the customer once at provisioning/rotation, and stored only as a keyed HMAC-SHA256 underINGEST_KEY_PEPPER(never plaintext, never bcrypt); verification is a constant-timehmac.Equalperformed before the request is proxied, so a missing/wrong key is rejected401and never reaches a tenant data plane, and rotation invalidates the previous key immediately. This closes the authentication half of the follow-up flagged at the M4 gate. It remains a write/abuse surface only — cross-tenant read isolation is unaffected. One follow-up remains before the edge is exposed to the public internet with real tenants: built-in per-org and per-IP rate limiting (operators rate-limit at the edge until then).
Reviewed and passed at the Security release gate on 2026-06-10
(go test ./... plus -race on internal/server, internal/billing,
internal/ingestkey — all green). This slice adds server-rendered signup ->
Stripe Checkout (TEST) -> customer dashboard -> internal admin to the existing
M4 control plane.
- Stripe secrets are env-only and never logged:
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRETand theSTRIPE_PRICE_*ids are read from the environment ininternal/config; the Stripe client returns sanitised errors (the Stripeerror.messageonly, never theAuthorization: Bearersecret), and startup logs print only abilling=stripe|fakelabel. With noSTRIPE_SECRET_KEYthe in-processFakebackend is wired so tests and the local demo run with no Stripe account. - Webhook signature verified, fail-closed: the webhook handler recomputes
the Stripe
HMAC-SHA256overtimestamp.payload, enforces a 5-minute tolerance window, compares in constant time, and rejects when no webhook secret is configured (never fail-open). Payment is confirmed by a server-side Checkout-Session retrieve (server-to-Stripe), never from a browser-supplied redirect parameter. - Per-org ingest key (M4-FU1): see the edge note above — 256-bit, shown
once, keyed-HMAC at rest under
INGEST_KEY_PEPPER, constant-time verified, rotation invalidates the old key, enforced with401on the ingest edge. The raw key is never stored or logged (Org.APIKeyHashisjson:"-"). - Admin/customer auth separation: the internal
/adminview accepts only a dedicatedvmp_admincookie keyed byADMIN_API_TOKEN(constant-time); a customervmp_sessioncan never reach it, and an emptyADMIN_API_TOKENdisables admin entirely (fails closed). - CSRF + output encoding: every state-changing browser POST carries a
synchroniser token in an HttpOnly cookie, matched in constant time;
templates use
html/templateauto-escaping with notemplate.HTMLon user data. - Tracked follow-up (does not affect read isolation or secrets): the
success-redirect confirmation records the plan from a client cookie and
binds the retrieved Checkout Session to the current session's account rather
than to the session's
client_reference_idorg. This must be hardened before the recorded plan drives entitlements, usage limits, or real billing. Status: CLOSED 2026-06-10 — see the F1 section below.
Reviewed and passed at the Security release gate on 2026-06-10
(go test ./... plus -race on internal/server and internal/billing —
all green). This change (A) closes the F1 checkout follow-up flagged above and
(B) replaces the server-rendered HTML control plane with a React SPA served
from an embedded ui/dist/ plus a JSON API under /api/* and /admin/api/*.
- F1 — checkout confirmation is authoritative and server-side (CLOSED):
internal/server/checkout.goconfirmCheckoutPlanis the single source of truth shared byPOST /api/checkout/confirmand the Stripe webhook. It (1) requiresSession.Paid()(Stripepayment_status+status, never a browser redirect parameter); (2) binds the paid session to the caller by requiringsession.client_reference_id == org.ID— a cross-tenantcs_…replay is rejected with403and grants no upgrade; and (3) derives the plan from Stripe viaCatalog.PlanByPriceID(session line-item price id)with the server-stampedmetadata[plan](paid-only) as the offline fallback — never from a client cookie or form field.RetrieveSessionexpandsline_itemsso the price id is the real purchased price. Proven byTestConfirmCheckoutPlanF1,TestConfirmCheckoutPlanDerivesPlanFromStripeand the end-to-endTestSPAConfirmRejectsCrossTenantSession. - CSRF — double-submit cookie + custom header on every mutation:
internal/server/csrf.gois applied at the group level on/apiand/admin/api, so no state-changing route is exempt. A safeGETmints a readablevmp_csrfcookie (SameSite=Strict,Securein production, and intentionally notHttpOnlyso the same-origin SPA can echo it); every unsafe method must present a matchingX-CSRF-Tokenheader, compared in constant time, else403. A cross-site page can neither read the origin-bound cookie nor set the custom header, so it cannot forge the pair.TestCSRFRejectsUnsafeRequestWithoutHeaderproves a header-less POST is rejected before any work. The Stripe webhook (/webhooks/stripe) sits outside/apiand is gated by HMAC signature verification instead of CSRF — correct for a server-to-server call. - Session/cookie posture: the customer
vmp_sessionand adminvmp_admincookies remainHttpOnlywithSameSite=Lax(notNone) so the top-level Stripe redirect-back keeps the user signed in; this is safe given the double-submit CSRF guard. Sessions stay opaque and HMAC-hashed at rest (carried from M4), expiry is enforced, and logout deletes the server-side session. - Admin/customer separation (fails closed):
/adminand/admin/api/*accept only thevmp_admincookie (HMAC ofADMIN_API_TOKEN); a customervmp_sessioncan never reach admin data and an emptyADMIN_API_TOKENdisables admin entirely. Proven byTestSPAAdminSeparateCredential. - SPA static mount does not shadow APIs:
internal/server/static.gois mounted last and its catch-all fallback defers/api/,/admin/api/,/o/,/webhooks/,/auth/,/healthzand/readyzback to their handlers, soindex.htmlis never served over an API/ingest/webhook route. The embed compiles with onlydist/.gitkeep, sogo testneeds no UI build. - Per-org ingest key and Fiber hardening carried over: the ingest edge is
still keyed-HMAC at rest under
INGEST_KEY_PEPPER, constant-time verified and401before any proxy hop;fiber.Config{Immutable:true}is set so persisted request values are safe; Stripe secrets stay env-only and Stripe errors are sanitised. React auto-escaping is intact — nodangerouslySetInnerHTMLon server/user data inui/src. - Tracked follow-up (LOW, attacker-unreachable):
handleStripeWebhookdefaults the recorded plan toteamwhen a validly-signed session carries no catalog price id and nometadata[plan]. Reaching this requires a genuine Stripe signature, so it is not exploitable by a customer; prefer skipping the record over a silent Team grant before entitlements depend on it.
Reviewed and passed at the Security release gate on 2026-06-10 (GATE-ALIASING).
This is the tenant-isolation bug class flagged twice from the gate (GATE-X3
org-id rewrite, GATE-PLATDEMO signup-email rewrite): Fiber's c.Get,
c.FormValue, c.Params, c.Query and c.Cookies return strings that alias a
pooled request buffer Fiber reuses across requests, so any such value retained
past its handler (a stamped OrgID, a persisted email) is silently rewritten in
place by a later request — collapsing tenant isolation. The standard enforced is
golden rule #11 in AGENTS.md: build every Fiber app with
fiber.Config{Immutable: true} (belt) AND strings.Clone request-derived
strings persisted in shared library/middleware code (suspenders). The CTO's
hardening sweep applied it across all three modules and each guard now has a
deterministic regression test that bites (verified by removing the guard and
observing the test fail):
- OSS (
versus-incident):cmd/main.gobuilds the Fiber app withImmutable: true;pkg/middleware/extension.goOrgInjectorstrings.Clones the resolved org at thec.Localspersistence boundary (this seam is reused by the enterprise binary, which cannot assume the host setImmutable). Guarded bypkg/middleware/extension_buffer_test.go(TestOrgResolverSurvivesBufferReuse) — built withoutImmutableso the clone is load-bearing; fails if the clone is removed. - Enterprise (
versus-enterprise):cmd/versus-enterprise/main.gobuilds the app withImmutable: true; both X3 clones remain present —pkg/tenancy/middleware.goOrgResolver(strings.Clone(strings.TrimSpace(c.Get(HeaderOrg)))) andpkg/tenancy/orgstore.goNewOrgScoped(NormalizeOrgID(strings.Clone(org))). Guarded bypkg/tenancy/buffer_aliasing_test.go, which drivesNewOrgScopedandOrgResolverdirectly (not via the OSSOrgInjector, whose own clone would otherwise mask a missing seam guard) with two subtests that each fail if their respective clone is removed — confirmed to bite per-clone under-race. - Platform (
versus-management-platform):internal/server/server.goNewbuilds the app withImmutable: true, the belt the control plane relies on (it has no per-field clone on the signup→account email path). Guarded byinternal/server/buffer_aliasing_test.go(TestSignupEmailNotAliasedAcrossRequests), which drives the real app via a form-encoded signup (a JSON body would not exercise Fiber's buffer-aliasing accessor) and fails whenImmutable: trueis removed fromNew. The twoc.Body()reads (ingest-proxy forward, Stripe-webhook verify) are consumed synchronously within their handlers and retain no sub-slice past return, so they are unaffected.
Ruling: the bug class is CLOSED and GUARDED across all three modules — a
future removal of any belt or clone is caught by a test. Tracked follow-ups, not
blockers now: OSS teams/members c.Params("id") persistence relies on the belt
only (no per-field clone); future enterprise X4 SSO subject/email and X10 source
metadata will need the same strings.Clone discipline when those epics land.
Reviewed and PASSED at the Security release gate on 2026-06-11 (GATE-X4SSO),
covering versus-enterprise/pkg/sso (verify.go, oidc.go, txstore.go,
session.go, config.go, api.go, pkce.go). Ran
cd versus-enterprise && go test ./... -race — green.
- ID-token validation (
verify.go):algpinned toRS256— anything else (notablynoneand an HS256 forgery using the RSA public key as the HMAC secret) is rejected before any crypto. Signature isRSA-PKCS1v15/SHA-256 verified against the JWKS key addressed by the token'skid(unknownkidtriggers one rate-limited JWKS refetch for key rotation, then fails).isscompared exactly (trailing-slash normalised),audmust contain the configuredclient_id,exp/iat/nbfenforced with a 2-minute skew, and a non-emptyemailclaim is required. JWKS/discovery bodies areLimitReader- capped at 1 MiB. - CSRF / replay: server-side single-use
state(txStore.consumedeletes on read, so a replayed/forgedstate→ 401);nonceminted server-side, sent to the IdP and required to match the ID token'snonce; PKCEcode_verifier(48 bytescrypto/rand) held only server-side, only its S256code_challengeleaves the process. Transactions expire after 10 minutes. - Session binding (
session.go): opaque 32-byte random handle; the cookie is<id>.<base64url(HMAC-SHA256(id))>verified in constant time (subtle.ConstantTimeCompare) before any store lookup — bad-tag / unknown / expired are indistinguishable (no oracle).OrgIDis stamped onto the session andstrings.Cloned off the request buffer (golden rule #11). Cookie isHttpOnly,Secure(prod),SameSite=Lax(so the top-level IdP redirect carries it). The session pepper is auto-generated and persisted on first licensed boot (stable across restarts) — never a hardcoded default. - Per-org isolation (
config.go): IdP config persists throughtenancy.NewOrgScoped(base, org), namespaced per tenant so one org's IdP settings can never resolve for another. The callback enforcessecureEqual(tx.Org, org)(constant-time) — a flow begun for org A cannot be completed on org B's callback.resolveOrgvalidatestenancy.ValidOrgIDand active-tenant status, andstrings.Clones the path org. - Secret handling:
client_secretaccepts an${ENV}reference expanded only at exchange time;IdPConfig.MarshalJSONredacts it (***redacted***) so it never leaves over the API, and the stored blob uses a separate non-redacting marshaller.state/nonce/verifier/session-handle all fromcrypto/rand; the org binding is compared viasecureEqual(sha256 +subtle). The token-exchange error path never echoes the IdP response body. - SSRF: the OIDC
issuer(hence discovery URL) is config-pinned per org via thesso:manage-gatedPUT /:org/config; it is never taken from request input at login/callback time. Discovery requires the document's ownissuerto equal the configured issuer exactly, andjwks_uriis taken from that verified document — an attacker cannot redirect the JWKS fetch at request time. - Golden rule #11 (verified by neutralisation): with the SSO app built
without the
Immutablebelt,SessionStore.New'sstrings.Clone(org)is the only guard. Removing it makesTestBufferAliasing_SessionOrgNotRewrittenAcrossRequestsFAIL (org-a sessionOrgIDrewritten toorg-bafter pooled-buffer reuse); restored, it PASSES and the full suite is green under-race. Confirmed this gate, then restored.
Ruling: PASS. Non-blocking follow-up (LOW): the per-org issuer is
admin-pinned but not range-restricted, so a privileged admin could point it at an
internal address — consider an egress allow/deny list (or SSRF guard on the
shared HTTP client) for the discovery/JWKS/token fetches when X4 hardens.
Reviewed at the Security release gate on 2026-06-11 (GATE-WAITLIST), covering
versus-management-platform/internal/server/{waitlist.go,server.go,csrf.go}, the
store layer (internal/store/{memory.go,postgres.go,store.go}) and migration
internal/store/migrations/0003_waitlist.sql.
Verdict: BLOCK on arrival → resolved. The build shipped with
fiber.Config{Immutable: false} in server.New — the app-wide belt the control
plane relies on (it carries no per-field clone on its persistence seams). With
it disabled, both golden-rule-#11 regression tests failed as committed:
TestWaitlistEmailNotAliasedAcrossRequests (re-add of emailA returned <nil>
instead of ErrConflict — the stored email had been rewritten to a later
request's value) and TestSignupEmailNotAliasedAcrossRequests (account A's
persisted email rewritten to eeee@versus.io). This is a live production
data-integrity / tenant-data-corruption defect on the form-encoded write path and
a regression of the GATE-ALIASING ruling above. Fix applied at the gate:
restored Immutable: true in internal/server/server.go. After the fix,
go test ./... -race is green across the module (both biting tests PASS).
Routed back to the Platform Engineer: do not ship server.New with the belt off.
With the belt restored, the rest of the surface is sound:
- CSRF: the route lives inside the
/apigroup whosecsrfGuardis applied viaapi.Use(double-submitvmp_csrfcookie +X-CSRF-Tokenheader, constant-time compare,SameSite=Strict). A public unauthenticated form inside the CSRF group is the correct posture — it blocks cross-site forced submissions while still allowing the same-origin SPA to post.TestWaitlistRequiresCSRFproves a token-less POST → 403 before any work. - Rate limit:
waitlistLimiter()(per-IP, 5/min, in-memory) is wired as route middleware beforehandleWaitlist, inside the CSRF group, so the order is csrfGuard → limiter → handler.TestWaitlistRateLimitedproves the 6th same-IP POST → 429. Follow-up (recorded, acceptable for now): the key isc.IP()with no trusted-proxy config, so behind a reverse proxy/LB all clients may share the proxy IP (over-throttle) or, ifProxyHeader/X-Forwarded-Foris later trusted naively, be spoofable — set Fiber's trusted-proxy config and derive the client IP from it before this is load-bearing in production. - Input validation:
net/mail.ParseAddresswithaddr.Address == email(rejectsName <addr>display-name smuggling), trim+lower normalisation, a 254-octet cap (RFC 5321) ahead of the 4 MiB appBodyLimit, and dedupe on the normalized form →ErrConflict→"already".TestWaitlistRejectsInvalidEmail/…QueuesNewEmail/…DuplicateReportsAlreadycover these. - PII / enumeration: the email is never logged anywhere on the path; the
response contract is exactly
queued/already(the address is only echoed back to the same submitter). The migration stores onlyemail/source/ created_at— no secret, no account linkage. - SQL injection: the Postgres writer is fully parameterised
(
INSERT … VALUES (lower($1), $2, $3)) with a defence-in-depthUNIQUE INDEX … (lower(email)); no string concatenation. - Buffer safety: the persisted email comes from
c.BodyParser(JSON allocates fresh strings; the form path is covered by the restored belt). Nothing fromc.Get/c.IP/c.Paramsis persisted —c.IP()is used only as an in-memory limiter map key for the request's lifetime (thewaitlist.gocomment crediting the belt forc.IP()safety is imprecise but harmless: defaultc.IP()returns a freshly-allocatedRemoteIP().String()).
Ruling: PASS once Immutable: true is restored (done at the gate).
Follow-ups (non-blocking): trusted-proxy/X-Forwarded-For handling for the
rate-limit key; correct the c.IP() belt comment in waitlist.go.
Reviewed and PASSED at the founder-authorized Security release gate on
2026-06-27 (GATE-X9X3-HASINGLEORG), covering five changes that touch auth, tenant
isolation, audit and secrets: X9-T11 (OSS storage.CreateBlobIfAbsent), X9-T12
(enterprise generate-once secret convergence), X3-T5 (single-org pivot +
default→license-org migration), the SSO↔People MergeRoster overlay, and the
metric/trace ApplyDeploymentOrg org-scoping fix. Ran
cd versus-incident && go test -race ./pkg/storage/... ./pkg/scheduler/... and
cd versus-enterprise && go test -race ./pkg/{bootstrap,localadmin,tenancy,audit,rbac,sso,members,datasource,intel}/...
— all green.
- Atomic single-writer primitive (
pkg/storage). PostgresCreateBlobIfAbsentisINSERT … ON CONFLICT (name) DO NOTHINGagainst a table chosen from the fixedblobTablesallowlist (or the constantvs_blobsfallback) — never caller input — withname/databound as$1/$2. No SQL injection surface.vs_blobs.nameisTEXT PRIMARY KEY(migration001_init), so theON CONFLICT (name)arbiter is well-formed.written = RowsAffected()==1, then a read-after-writeSELECT 1confirms the row is durably present so the race loser canReadBloband adopt the winner. Thefilebackend isO_CREATE|O_EXCL+ write/fsync with cleanup-on-failure (single-node only, the only place file storage is allowed under HA per the X9-T3 guard);memoryis atomic under the provider mutex. The capability is strictly additive (optional type-assert, likeLifecycle/Searcher) and never weakensWriteBlob. One-way import preserved:pkg/storageimports nothing fromversus-enterprise(the onlyversus-enterprisetoken in the package is a comment). - Enterprise convergence (
bootstrap,localadmin,tenancy). The SSO session HMAC pepper + the AES-256-GCM AI master key (bootstrap.Resolve, one blob) and the built-in admin bcrypt hash (localadmin.EnsureBootstrap) are each generated locally (crypto/rand) then committed viaCreateBlobIfAbsent; onwritten==falsethe instance re-reads and adopts the persisted bytes, so N replicas on the shared store converge on ONE of each. No secret value is ever logged: bootstrap logs the "generated…and persisted" banner only on the winning write;localadminreturns the plaintext only whencreated==trueandmain.goprints it once inside the credentials banner, never on adoption. Both paths fail closed when the backend lacksBlobCreator(redis/database stubs) rather than racing a read-then-write.tenancy.orgScopedProvider.CreateBlobIfAbsentnamespaces the key under the org and fails closed identically. - Unchanged posture confirmed:
VERSUS_ENTERPRISE_SECRET_KEYBYOK precedence intact (runtimeai.NewCryptoFromEnvwins overboot.SecretKeyat the runtimeai layer; the env error logs the variable NAME constant + a validation error, never the key). The plaintext-at-rest posture of the pepper/master key is identical to what was gated at GATE-X27 — the master key and the AI-key ciphertext it seals live in separate blobs. No plaintext-at-rest regression.
- Boot-pin, fail closed.
deploymentOrg := strings.TrimSpace(lic.Claims().Org)is resolved once at boot; enterprise + empty claim ⇒log.Fatalf(nodefaultfallback); community ⇒storage.DefaultOrgID.tenancy.OrgResolverreturns this one server-pinned constant for every request —X-Versus-Org, the tenant registry, andIsolation/HeaderOrgare gone. The org is process state, never request input, so header-spoof is impossible by construction. - No dropped authz.
RequireOrgParamno longer comparespathOrg == sessionOrg(there is exactly one org and every session is bound to it), but the check that doubled as authz is re-expressed, not lost: the guard still fails closed on a missing session (401) and delegates toRequirefor the permission check, and no handler scopes data by the:orgpath param — a workspace-wide search found zeroc.Params("org")reads; every data path scopes bymiddleware.OrgFromContext(the pinned resolver). The SSO callback binds the session server-side: org fromresolveOrg(the pinned constant),secureEqual(tx.Org, org)cross-org guard,Sessions.NewWithAuth(org, …)withOrgIDstrings.Cloned; the productionSessionResolverreturnssess.OrgID/sess.Subject(server-set at creation) — no client can spoof the org. - Migration is re-key, not orphan, and secrets are not silently moved.
tenancy.RekeyBlobis copy-only (thestorage.Providerseam has no delete), idempotent (skips when the target org already holds the blob), and a community no-op (default org ⇒ same namespace). The audit chain is re-keyed by byte copy so it still verifies — recomputing per-entry hashes to rewrite the embeddedorg_idwould be indistinguishable from forgery, so copy-only is the correct tamper-evident choice; appended entries chain onto the copied tail. rbac role + team-role assignments and the SSO require-SSO/MFA policy re-key cleanly. SSO connection NON-secret config re-keys, but the client secret is AES-GCM AAD-pinned tosso/<org>/connections/<id>so it cannot decrypt under the new org —migrateDeploymentOrglogs the explicit RE-ENTER WARNING and the callback fails login closed (audited) on a decrypt error. No secret value is moved silently or logged.
members.MergeRoster is a pure read-side function (no write path). The
GET /api/admin/members interceptor resolves the org from ssoResolver (the SSO
session org, never a caller-supplied value) and reads memberStore.List(org)
scoped to that org, so per-org isolation is exact (an SSO user in org A never
appears for org B). Sessionless/community requests pass through untouched. The
synthetic sso:<subject> rows are read-only: they never exist in the mutable OSS
teams store, so PATCH/DELETE /api/admin/members/:id return 404
(mapStoreErr → teams.ErrNotFound), and the per-row self-edit guard cannot
match a synthetic id. No privilege escalation, no cross-store leak.
ApplyDeploymentOrg stamps the boot-pinned deployment org onto every
prometheus/traces source's Options["org"] at boot (before sources/brains are
built), so learned baselines WRITE and the session-pinned Baselines API READ under
the same license org — closing the default-orphan mismatch. No per-request org
derivation is reintroduced; a blank deployment org (community) is a no-op; non
metric/trace sources are never touched. Note (not a defect): the helper stamps
unconditionally, overriding any explicit per-tenant org left in config
(TestApplyDeploymentOrg_OverridesPreexistingOrg). This diverges from the older
X10 note's "explicit org wins" phrasing but is the safer single-org behaviour — a
stale/mismatched config org can no longer split the write org from the read org or
orphan baselines. Routed to the Enterprise Engineer as a doc-reconciliation item
only; no code change required.
Ruling: PASS for the batch. No secret logged, no plaintext-at-rest
regression, no cross-org or cross-store leak, no unparameterized SQL, no
fail-open path, no one-way-import violation. Non-blocking follow-ups routed:
(F1, Enterprise, LOW/doc) reconcile the ApplyDeploymentOrg "explicit org wins"
wording with the shipped unconditional-override behaviour; (F2, Enterprise, INFO)
migrated audit chains carry mixed per-entry org_id (legacy default on copied
entries, license org on new ones) — cosmetic, chain still verifies, no action
needed. Pre-existing backlog unchanged: B19 (replace the dev ngrok
platformBaseURL before GA), B20 (first-class air-gapped switch).