CloudPanorama Request evaluation

Security

The self-assessment — every isolation and fail-closed invariant with the test that holds it — the scanners, the pen-test scope, and how to report a vulnerability.

This is the document a buyer's security team reads first, and the one the pen test is scoped from. It states what the platform promises, names the test that proves each promise, and says what happens when a scanner lights up. It is kept honest by being short: a line here without a test beside it is a claim, and claims are what a pen test is for.

The public summary, for the website, is written from this file and never the other way round.

1. The self-assessment

Every line is an invariant the code is built around (CLAUDE.md "Non-negotiable invariants", PROVISIONING_SPEC.md §3), the mechanism that holds it, and the test that fails when it stops holding. Status is held when the named tests passed in the latest evidence run, open otherwise. The run is security-evidence.md beside this file, written by platform/tools/security-evidence.py: the date, the commit and the paths changed on top of it, a digest of the files it ran on, and every named test's result, with the checks that are not tests. CI runs the same tests on every train: since 2026-10-18 a train reaches main only as a pull request whose required checks are green on its exact commit (platform/EXECUTION.md §6). The evidence run is what a status rests on before then, and what records which named test passed on which tree. TestSecurityDocumentationDoesNotClaimUnverifiedGuarantees fails when a held line names a test that does not exist or did not pass in it, and when the digest is not this tree's. The digest covers every source file directly in a directory that holds a named test, the migrations, the two checks, the Dockerfiles, the modules' dependencies and the tool. So a change to any of them after the run leaves the run stale, and the run is repeated before the change lands. Severity is what a breach of the line would mean: high is another tenant's data or an unauthenticated write; medium is a single tenant's data reachable by the wrong person inside it; low is availability or hygiene.

# Invariant Mechanism Tests Severity Status
1 A tenant reads and writes only its own rows Row-level security on every tenant table, enforced on the panorama_app role; every tenant query runs inside db.InTenantTx, which sets app.tenant_id. tenants itself too, since migration 0158 (S07): the app role sees its own tenant's row and no other db.TestInTenantTxCannotWriteIntoAnotherTenant, db.TestTheAppRoleSeesOnlyItsOwnTenantRow, db.TestTenantScopeDoesNotLeakToTheNextTransaction, db.TestSnapshotsAreTenantIsolated, db.TestNewPartitionsCarryRowSecurity, db.TestPerformanceIsTenantIsolated, db.TestPerformancePartitionsCarryRowSecurity, db.TestCostCrossTenantIsolation, db.TestCostIsolationCoversEveryCostTable, org.TestCrossTenantIsolation, db.TestCrossTenantIsolation (provisioning), db.TestCompositeFKsRefuseCrossTenantParents high held
2 Nothing reaches the database outside the tenant transaction CI greps for raw pool access outside internal/db (scripts/check-no-raw-pool.sh, make check-isolation) the guard itself (make check-isolation), run in the go job and in the evidence run high held
3 The BYPASSRLS role runs exactly the sanctioned operations db.InControlTx has a caller allowlist by file, each file named with the operation it performs, and a listed file that stops calling it is an error too (S06: it was by package, so any new file in cli — serve.go included — passed); partition DDL a second one (db, scheduler, and by path seed-demo --performance's one file for the Performance tables' creating helper — and import-awspricing's one file for the cost_data one); both enforced by the same CI guard. And the control pool is its own type, db.ControlPool: InTenantTx does not accept it, InControlTx and the partition helpers accept nothing else, and a constructor taking both pools cannot have them swapped — misuse does not compile scripts/check-no-raw-pool.sh; db.TestMaintenanceFunctionsAreNotCallableByTheAppRole, db.TestOnlyTheOwnerCanSupplyTheClock, db.TestTheControlPoolIsAnotherType high held
4 The tenant comes from the credential, never from the request Sessions, ingest tokens and agent tokens each resolve to one tenant; a handler is never reached without one; a public route leaves none behind. The host is checked against the credential, never read for it: a person's session or read token presented at a host that names another tenant is 401, as an unknown credential is; a host that names none (the deployment's own, a self-hosted hostname, an address) has nothing to compare. Logout and the single sign-on callback take no credential and make the same check before they end a session or record a step-up on it middleware.TestTheTenantComesFromTheCredential, middleware.TestAHandlerIsNeverReachedWithoutATenant, middleware.TestAPublicRouteLeavesNoTenantBehind, middleware.TestAPersonsCredentialAtAnotherTenantsHostIs401, handlers.TestAPersonsCredentialIsRefusedAtAnotherTenantsHost, handlers.TestALogoutAtAnotherTenantsHostEndsNothing, handlers.TestAStepUpReturnedAtAnotherTenantsHostIsNotRecorded, handlers.TestOneTenantsTokenCannotReachAnothersRun, db.TestSessionCannotReferenceAUserFromAnotherTenant high held
5 A credential opens only the routes it is for The contract declares one scheme per route; the guard resolves that scheme and no other. One alternative exists: a person's read token, listed beside the session on the reads the contract marks; the derivation refuses it on anything but a GET outside /auth/* and /admin/* below an administrator's floor, there a bearer is the token and the cookie is not read, and every other route answers it 401 middleware.TestACredentialOnlyOpensTheRoutesItIsFor, middleware.TestARouteWithNoPolicyIsRefused, middleware.TestAPolicyMatchingNoRouteIsAlsoDrift, middleware.TestTheRoutePolicyRefusesASpecThatPutsTheTokenOnAWrite, middleware.TestOnlyAllowlistedReadOperationsAcceptTheToken, middleware.TestBearerWinsAndCookieIsIgnoredOnTheSameRequest, handlers.TestAnAPITokenIsRefusedOnEveryUnsafeRoute high held
6 Auth fails closed No ingest credential and no explicit opt-out: the process exits. A database failure is a 503, never a 401 that lets a retry through cli.TestNoCredentialAndNoOptOutRefusesToRun, auth.TestOneSharedCredentialInAMultiTenantDeploymentIsAnError, middleware.TestADatabaseFailureIsNot401, middleware.TestARequestWithNoCredentialIs401 high held
7 Entitlement is not authorization, and neither is inferred Roles gate people; entitlements gate tenants; no row means nothing unlocked unless the deployment says ENTITLEMENTS_UNRESTRICTED, which serve refuses beside a licence middleware.TestARoleIsEnforcedWhereTheContractRequiresOne, handlers.TestNoRowMeansNoModules, config.TestUnrestrictedIsAnExplicitFlag, cli.TestServeRefusesTheFlagBesideALicense, entitlements.TestOneTenantsEntitlementIsNotAnothers medium held
8 A licence is verified before it is parsed, and cannot be rolled back ed25519 over the payload, checked first; issued_at decides newer, in the upsert's own WHERE license.TestATamperedLicenseIsRefusedLoudly, cli.TestAnOlderLicenseNeverReplacesARenewal, entitlements.TestAPreIssuedAtLicensedRowCannotBeRolledBack medium held
9 The public status page carries no identifier Its own response type, closed schemas, and a test that walks the body handlers.TestThePublicPageLeaksNothing, handlers.TestThePublicPageIsTenantScoped, handlers.TestThePublicPageIsRateLimitedPerAddress medium held
10 The platform never holds a customer cloud credential No field in the agent API can carry one; the agent runs on ambient credentials api.TestNoCredentialFieldsInAgentAPI (every property name in the agent API is checked against the credential rule from), agent.TestNoCredentialsUploaded, scrub.TestSecretScrubbed high held
10b The agent's platform token cannot be obtained by the code it runs Two roles of one image (D18, platform/docs/agent-execution-boundary.md): the broker holds the token and runs no customer code; the worker — another user, no token, non-dumpable, sweeping its process space before and after every attempt — runs OpenTofu; one workspace volume under a shared group, one channel per attempt. The worker's own environment is still built from nothing (envallow) agent.TestBoundaryTemplateCannotReadAgentTokenFromProc, agent.TestBoundaryTemplateCannotReadBrokerFiles, agent.TestBoundaryTaskCapabilityCannotCrossAccountOrAttempt, agent.TestBoundaryIsolatedRunnerCreateCancelDestroy (against the shipped image and users, make test-boundary, nightly and in the release gate — not per PR), worker and agent split tests, the agent acceptance test for the environment high held
11 An agent claims only its own tenant's and account's work The token names both; a claim is structurally scoped agentapi.TestClaimNeverCrossesAccountOrTenant, agentapi.TestAnotherAgentsTaskIsNotFound, drift.TestDriftNeverCrossesTenants, events.TestRemindersStayInTheirTenant high held
12 Every write is replayable without duplication Idempotency keys, run ids, claim ids; the replay matrix handlers.TestProtocolReplayMatrix, agentapi.TestClaimLostResponseIsIdempotent, ingest.TestReplayReturnsTheOriginalAndDoesNotBump medium held
13 Passwords and tokens are stored hashed; comparisons are constant-time; timing is uniform argon2id for passwords; hashed tokens; the timing suite auth.TestTheAcceptedShapesCostTheSameToRefuse, auth.TestAnEnormousStoredValueIsRefusedOnLength, make test-timing, in the evidence run medium held
14 Logins are throttled per address, per account as guessed from each address, and by an account-wide ceiling; an attempt made while locked is refused without being charged, so nobody can hold another person's account locked (S01) auth.Throttle (AdmitAll), keyed by the client the deployment declares (CLIENT_ADDRESS_SOURCE), never by a header the caller chooses: five failures lock the (account, address) pair, twenty across addresses lock the account, a lockout restarts the count and doubles to a 15-minute cap auth throttle tests; TestALockedAccountCannotBeHeldLockedForever, TestASprayFromManyAddressesStillStops; config client-address validation low held
15 Every unauthenticated route has a per-address budget; every body-taking route has a size limit Derived from the contract by middleware.Policies (security: [] → 120/min, x-rate-limit where a route says otherwise — 60 on the public page, 300 on /auth/me; requestBody → 1 MiB unless x-max-body-bytes); applied by the guarded router before authentication and nowhere else; the budget is counted once for every serve replica, in the database (db.RateLimits, the unlogged rate_limit_windows: one statement per limited request, refusals not charged), and the process's own count decides while the database does not answer within 250 ms (B08); the shared decoders answer 413 when a capped stream runs out. The agent's register, claim and heartbeat ask for 1,200 a minute (S09). A JSON body is checked against the contract's schema before its handler runs — maxLength, required, enums, patterns — 400 naming the field, 413 for an array over maxItems; the 192 MiB snapshot batch alone is not, its handler validating each record (S02) middleware.TestUnauthenticatedRoutesAreRateLimited, middleware.TestEveryBodyRouteHasASizeLimit, middleware.TestEveryJSONBodyIsCheckedAgainstItsSchema, middleware.TestTheBodyIsCheckedBeforeTheHandler, handlers.TestADeclaredMaxLengthIsEnforced, middleware.TestARateLimitedRouteAnswers429PerAddress, middleware.TestTwoReplicasShareOneBudget, middleware.TestAFailedSharedCountFallsBackToTheProcessBudget, db.TestTwoReplicasChargeOneBudgetInTheDatabase, middleware.TestABodyOverTheLimitIsRefusedBeforeTheHandler low held
16 Every response carries the security headers middleware.SecurityHeaders: a same-origin CSP with no inline allowance, nosniff, Referrer-Policy, a Permissions-Policy that turns everything off, COOP, and HSTS when COOKIE_SECURE middleware.TestTheHeadersAreOnEveryResponse, middleware.TestThePortalPolicyAllowsNoInlineAndNoForeignOrigin low held
17 CORS is an allowlist, never *; a 401 still carries it middleware.CORS(cfg.CORSOrigins), validated at startup middleware.TestOnlyAnAllowedOriginGetsCORSHeaders, middleware.TestAPreflightFromAnOriginNotOnTheListGetsNothing, middleware.TestA401StillCarriesCORSHeaders medium held
18 The image holds one static binary, the portal and nothing else; no secret is baked in Distroless, non-root; .dockerignore excludes every .env; the file list is checked in CI; the licence issuer never ships the image job's "Size, contents and licence key", which the evidence run repeats on a local build; license.TestTheIssuerNeverShipsInTheImage medium held
19 Sessions: a cookie that is HttpOnly, SameSite, Secure when told; a TTL that slides with use and ends twelve hours after the login however busy it is; logout deletes server-side auth.Sessions: SESSION_TTL (at most 12 h) is how long one lasts unused, and a refresh extends by it but never past auth.MaxSessionLifetime from the login (S04: the line said "fixed" while every portal load could extend it for ever); COOKIE_SECURE handlers session tests (auth_session_integration_test.go), TestASessionEndsTwelveHoursAfterItsLogin medium held
22 A credential or privilege change ends the sessions that predate it, and a user is added, reset, re-roled and removed without SQL The user lifecycle (auth.Lifecycle): a role change, a deactivation, a password change and a redeemed reset each end every session the user holds, in that order after the change commits; a reset is a 256-bit token stored hashed, redeemed once by its own UPDATE predicate, expiring after a day; the last enabled administrator cannot be demoted or deactivated (locked rows); another tenant's user is not found; every operation audits the email and never a password or token. A deactivation also ends the person's read tokens in its own transaction, by bumping their read-token generation, which every token carries from its issue; a token of an earlier generation is refused even once the person is reactivated, and marking the rows revoked afterwards is tidying whose failure brings nothing back. A revoked, expired or disabled person's token is refused on the next request; a password change or reset leaves them, listed and revocable on their own (the owner, 2026-10-07) Rounds 1 and 2 (2026-09-07): the guarantee is the row, not the deletion — every password write increments users.password_version in the transaction that writes the hash, a login records on its session the version of the hash it verified (Repo.Credential, Sessions.Create), and the resolver refuses a session whose version is not the user's; no clock takes part, so neither a write whose transaction started before a login nor a login that read the hash before a reset can leave a usable session; the eager deletion is bounded tidying; migration 0150 removes the sessions the pre-0149 resolver already refused by timestamp, so the upgrade revives none (round 4) handlers.TestAdminOnboardsNonAdminWithoutSQL, handlers.TestPasswordResetIsExpiringSingleUseAndRevokesSessions, handlers.TestDeactivationAndRoleChangeInvalidateAccess, handlers.TestLastAdminAndCrossTenantUserMutationAreProtected, handlers.TestMultiUserPilotFlow, cli.TestCreateUserAndResetPasswordFromTheCommandLine, auth.TestAPasswordChangeEndsOldSessionsEvenWhenDeletionFails, auth.TestASessionIssuedDuringAPendingPasswordWriteIsRefused, auth.TestALoginInFlightAcrossAPasswordResetIsRefused, auth.TestUpgradeDoesNotReviveRevokedSessions, handlers.TestARevokedOrExpiredTokenIsRefusedWithinOneRequest, handlers.TestReviewerDisableFailureMustNotReviveReadToken, handlers.TestADeactivationOutlivesAFailedTokenRevocation, handlers.TestATokenIssuedAcrossADeactivationDoesNotSurviveIt, handlers.TestAPasswordChangeOrResetLeavesReadTokensAlone medium held
23 An outbound notification reaches only a public address the tenant configured, over https, without following a redirect; the destination is never read back notify.ValidateDestination when a target is stored and again before each send (https on port 443 only, no credentials, every resolved address public — the template-source list, varschema.IsPrivateOrReserved, which since then (S08) also holds the unspecified, documentation, benchmarking and discard ranges and the IPv6 prefixes that embed an IPv4 address: NAT64, 6to4, Teredo), and the sender's own dialer applies the same check to the address it connects to; CheckRedirect returns the redirect unfollowed; a send failure is classified, never wrapped, so no error carries the URL; reads and audit rows carry host, hint and fingerprint only Round 1 (2026-09-07): the host a member reads is the parsed hostname (notify.HostOf), never a split on / — a query with no path before it had carried the credential; migration 0148 scrubs the audit rows that kept one notify.TestDestinationValidationRejectsUnsafeRedirectAndDNSPaths, varschema.TestTheRangesTheReviewNamedAreRefused, notify.TestASendFailureNeverNamesTheDestination, handlers.TestNotificationChannelLifecycleWithoutSQL, handlers.TestChannelMutationAndTestDeliveryRespectTenantTeamScope, handlers.TestChannelSecretsAreRedactedAndRotationWorks, handlers.TestAQueryOnlyDestinationNeverShowsItsQuery medium held
24 Object content is never read under the inventory role; a Cost & Usage Report is read under a second role the account scopes to that report's bucket and prefix cur.S3Stores assumes only the report's own role, with the tenant's external id, and refuses a report with none before any request; deploy/cloudformation/cost-report-role.yaml grants s3:ListBucket bound to the prefix and s3:GetObject on the prefix's objects, nothing else; the reader role keeps its no-object-read rule cur.TestCURAccessIsRestrictedToApprovedReportPrefix, cur.TestInventoryRoleStillCannotReadCustomerObjects, cur.TestCURConnectionReportsMissingPermissionAndIngestsFixture, onboarding.TestKnownContentBearingCallsAreNeverGranted medium held
20 An unsafe request on a session route, or on login/logout, comes from this deployment's own browser context; a body on such a route is JSON The browser boundary in the guarded router (middleware/csrf.go), before the body is read and before the credential is resolved: Sec-Fetch-Site: same-origin/none, or same-site/cross-site only from an Origin in CORS_ORIGINS; without Fetch Metadata the Origin (then the Referer's origin) must be the server's host or listed, and null never is; a request with none of them is not a browser's. Bodies must be application/json. Bearer routes are exempt by credential kind; a read token on a session route is exempt from nothing, and only ever marks a GET. SameSite=Lax remains, and is not the boundary — it is about the site, and a same-site sibling carries the cookie middleware.TestUntrustedSiblingCannotMutateSessionAPI, middleware.TestSimpleTextPlainJSONCannotBypassCSRF, middleware.TestAllowedPortalAndBearerAgentRemainFunctional, middleware.TestAReadTokenRouteKeepsTheSessionsBrowserBoundary, handlers.TestLoginLogoutRefuseAnUntrustedOrigin; TestLoginLogoutCSRFPolicyInRealBrowser (web/e2e, a real Chromium against a same-site sibling, on demand) medium held
21 A request body is read within a budget, a response written within one, and a large route reads only so many bodies at once Per route, from the contract, set on the connection by the guarded router before the body is read (middleware/budget.go): 30 s for a body unless x-body-timeout-seconds (120 s for the snapshot batch, 60 s for agent logs and template sources), 10 s to drain a bodiless route's unread body (none for a request without one: the deadline bounds reading, never the handler), 60 s past that for the response; the claim handler extends its own deadline to its poll, which PROVISIONING_CLAIM_POLL_TIMEOUT bounds at 4 minutes under the server's 5-minute ceilings; x-max-inflight caps concurrent bodies (2 batches, 16 log batches) with 429 and Retry-After; panorama_http_requests_inflight shows what is being read middleware.TestSlowBodyTimesOutWithinBudget, middleware.TestStalledUploadsCannotExhaustConfiguredConcurrency, middleware.TestEveryBodyRouteHasATimeBudget, middleware.TestBodylessRouteStillBoundsAnUnreadBody, middleware.TestBodylessRouteHandlerOutlivesItsReadDeadline, handlers.TestLargeValidIngestIsNotRejectedByGlobalTimeout, handlers.TestLongPollClaimSurvivesReadDeadlinePolicy, cli.TestAPIServerCarriesTheCeilings, config.TestServerValidation low held
25 A user holds at most a set number of sessions at once auth.Sessions: SESSION_MAX_PER_USER, 5 unless set and at most 100. The login past it ends that user's oldest sessions, sessions of an older password and expired ones first, in the transaction that creates the new one and under a per-user advisory lock, so logins that race at the cap cannot both find room; a login whose password changed after it verified it is refused and ends nothing (the login compares the account's current password version with the one it verified, and the store, under the lock, refuses a version older than a session the user holds); and every request ends a session that more of its user's newer sessions than the cap have overtaken, so a lowered cap, or the first release with one, reaches the sessions already open. During a rolling deploy, a replica still on the earlier release admits the sessions it already knows until it is replaced auth.TestALoginPastTheCapEndsTheOldestSession, auth.TestTheCapEndsAnExpiredSessionBeforeALiveOne, auth.TestConcurrentLoginsAtTheCapCannotExceedIt, auth.TestALoweredCapReachesSessionsAlreadyOpen, auth.TestAStaleLoginNeitherEvictsNorTakesAPlace, auth.TestAnOlderPasswordsSessionGoesBeforeALiveOne, auth.TestALoginInFlightAcrossAPasswordResetIsRefused, auth.TestALoginInFlightLeavesTheNewPasswordsSessionAlone, handlers.TestTheLoginPastTheCapEndsTheFirstSession, config.TestServerValidation medium held
26 A destructive administrator action needs the password again, on the session that takes it, within ten minutes The routes the contract marks x-step-up: the guard derives them as it derives the role and, after the role check, refuses a session with no password check in auth.StepUpWindow (10 minutes) with 403 step_up_required; POST /auth/step-up checks the password under the login's per-account throttle and records it on that session alone; the mark on a bearer route, or one with no role, is a startup error; every administrator DELETE carries it, and so does every administrator update (PATCH or PUT) that can archive or switch off what exists, through a boolean archived, enabled, disabled or active in its body. The portal asks once however many refusals arrive together, and every waiting request settles on the answer middleware.TestEveryDestructiveRouteAsksForAStepUp, middleware.TestARouteThatArchivesOrDisablesAsksForAStepUp, middleware.TestPoliciesTheContractMustNotDeclare, auth.TestAStepUpIsRecordedOnItsOwnSession, auth.TestAStepUpNeedsTheRightPassword, handlers.TestADestructiveRouteWaitsForTheSessionsOwnStepUp medium held
27 A sign-in through a tenant's identity provider opens only that tenant, as the account the provider vouched for; where the tenant enforces it, a password opens nothing but a break-glass administrator's auth.SSO: the OpenID Connect code flow with PKCE and a nonce. The attempt is kept in control.sso_attempts under its state's hash, taken once, for ten minutes, and names the tenant that started it. The ID token is verified against that tenant's registered issuer and client (signature, issuer, audience, expiry) and must carry the attempt's nonce. An address the provider says is unverified is refused, and one it does not vouch for either way is accepted only in the domains the tenant named; a first sign-in creates an account only in those domains when there are any. The account is the provider's subject: looked up by it first, bound once, and unique within the tenant and the issuer (0179), so a changed email or two first sign-ins at once make no second account; an address already bound to another subject is refused. A callback must carry the attempt cookie of the browser that started it. Every request of the flow (discovery, the token exchange, the keys) goes through a client that accepts only https on port 443 to hosts whose every address is public, checks the address it dials, and follows no redirect; an issuer that fails the policy is not saved. A successful sign-in refunds the throttle charges its own start and callback made, and nothing earlier. An account without a usable password confirms a step-up by signing in again at the provider (prompt=login, max_age=0), accepted only for the same session's user and within five minutes. Saving or removing the registration is audited in the same transaction, without the secret. A first sign-in's account is created with its identity in one insert, so a concurrent one under another email leaves no account behind; a new issuer is refused while accounts are bound to another until an administrator releases them, audited; migration 0179 refuses, and puts itself back, over the duplicates single sign-on's first version could leave, and 0180 takes older binaries out of service. A binding commits only under the tenant's current issuer (0181), which it reads under the registration's row lock, the lock the save takes before it counts: a sign-in racing an issuer change is refused, or is counted and refuses the save. The session is issued by the password path's own Sessions.Create; the return path is a path on this site; the client secret is never returned. Enforced mode answers a correct password with 403 sso_required, and a wrong one as any wrong password auth.TestASignInThroughTheProviderIssuesASession, auth.TestAnAttemptIsTakenOnceAndExpires, auth.TestTheIDTokenMustSayWhatTheAttemptNeeds, auth.TestTheAccountIsItsSubjectsAndItsTenants, auth.TestEnforcedSignOnKeepsPasswordsForBreakGlassOnly, auth.TestTheReturnPathStaysOnTheSite, handlers.TestASignInThroughTheProviderEndsInASessionCookie, handlers.TestAFailedSignInGoesBackToTheLoginPage, handlers.TestTheReturnPathStaysOnThisSite, handlers.TestEnforcedSignOnAnswersAPasswordWithSSORequired, handlers.TestTheRegistrationNeedsTheCapabilityAndKeepsItsSecret, auth.TestTheSignInNeverDialsAPrivateAddress, auth.TestAnIssuerOutsideThePublicInternetIsRefused, auth.TestTheOutboundPolicyRefusesWhatIsNotPublicHTTPS, auth.TestTheClientChecksTheAddressItDials, auth.TestTheClientRefusesAPrivateEndpointTheDocumentNames, auth.TestTheClientFollowsNoRedirect, auth.TestSuccessfulSignInsLeaveOnlyTheEarlierFailure, auth.TestAStableSubjectWhoseEmailChangesIsOneAccount, auth.TestConcurrentFirstSignInsMakeOneAccount, handlers.TestAStateFromOneBrowserIsRefusedInAnother, handlers.TestAnAdministratorWhoOnlySignsOnStepsUpAtTheProvider, handlers.TestAProviderChangeIsAuditedWithoutItsSecret, auth.TestFirstSignInsOfOneSubjectUnderTwoEmailsMakeOneAccount, auth.TestAnIssuerChangesOnlyOnceItsAccountsAreReleased, auth.TestDuplicateIdentitiesRefuseTheUpgradeUntilReleased, auth.TestAFailedIdentityBuildIsPutBackBeforeTheNextRun, auth.TestBinariesBeforeTheIdentityFloorStopServing, handlers.TestAnIssuerChangeIsRefusedUntilTheAccountsAreReleased, auth.TestAnOldProviderSignInCannotBindUnderANewIssuer, auth.TestAnOldProviderSignInCannotBindAnExistingAccountUnderANewIssuer high held
28 The Admin area's reads are an administrator's alone, and the audit log a tenant reads is its own GET /admin/audit and GET /admin/overview carry x-role: admin, which the guarded router derives from the contract and enforces before the handler runs. The audit log is read in the tenant's transaction, under audit_log's forced row-level security (0128) and with the tenant in the query besides, newest first by id and a page at a time by an id cursor; the overview counts the users in the same kind of transaction and takes the licence as GET /entitlements answers it. The portal's Admin area refuses a member by name and asks the API for nothing handlers.TestTheAuditLogIsTheTenantsOwnNewestFirst, handlers.TestTheAuditLogIsAnAdministratorsAlone, handlers.TestTheAdminOverviewIsOneRead, middleware.TestThePolicyDerivedFromTheContract high held
29 A Google Cloud project is read with metadata-only permissions, as the platform's own identity, and a module of one cloud never runs against an account of the other The reader's custom role (deploy/gcp/reader-role.yaml) grants exactly the Google collectors' permissions and verification's, and none of the content-bearing ones its test names (table rows, routine bodies, a function's environment, Datastore documents, keys, secrets, logs, an instance's own read, the project's shared metadata, a cluster's credentials, a Run service's or revision's own read, messages). Twelve granted permissions would allow more than the product reads (MILESTONE_1 §13.3, the owner's decisions of 2026-09-20 and, for the function listing, 2026-10-09): compute.instances.list returns an instance's metadata items, container.clusters.list a cluster's master credentials and node metadata, run.services.list a Cloud Run service's containers' environment variables and their values, arguments and commands and its build's configuration, pubsub.topics.list a topic's message transforms (JavaScript source), pubsub.subscriptions.list the same transforms and a push endpoint that may carry a credential, compute.backendServices.list, compute.regionBackendServices.list and the get of each (which the backend health read needs) a backend service's custom header values, an AWS access key's identifier and an IAP client secret's hash, compute.urlMaps.list and compute.regionUrlMaps.list the header values a URL map's routes add and match, and cloudfunctions.functions.list a function's build and service configuration (its environment variables and their values, its secret references) and its event trigger; none can be granted without the field. Every Google request the first and second waves, the function listing and region discovery make carries a partial-response mask naming only the metadata shown, so that content never crosses the wire, and an answer carrying an excluded path anyway — at any depth a URL map nests one — is refused whole and never decoded (gcpclient.GetMasked); the load balancers' backend health is getHealth, a POST that reads, and gcpclient.PostMasked sends it under the same rule and refuses any POST that is not a named read before it leaves; the role's header, capabilities.md and this line say plainly that the permission itself would allow more. The platform holds no key file: it exchanges its own AWS credentials at Google's Security Token Service and impersonates the project's reader. Its token carries the cloud-platform scope, the simplest single scope every method the reader calls accepts (IAM also lists its own, and Cloud Monitoring does not accept the read-only one); a scope can only narrow a token, and what the reader can do at all is the custom role's. The scheduler pairs each module with accounts of its cloud, and the worker picks credentials by the module's cloud and refuses a job whose module and account are different clouds before it makes any onboarding.TestTheGoogleReaderRoleIsAnExactAllowlist, onboarding.TestKnownContentBearingGooglePermissionsAreNeverGranted, gcpclient.TestAReaderSignsInThroughThePlatformsAWSIdentity, gcpclient.TestTheScopeIsOneEveryMethodAccepts, worker.TestAModuleOfTheOtherCloudIsRefused, scheduler.TestAModuleRunsOnlyForAccountsOfItsCloud, onboarding.TestTheContentBearingGrantsAreExactlyTheMaskedOnes, gcpclient.TestAnAnswerCarryingAnExcludedFieldIsDroppedWhole, compute.TestEveryRequestCarriesItsMaskAndNoneAsksForMetadata, compute.TestAnAnswerCarryingMetadataIsDroppedNeverStored, container.TestEveryRequestCarriesItsMaskAndNoneAsksForCredentials, container.TestAnAnswerCarryingCredentialsIsDroppedNeverStored, discovery.TestDiscoveryRefusesContentAndKeepsWhatItFound, run.TestEveryRequestCarriesItsMaskAndNoneAsksForTheEnvironment, run.TestAnAnswerCarryingTheEnvironmentIsDroppedNeverStored, pubsub.TestEveryRequestCarriesItsMaskAndNoneAsksForContent, pubsub.TestAnAnswerCarryingContentIsDroppedNeverStored, discovery.TestTheSecondWavesDiscoveryIsNamesOnlyAndRefusesContent, loadbalancer.TestEveryRequestCarriesItsMaskAndNoneAsksForContent, loadbalancer.TestAnAnswerCarryingContentIsDroppedNeverStored, gcpclient.TestOnlyTheReadingPostsAreSent, gcpclient.TestAReadingPostCarriesItsMaskAndBodyAndRefusesContent, gcpclient.TestADeepWildcardFindsTheFieldAtAnyDepth, functions.TestEveryRequestCarriesItsMaskAndNoneAsksForTheEnvironment, functions.TestAnAnswerCarryingTheEnvironmentIsRefusedWhole high held
30 A person reaches only their own sessions, inbox and settings Every route behind a person's own settings takes the account from the session and nothing from a body or a path: /auth/me, /auth/me/notifications, /auth/me/teams, /auth/sessions and /auth/inbox. A session is named by an id derived from its token's hash, never stored and never a token; ending one is scoped to the tenant and the account, a session that is not the person's is not found, and the session making the request cannot be ended through it. The inbox is read and marked in the tenant's transaction under row-level security, with the account in the query; an id that is not the person's is passed over. What the notifier writes to it is only what its person may see: an administrator every account and team, a member their teams and their teams' accounts, an account no team owns the administrators alone notify.TestAnInboxHoldsOnlyWhatItsPersonMaySee, handlers.TestAPersonSeesTheirOwnSessionsAndEndsTheOthers, handlers.TestNobodyReachesAnotherPersonsSessions, handlers.TestAPersonsInboxAndChoicesAreTheirOwn, notify.TestReadingAndMarkingTouchOnlyYourOwn, middleware.TestThePolicyDerivedFromTheContract high held
31 An AI review reads, and its answer cites, only what the people who read the answer may see The review records its audience when it opens (reviews/audience.go): the subject account's team, intersected with the asker's scope when a person asked, narrowed on reopen. Reads and answers are held to that audience narrowed by the readers as they are now (EffectiveAudienceTx), and a member reads a review, or its inbox entry, only while their teams now cover the recorded audience (Covers; Visible.where and InboxWhere, one SQL spelling, checked on every read — an entry carries the answer, so it names its review and is filtered when read, not only when written) — so a requester who leaves the team, or the team an account moves to, reads nothing built on what they cannot see. Every /ai/reviewer/* read the team axis scopes — cost anomalies, forecasts, the change feed, an investigation, an acknowledgement's note — runs in that audience rather than an administrator's width, and answer acceptance refuses evidence outside it (anomaly.VisibleTx, the list's own predicate); an anomaly review reads its own anomaly in any status through the same predicate (anomaly.GetIn), and one outside the audience answers as one that does not exist handlers.TestAnEngineersReviewReadsAndCitesOnlyWhatTheEngineerMaySee, handlers.TestAReviewsAnomalyItCannotReadIsNotFound, handlers.TestANotifierReviewIsBoundByItsAccountsTeam, handlers.TestAReopenedReviewNarrowsAndAMembershipNeverWidensIt, handlers.TestAnAdministratorsReviewOfAnUnownedAccountReadsTheTenant, handlers.TestARequesterWhoLeavesTheTeamTakesNothingOfItWithThem, handlers.TestATeamAnAccountMovesToReadsNothingOfTheOldTeams, handlers.TestAnAnswerStopsReachingAReaderWhoLostTheTeam, handlers.TestAnInboxEntryIsReadOnlyWhileItsReviewIs, handlers.TestAnAdministratorsInboxEntryFollowsTheirRole, handlers.TestAnAIReviewEntryWithNoReviewIsAnAdministratorsAlone, notify.TestAChannelTestsReviewIsTheTesters, org.TestIntersectIsWhatBothSee medium held
32 Triage's decision sidecar, which has no authentication, is reachable only by notify and serve, reaches nothing itself, and is never pointed at a public address Compose runs it under the triage profile with no published port, on an internal network only notify and serve share, read-only and with every capability dropped; Kubernetes' components/triage puts it behind a ClusterIP Service and a NetworkPolicy admitting those two pods on its one port and no egress (TRIAGE_SPEC §10). The image is built from pinned sources and starts offline (HF_HUB_OFFLINE); every baked file matches SHA256SUMS or the build fails. The roles refuse, as they start, a TRIAGE_URL for the unauthenticated provider that is not a private address or service name, a key for it, and an alias model (internal/triage LoadConfig, read by config.Triage) deploy.TestTheTriageSidecarInComposeIsInternalOnly, deploy.TestTheTriageComponentAdmitsOnlyNotifyAndServe, deploy.TestTheTriageImageIsBuiltFromPinnedSources, config.TestTriageMisconfigurationStopsNotifyAndServe, triage.TestLoadConfigRefusals medium held
33 Triage cannot keep from people a floor (a resource failed, a budget past 100%) or an event its model failed, timed out on or was not configured for; it routes nothing to none but on the exact model and question a kind was switched against; and the hosted provider is shown no name, tag or id unless the operator shares it The floors are decided in code before the model is asked and route to people in every mode; a model error is notify; a different model or question drops the kind to shadow, audited, before any of its events is narrowed (notify enforced, Gate.decide). The switch to enforce is an administrator's, audited, and refused without a decision model or the kind's shadow record. TRIAGE_HOSTED_SHARE is empty by default, so the hosted state is redacted of names, tags and ids notify.TestEnforceNeverTakesAFloorFromPeople, notify.TestEnforceRoutesByOutcome, notify.TestAModelChangeDropsEnforceToShadow, notify.TestTheSwitchToEnforceNeedsTheShadowRecord, notify.TestTheHostedProviderSeesNoNameTagOrID, triage.TestOnlyTheHostedProviderIsRedacted medium held
34 Google Cloud's billing export is read only where the customer binds two predefined roles on the export's project and dataset, never under the reader's metadata-only role, and each read is one capped, partition-filtered query that writes nothing when refused The reader's custom role still grants neither bigquery.jobs.create nor bigquery.tables.getData; the read needs roles/bigquery.jobUser on the project holding the export and roles/bigquery.dataViewer on the export's dataset alone, bound by the customer (deploy/gcp/reader-role.yaml's header, capabilities.md). gcpbilling.Read sends one POST, BigQuery's jobs.query, carrying NewQuery's SQL, the export's byte cap as maximumBytesBilled and the partition range to the pull's start, then reads only that job's pages; a query over the cap or a reader without the roles is refused whole, nothing of the window is written, and the run reports the remedy instead of retrying. The export's conversion rate is never read gcpbilling.TestTheReaderSendsTheBoundedQueryAndReadsEveryPage, gcpbilling.TestARefusedQueryReadsNothing, gcpbilling.TestARefusedPullIsReportedAndKeepsTheLastPullsRows, gcpbilling.TestQueryBytesAreBoundedAndPartitionFiltered, gcpbilling.TestNoFXAnywhere, onboarding.TestKnownContentBearingGooglePermissionsAreNeverGranted medium held

No open highs as of the evidence run of 2026-10-28 (line 10b was open for the day between the decision and its implementation, on 2026-09-06). Two lines are honest about being narrower than their name: line 13's uniform timing is measured for the login path and the token parsers, not for every comparison in the codebase; line 15's budget is per address, so a distributed flood is bounded per source, not in total — that is the load balancer's job, not this process's. Line 21 bounds each request and each large route, not the process's total connections: that ceiling is the edge's too, and deploy/proxy/nginx.conf.example shows where it goes.

What this does not claim

  • Every budget shared across replicas (S03). The per-route, per-address rate limits (line 15) are counted once for every replica. Two budgets are still counted in each process's memory: the login throttle (line 14) and a read token's per-token and per-tenant budgets. serve runs two replicas in the reference manifests, so each of those is twice its number across the deployment, and a rollout starts its count again; the per-address limit on /auth/login itself is shared, so the total guessing rate from one address is held to it whatever the replica count. Accepted: the throttle's lockouts slow online guessing — argon2 bounds what each attempt costs — and do not have to be exact. While the database does not answer, the shared limits fall back to the same per-process count. Revisit when serve autoscales, or on the first credential-stuffing incident.
  • /metrics on serve's own listener (S05). It is unauthenticated, as the contract says, and discloses queue depth, error rates and traffic volume — no tenant. deploy/proxy/nginx.conf.example refuses it at the edge and Prometheus scrapes serve directly; an edge of your own must do the same (the runbook's "Watching the roles"). Moving serve's scrape to METRICS_LISTEN, as the other four roles have it, is the fix that needs no edge; it changes the contract and every scrape configuration, and is taken when serve's listeners are next changed.
  • Compressed responses that hold a secret. Responses are gzipped for GET only. Compression over TLS leaks how much a body shrank, and a body holding a secret beside input an attacker chose can be read that way (BREACH); every response that hands out a token or a password answers a POST and is sent as it is. A GET that returned a credential would have to stay out of compression — middleware.Compress says so.
  • A per-channel budget on the test delivery (S09). The test is limited to 30 a minute per address, not per channel: an administrator of the tenant sending to a destination the tenant configured, which has passed line 23's checks. Revisit if a tenant is seen using it against a third party.
  • Encryption at rest is the database's and the host's. The platform stores no customer cloud credential, no Terraform state and no git credential, which is why that is enough for a self-hosted deployment.
  • SOC 2 is deferred until a customer's procurement asks for it in writing (p5 kickoff, 2026-09-04).

2. The external pen test

To be booked before the first pilot, by the owner: the vendor, the dates and the authority to test are the owner's. It is scoped to what a buyer's security team asks about and nothing generic. The scope, the test environment and the triage are in platform/docs/security-assessment.md:

  1. Cross-tenant isolation under RLS. Two tenants, credentials for both, every read and write route; the tester's goal is any row of the other tenant through the API, through a crafted credential, or through an ingest or agent token. Lines 1–5 and 11 above are the claims under test.
  2. The agent protocol. A hostile agent with a valid token: claim another account's work, replay to duplicate an apply, exfiltrate a credential through a log line, reach the platform token from inside OpenTofu. Lines 10–12 are the claims under test.
  3. The cloud identities. The reader role, the cost-report role and the platform's own identity each grant only what their lines say, and the reader role reads no object content. Lines 10 and 24 are the claims under test.
  4. The browser. The session cookie, the CSRF boundary from a hostile same-site page, the headers and the content-security policy. Lines 16, 19, 20 and 22 are the claims under test.

Findings are triaged by the rule in §4 and land in this table as new lines with their tests.

3. Scanners

Three run in CI, in the job that builds the thing they scan, on every train before it reaches main (required checks since 2026-10-18; platform/EXECUTION.md §6), and the release gate requires them. Trivy's database is fetched once per job with retries over two registries (deploy/trivy-db.sh), so a registry outage is not a red build:

Scanner Where What it fails on
govulncheck every Go module, each on the Go its own go.mod declares: the go, agent and reviewer jobs after their tests, and go-tools for www/tools and web/e2e/tools (internal/deploy TestEveryGoModuleIsScannedByGovulncheck fails on a module no job scans) any vulnerability in a function the binary actually calls (call-graph aware; an unreached vulnerable function is reported, not failed)
npm audit --omit=dev --audit-level=high the web job, after the build a high or critical in a dependency that ships in the built portal; the full audit including dev tooling is printed and does not fail
Trivy CLI 0.74.0, a pinned release verified by checksum, two passes the image job, after the image is built and before it is pushed — once per platform (linux/amd64 and linux/arm64, each its own glibc and binaries), read from the job's own registry the report pass prints every finding at every severity, fixed or not, and never fails; the gate pass fails on a critical with a fix available, in an OS package (distroless/base-nossl's glibc, tzdata and their neighbours) or in either Go binary's module list — panorama and the signing helper — and prints nothing else

4. The triage rule

A scanner finding is handled by severity and by whether it is reached, not by whether CI is red:

  1. Critical or high, reached, fix available: the build is red and stays red; the dependency is bumped in a commit of its own, the same day. No suppression.
  2. Critical or high, reached, no fix: the build is red; the finding is suppressed with an expiry (govulncheck: not supported — the module is replaced or the call removed; npm: an overrides entry with a comment; Trivy: an entry in .trivyignore.yaml at the repository root, scoped to the binary's path, with an expired_at at most 60 days ahead and the reason, read only by the platform image's gates — internal/deploy TestThePlatformImageIgnoreFileIsScopedAndExpires) and a line in this file under "Accepted findings" with the mitigation. Reviewed at the next release.
  3. Not reached (govulncheck says so; or the package is dev-only for npm): recorded, not suppressed, bumped with the next routine update.
  4. Medium and below: bumped with the next routine update; never suppressed, because a suppression that outlives its reason is how the list stops being read.

A suppression without an expiry date or without a line in this file is a lint failure of the reviewer, not of the tool.

Accepted findings

Recorded 2026-09-04, from the first run: five advisories in modules the binary requires but did not call (govulncheck, call-graph aware; rule 3), whose fixed versions needed the Go 1.26 line. Resolved 2026-09-11 by the Go 1.26 move: x/crypto v0.57.0 and kin-openapi v0.149.0 carry the fixes. The move was brought forward because Trivy's gate reads module versions, not call graphs, and failed the image on kin-openapi's critical GHSA-r277-6w6q-xmqw — which rule 3's "recorded, not suppressed" had left standing. One remains, with no fix published:

Advisory Module Fixed in Why accepted
GO-2026-5932 golang.org/x/crypto v0.57.0 no fix published not reached (openpgp)

Resolved 2026-09-11: GO-2026-6355 and GO-2026-6354 (x/crypto, fixed in v0.56.0), GO-2026-6112 and GO-2026-6095 (kin-openapi, fixed in v0.144.0). The same day the agent module's golang.org/x/text v0.25.0, reached through the HCL parser (GO-2026-5970, fixed in v0.39.0), went to v0.42.0 under rule 1.

Review: at the next release.

Recorded 2026-09-11, from the first scan of the image carrying AWS's IAM Roles Anywhere signing helper. aws_signing_helper 1.8.5 is AWS's own release binary, the latest, pinned by the checksum AWS publishes; its modules are AWS's to bump, and it moves when AWS releases the next one. Trivy reads its module list; govulncheck in binary mode finds the SSH and OpenPGP symbols linked in, and in source mode, run on the helper's v1.8.5 tag, finds none of them called — the helper's own code imports only pbkdf2, pkcs12 and scrypt from x/crypto, and makes no SSH connection and serves none. Rule 3 applies.

Advisory Module (in the helper) Fixed in Why accepted
GO-2026-6303 (CVE-2026-56854, high) golang.org/x/crypto v0.53.0 v0.55.0 not called: ssh.NewServerConn; the helper serves no SSH
GO-2026-6355 golang.org/x/crypto v0.53.0 v0.56.0 not called: ssh.Dial and friends
GO-2026-6354 golang.org/x/crypto v0.53.0 v0.56.0 not called: as above
GO-2026-5932 golang.org/x/crypto v0.53.0 no fix published not called: openpgp

Review: at the next helper release, or at the next platform release, whichever is first.

Recorded 2026-10-28 (the owner's decision the same day), when Trivy's database gained a critical in the Go standard library and the image gate failed on the same helper, the panorama binary beside it clean. The helper 1.8.5 is still AWS's newest release, built with Go 1.26.6; its standard library cannot be rebuilt here. The defect is reachable only on Windows, and the image is Linux. Accepted in .trivyignore.yaml for usr/local/bin/aws_signing_helper alone, until 2026-11-30: the report pass still prints it on every run, and the agent image, whose OpenTofu had the same finding, took OpenTofu 1.12.9 (Go 1.26.10) instead of an exception.

Advisory Module (in the helper) Fixed in Why accepted
CVE-2026-56857 (critical) stdlib v1.26.6 Go 1.26.9, 1.27.2 Windows only (os.Root's Mkdir/MkdirAll follow a junction out of the root); no helper built with a fixed Go exists; expires 2026-11-30

Review: when AWS ships a helper built with Go 1.26.9 or later (drop the entry), or on 2026-11-30, whichever is first.

5. Reporting a vulnerability

Write to contact@cloudpanorama.com with "security" in the subject. Reports are acknowledged within two working days; a fix for a confirmed high ships before any other work. Please do not open a public issue for a security finding.

Generated from observability/docs/security.md (59918757585a) by www/tools/docs; the value in brackets is a hash of the source's content, so a page changes only when its source does.