Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ Splinter maintains a set of lints for Supabase projects. It uses SQL queries to

If you are only interested in linting a project, a single query containing the latest version of all lints is availble in splinter.sql in the repo root.

### API-exposure lints and `pgrst.db_schemas`

Several lints (e.g. `auth_users_exposed`, `materialized_view_in_api`, `foreign_table_in_api`) only report objects that are reachable through the Data API — that is, objects in a schema listed in the `pgrst.db_schemas` setting. PostgREST sets this value at runtime, but it is **not** present in a plain `psql` connection.

When `pgrst.db_schemas` is unset, these lints default to the **`public` schema only**. If your project exposes additional schemas, set the value to match your PostgREST config before running the lints so those schemas are covered too:

```sql
set pgrst.db_schemas = 'public, graphql_public, ...';
```

Without this, exposures in non-`public` schemas will not be reported.

## Lint Interface

Each lint creates a view that returns a common interface. The interface is:
Expand Down
2 changes: 1 addition & 1 deletion lints/0002_auth_users_exposed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ where
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
-- Exclude self
and c.relname <> '0002_auth_users_exposed'
-- There are 3 insecure configurations
Expand Down
2 changes: 1 addition & 1 deletion lints/0010_security_definer_view.sql
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ where
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down
2 changes: 1 addition & 1 deletion lints/0013_rls_disabled_in_public.sql
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ where
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
);
2 changes: 1 addition & 1 deletion lints/0016_materialized_view_in_api.sql
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ where
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down
2 changes: 1 addition & 1 deletion lints/0017_foreign_table_in_api.sql
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ where
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down
2 changes: 1 addition & 1 deletion lints/0019_insecure_queue_exposed_in_api.sql
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ where
and n.nspname = 'pgmq' -- tables in the pgmq schema
and c.relname like 'q_%' -- only queue tables
-- Constant requirements
and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and 'pgmq_public' = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
2 changes: 1 addition & 1 deletion lints/0023_sensitive_columns_exposed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ exposed_tables as (
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down
2 changes: 1 addition & 1 deletion lints/0028_anon_security_definer_function_executable.sql
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ from
where
p.prosecdef = true
and pg_catalog.has_function_privilege('anon', p.oid, 'EXECUTE')
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ from
where
p.prosecdef = true
and pg_catalog.has_function_privilege('authenticated', p.oid, 'EXECUTE')
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down
18 changes: 9 additions & 9 deletions splinter.sql
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ where
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
-- Exclude self
and c.relname <> '0002_auth_users_exposed'
-- There are 3 insecure configurations
Expand Down Expand Up @@ -621,7 +621,7 @@ where
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down Expand Up @@ -717,7 +717,7 @@ where
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
))
Expand Down Expand Up @@ -849,7 +849,7 @@ where
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down Expand Up @@ -893,7 +893,7 @@ where
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down Expand Up @@ -980,7 +980,7 @@ where
and n.nspname = 'pgmq' -- tables in the pgmq schema
and c.relname like 'q_%' -- only queue tables
-- Constant requirements
and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))))
and 'pgmq_public' = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))))
union all
(
with constants as (
Expand Down Expand Up @@ -1212,7 +1212,7 @@ exposed_tables as (
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down Expand Up @@ -1743,7 +1743,7 @@ from
where
p.prosecdef = true
and pg_catalog.has_function_privilege('anon', p.oid, 'EXECUTE')
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down Expand Up @@ -1816,7 +1816,7 @@ from
where
p.prosecdef = true
and pg_catalog.has_function_privilege('authenticated', p.oid, 'EXECUTE')
and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
Expand Down
15 changes: 15 additions & 0 deletions test/expected/0013_rls_disabled_in_public.out
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
-- PSQL-1309: lint must still fire when `pgrst.db_schemas` is unset (e.g. running
-- splinter.sql directly via psql), defaulting to the `public` schema. This block
-- runs first, in a fresh session where the GUC has never been set (NULL).
begin;
set local search_path = '';
-- pgrst.db_schemas intentionally NOT set
create table public.unset_guc_tbl( it int primary key );
-- 1 issue (defaults to the `public` schema)
select * from lint."0013_rls_disabled_in_public";
name | title | level | facing | categories | description | detail | remediation | metadata | cache_key
------------------------+------------------------+-------+----------+------------+-------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------+--------------------------------------------------------------------------------------------+----------------------------------------------------------------+---------------------------------------------
rls_disabled_in_public | RLS Disabled in Public | ERROR | EXTERNAL | {SECURITY} | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \`public.unset_guc_tbl\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public | {"name": "unset_guc_tbl", "type": "table", "schema": "public"} | rls_disabled_in_public_public_unset_guc_tbl
(1 row)

rollback;
begin;
set local search_path = '';
set local pgrst.db_schemas = 'public';
Expand Down
14 changes: 14 additions & 0 deletions test/sql/0013_rls_disabled_in_public.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
-- PSQL-1309: lint must still fire when `pgrst.db_schemas` is unset (e.g. running
-- splinter.sql directly via psql), defaulting to the `public` schema. This block
-- runs first, in a fresh session where the GUC has never been set (NULL).
begin;
set local search_path = '';
-- pgrst.db_schemas intentionally NOT set

create table public.unset_guc_tbl( it int primary key );

-- 1 issue (defaults to the `public` schema)
select * from lint."0013_rls_disabled_in_public";
rollback;


begin;
set local search_path = '';
set local pgrst.db_schemas = 'public';
Expand Down
Loading