# Security Ekwo OS holds books. A flaw here is not a crash, it is a company reading another company's ledger, or an entry that posts without a trace. Please report it, and please report it privately first. ## Reporting a vulnerability Use GitHub's private reporting: **Security → Report a vulnerability** on this repository. It opens a private advisory that only the maintainers can read, and it keeps the report, the fix and the credit in one place. If you cannot use it, write to **security@ekwo.ai**. Say what you found, where, and how to reproduce it. A failing test is the best report there is; a `curl` against a demo instance is the second best. Do not include real books, real people or real keys in a report — a fictional company reproduces everything a real one does. Please do not open a public issue for a vulnerability, and please do not test against an instance you do not own. The same holds for the maintainers: a weakness found here is fixed through a private advisory first, and the decision record that explains it is written once the fix has shipped (see [`docs/decisions/README.md`](docs/decisions/README.md)). ## What to expect - An acknowledgement within three working days. - A fix, or a reason why not, within thirty days for anything that crosses a row level security boundary or lets a posting bypass the ledger's rules. Shorter when the exploit is simple. - Credit in the advisory and the changelog, unless you would rather not. No bounty programme yet. The project is young; what it can offer today is a fast fix and a public thank-you. ## What counts The threat model is short, and its reasons are in the decision records [0001](docs/decisions/0001-one-installation-is-one-customer.md) to [0006](docs/decisions/0006-a-machine-key-is-a-narrow-caller.md): - **Every table has row level security, and a user only ever sees the companies they are a member of.** Reading, writing or listing a row of a company you are not a member of — through a table, a view, a function or the MCP server — is a vulnerability. - **The schema declares its own privileges, and the anonymous role holds none on any table.** Every table, view and function names the roles that may reach it, in the migration that creates it; nothing is taken from a Supabase project's default privileges. A grant says which verbs a role may attempt and a policy says on which rows they succeed, and both are required. A privilege wider than the migration that created the object declares — `anon` reaching a table at all, a verb no policy will ever accept — is a vulnerability. `ekwo doctor` reports the gap and `packages/cli/assets/expected-objects.json` is what it compares against. - **A function runs with the caller's rights, or checks membership itself.** A `security definer` function reachable by `anon` or by any authenticated user that does not verify the company is a vulnerability. - **The ledger is append-only in effect.** Any path that changes a posted entry, or posts one without its document, without a journal, or unbalanced, is a vulnerability. - **No client rewrites the history.** A member, a machine key, `service_role` or any login other than the owner of the tables updating, deleting or truncating a row of `audit_log`, or a purge that drops a row not older than the date it names or leaves no `audit_log_purged` line, is a vulnerability. - **A setting is not a credential.** Any session may write an `ekwo.*` setting. A caller who gains a capability, or the installer's exemption, by setting one is a vulnerability. - **No secret reaches the disk.** The CLI writing a password, a `service_role` key or a token anywhere but memory is a vulnerability. The MCP server accepting a `service_role` key is one too. What the schema does not hold against, because PostgreSQL gives it no way to: - **The owner of the database.** Every rule above is a function or a trigger, and the owner of a table may disable its triggers; a superuser may do anything. The guarantees bind clients. A trail that its own operator cannot edit is a copy that has already left the database. - **The identity a direct connection declares.** Over the API, `auth.uid()` comes from a token Supabase Auth signed. On a direct connection it comes from `request.jwt.claims`, which is a setting like any other: a login that holds the grants of `authenticated` can declare itself any member. Give a direct login only to somebody you would trust with the rights of every member of the installation — or give it `select` on what it needs to read and no membership in `authenticated`. Out of scope: a misconfigured Supabase project (a disabled RLS on a table you created yourself, an exposed `service_role` key in your own environment), and anything in a dependency that is already reported upstream. ## Supported versions `v0.2.0`, released on 14 September 2026, is the first tagged release. The latest minor is the supported line: it receives the fixes, and the changelog says what changed. `main` is where they land first. There is no backport to an earlier minor. Migrations move forward only — there is no `down` — so the answer to a fix on an old installation is `npx -y ekwo-os@latest migrate`, and [`docs/releasing.md`](docs/releasing.md) is how a release carrying one is cut.