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 (platform task p5_7). 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
(p7_28). 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 when it
runs, but pushes skip CI by the owner's decision of 2026-09-12, so the
evidence run is what a status rests on.
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 (p7_48, 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 (p7_48, S06: it was by package, so any new file in cli — serve.go included — passed); partition DDL a second one (db, scheduler); both enforced by the same CI guard |
scripts/check-no-raw-pool.sh; db.TestMaintenanceFunctionsAreNotCallableByTheAppRole, db.TestOnlyTheOwnerCanSupplyTheClock |
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 | middleware.TestTheTenantComesFromTheCredential, middleware.TestAHandlerIsNeverReachedWithoutATenant, middleware.TestAPublicRouteLeavesNoTenantBehind, 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 | middleware.TestACredentialOnlyOpensTheRoutesItIsFor, middleware.TestARouteWithNoPolicyIsRefused, middleware.TestAPolicyMatchingNoRouteIsAlsoDrift |
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 p4_1), 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 (p7_48, 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 shared decoders answer 413 when a capped stream runs out. The agent's register, claim and heartbeat ask for 1,200 a minute (p7_48, 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 (p7_48, S02) |
middleware.TestUnauthenticatedRoutesAreRateLimited, middleware.TestEveryBodyRouteHasASizeLimit, middleware.TestEveryJSONBodyIsCheckedAgainstItsSchema, middleware.TestTheBodyIsCheckedBeforeTheHandler, handlers.TestADeclaredMaxLengthIsEnforced, middleware.TestARateLimitedRouteAnswers429PerAddress, 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 (p7_48, 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, p7_20): 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 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 |
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 p7_48 (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 (p7_21) 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 (p7_22) |
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, p7_18), 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. 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, 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, p7_19): 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, 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, 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 (p7_73): 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 (p7_72): 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 (p5_5): 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 (p7_88) 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, p7_82) 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). 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 |
high | held |
| 30 | A person reaches only their own sessions, inbox and settings | Every route behind a person's own settings (p7_89) 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 |
No open highs as of the evidence run of 2026-09-17 (line 10b was open for
the day between the decision and its implementation, p7_2, 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
- Budgets shared across replicas (p7_48, S03). The login throttle and
every per-address rate limit are counted in each process's memory.
serveruns two replicas in the reference manifests, so each budget is twice its number across the deployment, and a rollout starts every count again. Accepted: the budgets slow online guessing — argon2 bounds what each attempt costs — and do not have to be exact; a shared counter would put a database write on every login attempt and every public request. Revisit whenservescales past two replicas or autoscales, or on the first credential-stuffing incident. /metricsonserve's own listener (p7_48, S05). It is unauthenticated, as the contract says, and discloses queue depth, error rates and traffic volume — no tenant.deploy/proxy/nginx.conf.examplerefuses it at the edge and Prometheus scrapesservedirectly; an edge of your own must do the same (the runbook's "Watching the roles"). Movingserve's scrape toMETRICS_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 whenserve's listeners are next changed.- Compressed responses that hold a secret (p7_51). 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.Compresssays so. - A per-channel budget on the test delivery (p7_48, 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 (p5_7): 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
(p7_28):
- 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.
- 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.
- 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.
- 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. Pushes skip CI by the owner's decision of 2026-09-12; the scanners run again when CI does, and the release gate (p7_31) requires them:
| Scanner | Where | What it fails on |
|---|---|---|
govulncheck |
the go and agent jobs, after the tests |
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 |
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, since p7_34) 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:
- 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.
- 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
overridesentry with a comment; Trivy: a.trivyignoreline with the date and the reason) and a line in this file under "Accepted findings" with the mitigation. Reviewed at the next release. - Not reached (govulncheck says so; or the package is dev-only for npm): recorded, not suppressed, bumped with the next routine update.
- 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 (p7_59): 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 (p7_34), 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.
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.