Security overview
This page is for the security reviewer at a customer that runs Arkush inside its own infrastructure. The page is also for the operator who answers a vendor questionnaire from it. Each section states facts, then names the files that prove them. Every open gap has a row in the last table, with its card.
1. What the software is and where it runs
Arkush is one web application: freeform canvas dashboards, each chart a Vega-Lite spec over SQL, one JSON document per dashboard. The software runs as one process in one container image on the customer's host. The state plane is one SQLite database, state.db, plus a directory of bytes beside it: snapshot payloads, uploaded originals and font faces. No external database server exists. An install is one container and one volume.
The vendor operates no service that the install calls. The software sends no telemetry, no error reports, no usage statistics and no version check. No analytics script loads in the browser. The constitution refuses a product that reports home (CONSTITUTION.md → What We're Not), and a test holds the table below to the source both ways: every host the source names has a row, and every row names a host the source reaches (src/api/outbound-hosts.test.ts). The app's own assets, the DuckDB engine, the code editor and the fonts are served from the image.
What leaves the host, and what leaves the browser:
| Destination | Who starts it | What is sent | How it is bounded |
|---|---|---|---|
accounts.google.com, www.googleapis.com, and *.googleapis.com in the browser's connect policy | The browser, at sign-in under app-owned sign-in, and for browser-side BigQuery and Sheets datasources | The Google sign-in flow under the person's own account | Only when the install configures Google sign-in or the browser query plane. The document policy admits the Google API origins as one wildcard and every other origin by name. |
bigquery.googleapis.com | The browser under the person's own token. The API under an impersonated registered service account | The datasource's SQL text. The rows come back. | A server-side run needs ARKUSH_BQ_PROJECT, a credential, and a registered account named on the datasource. Unset, every server-side run answers no-bigquery. |
sheets.googleapis.com, apis.google.com, www.gstatic.com, docs.google.com (a frame) | The browser, for Google Sheets datasources and the Picker dialog | The person's own credential | Only when the install configures the Picker pair |
www.youtube-nocookie.com, player.vimeo.com | The browser, on a landing page where an admin embedded a video | A frame load | Admin-authored copy only |
Any https: image address | The browser, for image blocks and the instance logo | A GET | Author-chosen addresses |
| The metadata server, or a mounted key file | The API, to prove its own identity | A token request under the app's identity | Only when the install configures a credential |
iamcredentials.googleapis.com | The API, when a run names a registered service account | A short-lived token mint under the app's identity | Only when the service-account registry is set |
cloudidentity.googleapis.com | The API, to read group membership | The group addresses an admin registered | Only under the Google groups backend. Each call stops after 10 s. |
iam.googleapis.com | The API, to read who may use a registered service account | The address of a registered service account | Only when the service-account registry is set. Each call stops after 10 s, and an answer is kept at most an hour. |
oauth2.googleapis.com, Google's JWKS | The API, under app-owned sign-in | The authorization code exchange | Only under app-owned sign-in |
slack.com/api, plus the upload address Slack answers | The API, for scheduled deliveries and subscriptions | Rendered chart images and table text, with the bot token | Only when an admin registered a destination. Authors name an entry, never a URL. |
The hosts in ARKUSH_FETCH_HOSTS. For the Google sources the guide names docs.google.com, .googleusercontent.com and storage.googleapis.com | The API, for url datasources | A GET. The bearer rides only hops on the start origin. | Off unless the list is set. The rules are in section 7. |
Any public https: name an agent presents as its client id | The API, when a signed-in person opens an agent's consent page | A GET for the client's metadata document | https only, a hostname filter, no redirects, 5 s, 64 KiB, 30 requests per minute per address. A name that resolves to a private range is refused, and a non-default port is refused. |
References: docs/exploration/customer-readiness-2026-09.md (the egress lane), src/api/http.ts (the content-security policy), src/app/services/duckdb.ts, src/app/services/google-auth.ts, src/api/signin.ts, src/api/impersonation.ts, src/api/google-groups.ts, src/api/slack.ts, src/api/source-fetch.ts, src/api/oauth.ts, src/api/public-address.ts, index.html, deploy/Dockerfile.
2. Data flow
Figure: [the data-flow figure](handbook/security-data-flow.html), drawn in the handbook's grammar.
The same flow in words:
- A person opens the app in a browser. The browser reaches the API service with the API's own signed session cookie. On a header-trust install, the browser reaches the API through the customer's identity proxy, which sets the email header.
- Under app-owned sign-in, the API sends the person to the customer's Google Workspace and exchanges the returned code with Google. Under header trust, the proxy owns sign-in, and the API reads the header only.
- The API reads and writes the state plane: records in
state.db, bytes inblobs/andfonts/, on the host's own disk. - The API runs server-side SQL against BigQuery under a registered service account that it impersonates, never under its own identity. The account is read-only, and no key for it exists anywhere.
- An editor's browser can query BigQuery directly under the person's own Google token. That token never reaches the server.
- A viewer's browser reads snapshots only. The DuckDB engine in the browser slices the snapshot rows. No viewer query reaches BigQuery.
- An MCP client (an agent) reaches
/mcpwith OAuth tokens that the API minted after a person consented. The agent acts under that person's rights. The agent never receives the person's Google token. - Nothing else leaves the host unless the configuration names it: a Slack destination, a fetch host, a groups backend.
References: docs/handbook/01-big-picture.html, docs/handbook/06-the-server.html, docs/architecture/00-overview.md.
3. Identity and access
Two identity modes exist, mutually exclusive, picked by configuration. The vendor authenticates nobody.
- App-owned sign-in. The API signs people in over the customer's Google Workspace with the scopes
openidandemail, and issues its own signed session cookie. The API keeps no Google token.ARKUSH_SIGNIN_ALLOWnames who can sign in: full addresses, a domain such as@example.org, or*. The API re-checks the list on every request. Unset, nobody signs in. - Header trust. An identity-aware proxy that the customer runs (IAP, Cloudflare Access, oauth2-proxy) authenticates every request and sets an email header. The API trusts that header. The proxy owns multi-factor authentication, session policy and the identity provider. An organization on Microsoft Entra ID or Okta runs an OIDC proxy in front and header trust behind it.
WARNING: In header-trust mode, a path that reaches the container around the proxy lets any caller name any email. Route every request through the proxy, and publish no host port on the container.
Boot refuses a half-configured identity by name. ARKUSH_ALLOW_ANONYMOUS=true offers account-free use, and an org install leaves it unset. Gap: an install that sets no identity variable at all boots in header trust on the default header name (TASK-4).
Two rights layers. Authoring needs a signed-in identity and nothing more. Two allowlists give the instance rights, and both accept group:<name> entries:
ARKUSH_ANALYSTS: curation — the theme library, the fonts, the datasource library — and reading a private address under the service's own Google. It does not reach the warehouse: a server-side BigQuery run answers to Google's own IAM policy on the account it names. Unset means nobody. The single entry*means every signed-in person, the posture a public deployment states on purpose.ARKUSH_ADMINS: the admin page, the Users page, groups, every agent grant, every dashboard's metadata and the access edit. Unset means nobody, and boot refuses*.
Per-dashboard access belongs to the dashboard. meta.owner, meta.shares (a person or a group, as viewer or editor) and meta.visibility (private, or everyone signed in, view only). One module, src/core/access.ts, evaluates them on every surface: the canvas, the Source panel, the HTTP routes and every MCP tool. Group references resolve server-side from one of two backends: a hand-edited membership file, or the Google directory read under the app's own identity and cached.
Offboarding. Revoke all on the Users page ends every agent grant and every browser session of one account. Removal from the sign-in allowlist stops any token the account still holds at its next use.
The agent OAuth server. The API is its own authorization server for MCP clients.
| Property | Fact |
|---|---|
| Flow | Authorization code with refresh. PKCE with S256 is mandatory. |
| Clients | Dynamic registration with a stateless HMAC client id, and the client-id metadata protocol. A redirect URI is https:, matched exactly, or loopback http:, matched on any port. |
| Consent | The consent page is authenticated like the app. The consent fields are signed, live 10 minutes, and are pinned to the identity. A code lives 5 minutes in memory and is single use. |
| Tokens | 24 random bytes, stored as SHA-256 hashes, checked by hash, expiry and the sign-in allowlist. An access token lives 4 hours. A refresh token lives 30 days and rotates on every use. |
| Revocation | A person revokes their own grants. An admin revokes any grant. A deleted grant fails at its next check. |
| Discovery | The authorization-server metadata (RFC 8414) and the protected-resource metadata (RFC 9728) are served. |
| Rate limit | 30 requests per minute per client address on register, authorize and token, answered 429 with Retry-After. |
The OAuth server is weaker than a reviewer expects in these places. No refresh-token reuse detection exists, and an access token is not bound to the client after consent (TASK-49). One scope exists, offline_access, so no narrower consent exists. The metadata advertises client_secret_post and no secret path exists. The limiter's key is the client-claimed forwarded address. The last three carry no card.
References: deploy/README.md (Identity, Offboarding), src/api/identity.ts, src/api/signin.ts, src/api/session-cookie.ts, src/core/access.ts, src/api/oauth.ts, src/api/routes/grants.ts, docs/spec/04-server-documents.md §04D and §04H, docs/spec/07-onboarding.md §07G. History: design-rationale §36, §179, §222.
4. Warehouse credentials
- Read-only, always, and never the app's own identity. Every server-side warehouse query runs as a registered service account, reached by impersonation: the API mints a short-lived token with the
bigquery.readonlyscope from its own identity. The app's identity therefore needs no BigQuery access at all — it mints tokens and reads IAM policies, and a datasource naming no account cannot run server-side. No key for an impersonated account exists anywhere. - The app's own identity is what the host can prove. A metadata server where the platform has one. Otherwise a key file that the install mounts read-only and names in
GOOGLE_APPLICATION_CREDENTIALS, kept outside the state directory so that a backup carries no credential. - Any setting can be mounted as a file instead of exported. Every
ARKUSH_*name also reads<NAME>_FILE: the service reads the value out of the file named there, trimming the trailing newline, which is the shape Docker and Kubernetes secrets produce. A secret therefore need never appear in the environment, in a process listing, or in a container inspect. Setting both keeps the direct value; a path with no file at it refuses the boot. - No credential is a posture, not a fault. With
ARKUSH_BQ_PROJECTunset, every server-side run answersno-bigquery. The DuckDB plane, derived views and file reductions, needs no credential. - Viewers never query the warehouse. A viewer reads snapshots only, capped at 50,000 rows per datasource. An editor's browser can query BigQuery under the person's own Google token, in the browser. The server never sees that token, and no agent receives it.
- A cost ceiling exists.
ARKUSH_BQ_MAX_BYTES_BILLEDcaps the bytes one server-side run can bill. Unset, no ceiling applies. - Google decides who may spend a registered account. The app asks that account's own IAM policy for the Service Account Token Creator role — when the account is attached to a datasource, and again at every run. An admin grants that role in Google Cloud, and no list inside this app grants it. An answer is kept for at most one hour, so a revocation takes effect within the hour, and an outage longer than an hour pauses server-side runs.
- A departure stops scheduled spending. A scheduled refresh runs as the person whose run last blessed the datasource, and the sweep re-checks that person's role before every run. Remove the role in Google Cloud, and every schedule resting on that person's blessing pauses within the hour.
References: AGENTS.md (the read-only credentials invariant), deploy/README.md (Server-side BigQuery, Named service accounts, Offboarding a person), src/api/impersonation.ts, src/api/iam-policy.ts, src/api/run-datasource.ts, src/api/refresh.ts, src/core/document.ts (SNAPSHOT_ROW_CAP). History: design-rationale §76, §197, §232.
5. What is stored where, and for how long
| Data | Where | Removal |
|---|---|---|
| Dashboard documents, with no snapshot rows | state.db, with up to 50 archived versions per dashboard, each a full copy | Move to trash, then a purge 30 days later. The purge removes the document and its payloads. The version archive is capped, never pruned by age. |
| Snapshot payloads — one per dashboard and datasource | state.db below 256 KB of rows, <state>/blobs/docs/ above it as Parquet | Removed with the document, and when a save stops naming a datasource |
| Uploaded files | <state>/blobs/files/, with an entry in state.db | Deletable as an object. A per-person quota applies when ARKUSH_UPLOAD_QUOTA is set. |
| Instance fonts | <state>/fonts/, with an index in state.db | An analyst deletes a face |
| Browser sessions | state.db | Sign-out, Revoke all, or expiry 30 days after the last renewal |
| Agent grants | state.db, tokens as SHA-256 hashes | The person or an admin revokes |
The two generated secrets in the database: oauth, attest | state.db, one row each in the records table, 32 random bytes as hex, made at first boot | Delete the row with the service stopped, and the next boot mints a new one. The deploy guide states what each rotation costs. |
| The session-cookie signing secret | ARKUSH_SESSION_SECRET when the deployment sets it, else <state>/session-secret, 32 random bytes as hex, file mode 0600, made at first boot | Replace the value, or delete the file with the service stopped, and recreate the container. Everyone signs in once more. A copy of state.db holds no session key. |
| The upgrade copy of the database | <state>/state.db.before-v<N>, the whole state.db as it stood before a schema step, one copy at most, file mode 0600 | The next schema step replaces it. The operator deletes it. Nothing prunes it by age. |
| Email addresses | The profile store, meta.owner and meta.shares, comment authorship, snapshot refreshedBy, log lines, agent grants | Partial. Each site removes separately, and no path removes one identity across all of them (TASK-51). |
| Onboarding answers | The profile store in state.db | DELETE /api/profile removes the record |
| Comments | Inside the document | A soft delete scrubs the body and keeps a tombstone with the author's email |
| Group membership | Process memory, cached from the Google directory | Cache expiry. The record of truth is Google's. |
| Usage events | stdout, read by the install's log driver | The sink's retention. No instance retention number is stated. |
| The hand-edited registries: groups, service accounts, destinations (with Slack bot tokens) | YAML files at operator-chosen paths, outside the state plane | The operator edits the file. An admin edits the same file in the app. |
| The landing copy and the legal pages | /data/landing, /data/pages | An admin edits them in the app |
| The Google client secret, the refresh-tick bearer, the key file | The environment, and a file mounted outside the state directory | The operator. The deploy guide states each rotation. |
Encryption at rest is the host's job. The application encrypts nothing at rest and claims no encryption. state.db, its upgrade copy, the session-secret file, the snapshot payloads and the uploaded originals are plain files. state.db, its upgrade copy and the session-secret file are set to file mode 0600. Disk encryption under the state directory and under every backup copy are the customer's controls. The deploy guide says so in its own words.
The rows above, the three retention numbers, the cookie names and the browser storage names are frozen in `src/api/privacy-facts.test.ts`, because an instance's privacy policy and terms state them: a change fails the suite with the obligation to tell the operator through a Privacy item in the changelog, and the release check refuses a tag without one (design-rationale §225).
No app-level retention rule exists beyond the version cap and the trash clock. Account-level export and deletion are TASK-51. The log retention number is the sink's, and no card names one.
References: docs/exploration/compliance-readiness.md (What Arkush holds about a person), deploy/README.md (Volumes, Versions and upgrades, Monitoring, Backup: every path, Encryption at rest, Rotate a secret), src/api/db.ts (loadOrCreateSecret, the file mode, SCHEMA_VERSION and the upgrade copy), src/api/sqlite-store.ts (RETAINED_VERSIONS), src/core/document.ts (TRASH_RETENTION_DAYS), src/api/session-cookie.ts (SESSION_TTL_S). History: design-rationale §29, §196.
6. What is logged
Every log line is one JSON object with t, level and event. Info lines go to stdout, error lines to stderr. A request id rid rides every line of one request. The id is an inbound x-request-id when a proxy supplies one, otherwise a generated one, and the response echoes it.
The event vocabulary. The table of record is docs/spec/04-server-documents.md §04J, derived from the logEvent call sites in src/api/. The families: dashboard acts (doc_*, version_open, comment_write), runs (ds_refresh, ds_refresh_failed, dslib_*, transform_preview, block_render), files and fonts (file_*, font_*), sign-in (signin, signin_refused, signin_failed, signout), agents (mcp_*, grant_revoke, grant_revoke_all, oauth_rate_limited), administration (admin_*, group_*, google_group_*, sa_*, destinations_edit, page_edit, theme_*), deliveries (delivery_*, subscription_*), and the process (listening, shutdown, refresh_tick). A run event carries the user, the dashboard, the datasource, the row count, the path it ran through, the bytes billed and the cache hit. A BigQuery run event also carries serviceAccount: the registered account's address, which every server-side run now has. A share, visibility or owner change emits access_edit with the values before and after.
One `request` line per answered request: method, path without its query string, status, and milliseconds to the last byte. The line carries no identity, so a log pipeline can file it apart from the usage events.
Never logged: a request body, a token, a profile answer, or SQL text. A failed run logs a classified reason and no message, because an engine message can quote SQL. An error line carries the server-side message and stack, and neither reaches a client. An unclassified error answers a fixed sentence.
The sink is the log driver. Retention is the sink's setting. The deploy guide asks the operator to pin the driver to journald, because the default driver drops the logs on rebuild.
Audit gaps. Under header trust the proxy signs people in and the API sees no sign-in act, so the first request an identity makes after the service starts logs the signin line, with provider: "header". No failed-attempt counter exists (TASK-162). No in-app audit view, log-level switch or support bundle exists (TASK-165).
References: src/api/http.ts (logEvent, logError, logRequestOnClose), src/api/run-datasource.ts (serviceAccount), src/api/routes/dashboards.ts (access_edit), docs/spec/04-server-documents.md §04J, deploy/README.md (Monitoring, Backup: every path). History: design-rationale §90, §124, §206.
7. Hardening
Headers on every response. X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Strict-Transport-Security: max-age=31536000, and a content-security policy. The policy has three tiers. JSON, MCP and asset responses get default-src 'none'. Served scripts get a worker-script policy. The app's own HTML gets a document policy. The document policy keeps 'unsafe-eval' for Vega's expression compiler and 'unsafe-inline' for the bootstrap script, by decision. The eval channel stays open, and everything else is closed. No foreign script loads, connect-src is bounded, objects and base overrides are shut, and no other page frames the app. The strict policy is TASK-50. connect-src names the app's own origin and Google only. A test pins the headers.
Cookies. The session cookie is host-only, HttpOnly, SameSite=Lax, and, when the public URL is https:, Secure and named __Host-arkush_session — a name the service reads only under https, so a bare-named cookie is ignored. The cookie is HMAC-signed with a secret that lives outside the state database, so a copy of state.db cannot mint a session (§5), and lives 30 days from the last renewal.
Cross-site writes. The API sends no CORS header, so a cross-origin fetch fails its preflight. The session cookie is SameSite=Lax. A POST, PUT, PATCH or DELETE whose Sec-Fetch-Site header is cross-site answers 403 before any surface reads it. A request with a bearer token is exempt. A request with no fetch metadata passes, so a machine client loses nothing. The deploy guide states the posture in one paragraph.
Rate limits. The OAuth endpoints answer 429 past 30 requests per minute per client address. Every other route is unlimited, by decision. A limit on sign-in, uploads, MCP and the BigQuery routes reverses that decision, so TASK-162 is a design round before any build.
Limits. The request, upload, font, snapshot, stream, query and fetch ceilings are listed once, in the deploy guide's Capacity section (deploy/README.md), with the compose file's own caps beside them.
The server DuckDB engine. Each run opens an in-memory instance and closes it after. Inputs are written under the OS temp directory per run and removed after. Before the caller's SQL runs, the engine sets enable_external_access=false, disabled_filesystems='LocalFileSystem' and lock_configuration=true. A test proves that a network read and a local file read both fail with the engine's permission error. No statement allowlist exists: the string runs verbatim, the lock refuses SET, and a 30 s wall clock stops the run. The server-side chart render uses a loader that refuses every URL.
The `url` fetcher. The mode does not exist until ARKUSH_FETCH_HOSTS is set. An entry matches a host exactly or as a leading-dot suffix. Only https: and gs: start a fetch. Redirects are followed by hand, at most 3 hops, and every hop is re-checked against the list. The bearer rides only hops on the start origin. The body is reduced through DuckDB into rows and a hash, never stored raw. Before each request leaves, the fetcher resolves the name. Every special-purpose range in both IP families is refused: private, loopback, link-local, shared address space, multicast and the rest. A name with one private answer among public ones is refused. The same check guards the client-metadata fetch. One gap is recorded: the fetch resolves the name again when it connects, so a resolver that answers differently twice (DNS rebinding) passes (TASK-172). The Cloud Identity fetch stops after 10 s.
Uploads. A file lands under a server-computed content hash. Ids are minted by the store. The client's file name becomes metadata only. The store's contract suite rejects a path escape.
Errors. No stack trace reaches a client. Some responses name an environment variable so that an operator can act. One admin-only response names an absolute path. A DuckDB error on a blob that does not exist can name the state directory to an agent (TASK-168).
The container. The process runs as the unprivileged node user on an Alpine base. The image declares a HEALTHCHECK, and the compose file declares the same check. The port is exposed and not published. The compose file drops every capability, sets no-new-privileges, and caps the process count at 512. The root filesystem is read-only, with a 1 GiB memory-backed temp directory for the DuckDB engine's per-run inputs.
The health route. GET /healthz needs no identity and touches no warehouse. The answer carries the version and a readiness flag, and answers 503 when the state plane cannot take a write.
References: src/api/http.ts (the headers and the three policies), src/api/hardening.test.ts, src/api/session-cookie.ts, src/api/server.ts (the cross-site guard, the health route), src/api/oauth.ts (FixedWindowLimiter), src/api/config.ts (MAX_BODY_BYTES), src/core/file-entry.ts, src/core/fonts.ts, src/api/main.ts (the timeouts), src/api/events.ts, src/api/duckdb-node.ts, src/api/duckdb-node.test.ts, src/api/render-block.ts, src/api/source-fetch.ts, src/core/source-url.ts, src/api/public-address.ts, src/api/google-groups.ts, src/api/routes/files.ts, src/api/file-store.ts, deploy/Dockerfile, deploy/docker-compose.yml. History: design-rationale §61, §90.
8. Supply chain
Pins. Every runtime dependency is exact-pinned in package.json. The lockfile is committed, and every install runs npm ci. The Node base is the Active LTS line, pinned by exact version tag and by digest: the tag keeps the version readable to a person reading a scan finding, and the digest names the bytes a tag could be repointed away from. The digest is the multi-arch index's, so both architectures of a release build resolve from it, and a test fails a stage that loses either. Dependabot opens one weekly pull request for the minor and patch churn of the development dependencies, one for the actions, and one for a move of the Node base within its line in the Dockerfile, which carries the tag and the digest together. A runtime dependency gets no weekly request, because it moves in a dedicated diff, and Dependabot opens one for it only on an advisory. A major of any package is the maintainer's move. A test holds the runtime list in the Dependabot configuration to package.json.
The gates before publish. The publish job needs the test job. The test job runs the typecheck, the test suite, the duplication gate, the dead-code gate, the marker census, the house rules and the licence gate. On a tag it also runs the release check: a version whose operator-facing surface changed since the previous tag carries an Operations item in its changelog entry. A red check publishes no image. npm audit --audit-level=high runs and does not gate, so a known advisory is reviewed work, not a failed build. The lockfile advisories are untriaged (TASK-145).
SBOM, scan, signature. After the push, the workflow emits two CycloneDX inventories as one workflow artifact per build. One lists the npm packages from the lockfile. The other lists the image filesystem with its Alpine packages. A scan over the pushed digest reports every high and critical finding, and fails the job on a critical finding that has a fixed version. On a release tag, the workflow signs the pushed digest with cosign, keyless. The certificate names this workflow at that tag, and the entry lands in Sigstore's public transparency log. A main push is unsigned. Buildx provenance attestation stays off by decision. Every action in the workflow is pinned by commit, with the release it resolves to beside it, and a test fails on one that is not. The workflow comments spell out the cosign verify command that checks a pulled release against the workflow identity.
The release set. A release tag also assembles one bundle of files, kept in a release bucket and attached to the run: the linux/amd64 image as a compressed tarball, a checksum file over every other member, that checksum file's own detached cosign signature, both SBOM files, the bundle's source maps, the registry digest, and a release note. It exists for the security review that scans a file before anything enters a network, and for a customer whose process takes files rather than a registry pull. The release note is lifted from the version's changelog entry, classified items first, because the changelog itself ships inside the image and is read after the update rather than before it. deploy/README.md carries the verify-and-load steps.
What the image contains. The built app. The API as one minified bundle, plus the two operator scripts. The runtime node_modules, pruned to a keep list that a test checks against the bundle's own externals. The pruned package.json. The handbook chapters that describe behavior. The operator documentation under /app/docs/: the deploy guide, this overview, SECURITY.md and the changelog, so a person who holds the image holds the documentation for its version. The base compose file at /app/deploy/docker-compose.yml, which is the stack the deploy guide's install step starts, so the install needs no source tree either. The DejaVu fallback font. THIRD-PARTY.md and the Apache-2.0 licence text, at /app/THIRD-PARTY.md and /app/licenses/Apache-2.0.txt, for the transcribed IBM Carbon glyphs, and the Help panel shows the same text. The image contains no source tree, no test, no source map and no .env file. The source maps for the bundle travel in the release set instead, so a stack trace from an install can be read back against source without the image carrying one. The image carries the OCI version, revision and source labels. The title and licenses labels stay unset, because the repository grants no licence.
Licences. The production dependency tree carries permissive licences only: MIT, BSD-2-Clause, BSD-3-Clause, ISC, Apache-2.0, 0BSD and Unlicense. One exception is the SVG rasterizer, @resvg/resvg-js, with its platform binding, under MPL-2.0 and unmodified. One package, dompurify, is dual-licensed MPL-2.0 or Apache-2.0. No package is GPL, LGPL or AGPL. npm run lint:licenses reads the same tree and refuses a package under a licence outside that list, in CI before every publish (scripts/licenses.mjs). The npm SBOM the publish workflow attaches to every release lists each package with its version and licence.
References: package.json, package-lock.json, .github/workflows/publish-image.yml, .github/dependabot.yml, deploy/Dockerfile, src/api/dockerfile.test.ts, src/api/supply-chain.test.ts, scripts/licenses.mjs, scripts/release-check.mjs, THIRD-PARTY.md, licenses/Apache-2.0.txt. History: design-rationale §90, §222, §225.
9. Vulnerability disclosure and patches
Report a vulnerability to security@arkush.app, whoever operates the install. SECURITY.md states the commitments, and its Self-hosted installs section states them for a customer that runs its own. An acknowledgment comes in 3 working days and a first assessment in 10 working days. A fix for a critical problem ships 14 days after the assessment, one for a high problem 30 days after it, and one for a medium or low problem 90 days after it. The same table measures a published advisory in a dependency, counted from the day the maintainer learns of it. One set of times covers every install, because the fix is a release tag in every case. The policy sends a report about how somebody else's instance is RUN — its host, its proxy, its configuration, its data — to that instance's operator, and a defect in the software to the address above. The customer's own team can test its own install, and the policy's safe harbor covers it.
Applying a tag is the operator's act. The maintainer holds no route into a customer's install and pushes no update to it. Fixes ship as new image tags on the current line. No version branch and no backported fix exists, so an install several versions behind moves forward through the boot refusals that name each step.
Support is best effort, with no response time promised for a question that is not a security report. The operator documentation rides in the image, so an install can be run, updated, backed up and decommissioned without the maintainer.
GET /.well-known/security.txt serves the install's own contact when an admin has set one on the admin page, and 404 when none is set. No penetration test report exists.
References: SECURITY.md, docs/spec/04-server-documents.md §04J, src/api/hardening.test.ts.
10. For the customer's legal and privacy team
- No customer data reaches the vendor. The software sends nothing to the vendor, and the vendor has no route into the install.
- The software has no sub-processor of the vendor's, because it calls no vendor-run service. The Google services and the Slack workspace that an install configures are the customer's own processors, under the customer's own agreements.
- The vendor holds no access to the customer's install. The host, the identity provider and the warehouse credential are the customer's.
The customer is the controller of whatever its warehouse holds. A snapshot is warehouse output, and the software cannot know what a snapshot contains. A deletion promise is therefore about the object, never about the contents. No browser fetch reaches a host the customer did not configure: the engine's own extensions are served from the app. The three sentences above are doctrine and gates, not a description of one version: the constitution refuses a product that reports home, a test fails the build on a host with no row in section 1, and a second test freezes the warranted facts — the destinations, the policy origins, the licence allowlist, the format version, the documentation set in the image — so that a change to any of them fails with the obligation to amend the agreement or drop the change (src/api/vendor-commitments.test.ts, design-rationale §225).
References: docs/exploration/compliance-readiness.md, section 1 of this page.
11. Gaps
| Topic | Gap | Card | Priority |
|---|---|---|---|
| Identity boot | No identity variable at all boots header trust on the default header name. An unset public URL counts as localhost. | TASK-4 | p3 |
| Privacy policy | The published privacy policy owes three sentences | TASK-7 | p2 |
| Export and deletion | No account-level export, no deletion of one identity across every store, no retention rule | TASK-51 | p2 |
| Log retention | No retention number is stated for the log sink | none | — |
| Rate limits | OAuth only. No failed-attempt counter. A sign-in limit reverses a recorded decision, so a design round comes first. | TASK-162 | p2 |
| Diagnostics | No in-app audit view, no log-level switch, no support bundle | TASK-165 | p3 |
| Offboarding visibility | A schedule the sweep stops for a departed person shows in the tick's report and the log only; no screen says so | TASK-201 | p2 |
| OAuth tokens | No refresh-token reuse detection. An access token is not bound to the client after consent. | TASK-49 | p2 |
| OAuth metadata | One scope. client_secret_post advertised with no secret path. The limiter key is client-claimed. | none | — |
| Content-security policy | 'unsafe-eval' and 'unsafe-inline' in the document policy | TASK-50 | p2 |
| DNS rebinding | The resolved-address check and the connect resolve the name separately, so a resolver that answers differently twice passes. | TASK-172 | p2 |
| Advisories | The lockfile advisories are untriaged by reachable API | TASK-145 | p2 |
| Error text | A DuckDB error on a blob that does not exist can name the state directory to an agent | TASK-168 | p3 |
| Penetration test | No report exists | none | — |
| Disclosure template | No template for an org operator's own disclosure policy | none | — |
References: docs/exploration/customer-readiness-2026-09.md (the questionnaire table and the cards), backlog/tasks/.