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_ role; every tenant query runs inside db., which sets app.. tenants itself too, since migration 0158 (S07): the app role sees its own tenant's row and no other |
db., db., db., db., db., db., db., db., db., org., db. (provisioning), db. |
high | held |
| 2 | Nothing reaches the database outside the tenant transaction | CI greps for raw pool access outside internal/db (scripts/, 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. 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.: 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/; db., db., db. |
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., middleware., middleware., middleware., handlers., handlers., handlers., handlers., db. |
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., middleware., middleware., middleware., middleware., middleware., handlers. |
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., auth., middleware., middleware. |
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_, which serve refuses beside a licence |
middleware., handlers., config., cli., entitlements. |
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., cli., entitlements. |
medium | held |
| 9 | The public status page carries no identifier | Its own response type, closed schemas, and a test that walks the body | handlers., handlers., handlers. |
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. (every property name in the agent API is checked against the credential rule from), agent., scrub. |
high | held |
| 10b | The agent's platform token cannot be obtained by the code it runs | Two roles of one image (D18, platform/): 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., agent., agent., agent. (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., agentapi., drift., events. |
high | held |
| 12 | Every write is replayable without duplication | Idempotency keys, run ids, claim ids; the replay matrix | handlers., agentapi., ingest. |
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., auth., 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. (AdmitAll), keyed by the client the deployment declares (CLIENT_), 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; Test, Test; 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. (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., the unlogged rate_: 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., middleware., middleware., middleware., handlers., middleware., middleware., middleware., db., middleware. |
low | held |
| 16 | Every response carries the security headers | middleware.: a same-origin CSP with no inline allowance, nosniff, Referrer-Policy, a Permissions-Policy that turns everything off, COOP, and HSTS when COOKIE_ |
middleware., middleware. |
low | held |
| 17 | CORS is an allowlist, never *; a 401 still carries it |
middleware., validated at startup |
middleware., middleware., middleware. |
medium | held |
| 18 | The image holds one static binary, the portal and nothing else; no secret is baked in | Distroless, non-root; . 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. |
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.: SESSION_TTL (at most 12 h) is how long one lasts unused, and a refresh extends by it but never past auth. from the login (S04: the line said "fixed" while every portal load could extend it for ever); COOKIE_ |
handlers session tests (auth_), Test |
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.): 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. in the transaction that writes the hash, a login records on its session the version of the hash it verified (Repo., Sessions.), 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., handlers., handlers., handlers., handlers., cli., auth., auth., auth., auth., handlers., handlers., handlers., handlers., handlers. |
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. 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., 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; Check 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.), 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., varschema., notify., handlers., handlers., handlers., handlers. |
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. assumes only the report's own role, with the tenant's external id, and refuses a report with none before any request; deploy/ grants s3: bound to the prefix and s3: on the prefix's objects, nothing else; the reader role keeps its no-object-read rule |
cur., cur., cur., onboarding. |
medium | held |
| 20 | An unsafe request on a session route, or on login/ |
The browser boundary in the guarded router (middleware/), 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_; 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/. Bearer routes are exempt by credential kind; a read token on a session route is exempt from nothing, and only ever marks a GET. Same remains, and is not the boundary — it is about the site, and a same-site sibling carries the cookie |
middleware., middleware., middleware., middleware., handlers.; Test (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/): 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_ 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_ shows what is being read |
middleware., middleware., middleware., middleware., middleware., handlers., handlers., cli., config. |
low | held |
| 25 | A user holds at most a set number of sessions at once | auth.: SESSION_, 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., auth., auth., auth., auth., auth., auth., auth., handlers., config. |
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. (10 minutes) with 403 step_; POST / 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., middleware., middleware., auth., auth., handlers. |
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. 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.; the return path is a path on this site; the client secret is never returned. Enforced mode answers a correct password with 403 sso_, and a wrong one as any wrong password |
auth., auth., auth., auth., auth., auth., handlers., handlers., handlers., handlers., handlers., auth., auth., auth., auth., auth., auth., auth., auth., auth., handlers., handlers., handlers., auth., auth., auth., auth., auth., handlers., auth., auth. |
high | held |
| 28 | The Admin area's reads are an administrator's alone, and the audit log a tenant reads is its own | GET / and GET / 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 / answers it. The portal's Admin area refuses a member by name and asks the API for nothing |
handlers., handlers., handlers., middleware. |
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/) 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_compute. returns an instance's metadata items, container. a cluster's master credentials and node metadata, run. a Cloud Run service's containers' environment variables and their values, arguments and commands and its build's configuration, pubsub. a topic's message transforms (JavaScript source), pubsub. the same transforms and a push endpoint that may carry a credential, compute., compute. 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. and compute. the header values a URL map's routes add and match, and cloudfunctions. 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.); the load balancers' backend health is getHealth, a POST that reads, and gcpclient. sends it under the same rule and refuses any POST that is not a named read before it leaves; the role's header, capabilities. 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., onboarding., gcpclient., gcpclient., worker., scheduler., onboarding., gcpclient., compute., compute., container., container., discovery., run., run., pubsub., pubsub., discovery., loadbalancer., loadbalancer., gcpclient., gcpclient., gcpclient., functions., functions. |
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, /, /, / 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., handlers., handlers., handlers., notify., middleware. |
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/): 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 (Effective), and a member reads a review, or its inbox entry, only while their teams now cover the recorded audience (Covers; Visible. 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 / 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., the list's own predicate); an anomaly review reads its own anomaly in any status through the same predicate (anomaly.), and one outside the audience answers as one that does not exist |
handlers., handlers., handlers., handlers., handlers., handlers., handlers., handlers., handlers., handlers., handlers., notify., org. |
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/ puts it behind a ClusterIP Service and a NetworkHF_); 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/ LoadConfig, read by config.) |
deploy., deploy., deploy., config., triage. |
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_ is empty by default, so the hosted state is redacted of names, tags and ids |
notify., notify., notify., notify., notify., triage. |
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. nor bigquery.; the read needs roles/ on the project holding the export and roles/ on the export's dataset alone, bound by the customer (deploy/'s header, capabilities.). gcpbilling. sends one POST, BigQuery's jobs.query, carrying NewQuery's SQL, the export's byte cap as maximum 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., gcpbilling., gcpbilling., gcpbilling., gcpbilling., onboarding. |
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.
serveruns 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/loginitself 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 whenserveautoscales, or on the first credential-stuffing incident. /metricsonserve'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.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. 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 (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:
- 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, 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/ (internal/ Test 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/ |
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/ |
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: an entry in.trivyignore.yamlat the repository root, scoped to the binary's path, with anexpired_atat most 60 days ahead and the reason, read only by the platform image's gates —internal/deployTestThePlatformImageIgnoreFileIsScopedAndExpires) 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: 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. |
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. |
v0.55.0 | not called: ssh.; the helper serves no SSH |
| GO-2026-6355 | golang. |
v0.56.0 | not called: ssh.Dial and friends |
| GO-2026-6354 | golang. |
v0.56.0 | not called: as above |
| GO-2026-5932 | golang. |
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.