diff --git a/lints/0002_auth_users_exposed.sql b/lints/0002_auth_users_exposed.sql index f21c6df..c9a2b47 100644 --- a/lints/0002_auth_users_exposed.sql +++ b/lints/0002_auth_users_exposed.sql @@ -6,7 +6,7 @@ select 'ERROR' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in the public schema, potentially compromising user data security.' as description, + 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as description, format( 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.', c.relname diff --git a/lints/0010_security_definer_view.sql b/lints/0010_security_definer_view.sql index 537a828..4782a35 100644 --- a/lints/0010_security_definer_view.sql +++ b/lints/0010_security_definer_view.sql @@ -6,9 +6,9 @@ select 'ERROR' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects views that are SECURITY DEFINER meaning that they ignore row level security (RLS) policies.' as description, + 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as description, format( - 'View \`%s.%s\` is SECURITY DEFINER', + 'View \`%s.%s\` is defined with the SECURITY DEFINER property', n.nspname, c.relname ) as detail, diff --git a/lints/0013_rls_disabled_in_public.sql b/lints/0013_rls_disabled_in_public.sql index 532bd00..0590098 100644 --- a/lints/0013_rls_disabled_in_public.sql +++ b/lints/0013_rls_disabled_in_public.sql @@ -6,7 +6,7 @@ select 'ERROR' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects cases where row level security (RLS) has not been enabled on a table in the \`public\` schema.' as description, + 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as description, format( 'Table \`%s.%s\` is public, but RLS has not been enabled.', n.nspname, diff --git a/lints/0016_materialized_view_in_api.sql b/lints/0016_materialized_view_in_api.sql index b885de3..d9778a3 100644 --- a/lints/0016_materialized_view_in_api.sql +++ b/lints/0016_materialized_view_in_api.sql @@ -6,7 +6,7 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects materialized views that are potentially accessible over APIs.' as description, + 'Detects materialized views that are potentially accessible over the Data APIs.' as description, format( 'Materialized view \`%s.%s\` is selectable by anon or authenticated roles', n.nspname, diff --git a/splinter.json b/splinter.json index 16cbddb..20f08cf 100644 --- a/splinter.json +++ b/splinter.json @@ -1,6 +1,6 @@ { "0001_unindexed_foreign_keys": "(\nwith foreign_keys as (\n select\n cl.relnamespace::regnamespace::text as schema_name,\n cl.relname as table_name,\n cl.oid as table_oid,\n ct.conname as fkey_name,\n ct.conkey as col_attnums\n from\n pg_catalog.pg_constraint ct\n join pg_catalog.pg_class cl -- fkey owning table\n on ct.conrelid = cl.oid\n left join pg_catalog.pg_depend d\n on d.objid = cl.oid\n and d.deptype = 'e'\n where\n ct.contype = 'f' -- foreign key constraints\n and d.objid is null -- exclude tables that are dependencies of extensions\n and cl.relnamespace::regnamespace::text not in (\n 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions'\n )\n),\nindex_ as (\n select\n pi.indrelid as table_oid,\n indexrelid::regclass as index_,\n string_to_array(indkey::text, ' ')::smallint[] as col_attnums\n from\n pg_catalog.pg_index pi\n where\n indisvalid\n)\nselect\n 'unindexed_foreign_keys' as name,\n 'Unindexed foreign keys' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Identifies foreign key constraints without a covering index, which can impact database performance.' as description,\n format(\n 'Table \\`%s.%s\\` has a foreign key \\`%s\\` without a covering index. This can lead to suboptimal query performance.',\n fk.schema_name,\n fk.table_name,\n fk.fkey_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as remediation,\n jsonb_build_object(\n 'schema', fk.schema_name,\n 'name', fk.table_name,\n 'type', 'table',\n 'fkey_name', fk.fkey_name,\n 'fkey_columns', fk.col_attnums\n ) as metadata,\n format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as cache_key\nfrom\n foreign_keys fk\n left join index_ idx\n on fk.table_oid = idx.table_oid\n and fk.col_attnums = idx.col_attnums\n left join pg_catalog.pg_depend dep\n on idx.table_oid = dep.objid\n and dep.deptype = 'e'\nwhere\n idx.index_ is null\n and fk.schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude tables owned by extensions\norder by\n fk.schema_name,\n fk.table_name,\n fk.fkey_name)", - "0002_auth_users_exposed": "(\nselect\n 'auth_users_exposed' as name,\n 'Exposed Auth Users' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in the public schema, potentially compromising user data security.' as description,\n format(\n 'View/Materialized View \"%s\" in the public schema may expose \\`auth.users\\` data to anon or authenticated roles.',\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'view',\n 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)\n ) as metadata,\n format('auth_users_exposed_%s_%s', n.nspname, c.relname) as cache_key\nfrom\n -- Identify the oid for auth.users\n pg_catalog.pg_class auth_users_pg_class\n join pg_catalog.pg_namespace auth_users_pg_namespace\n on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid\n and auth_users_pg_class.relname = 'users'\n and auth_users_pg_namespace.nspname = 'auth'\n -- Depends on auth.users\n join pg_catalog.pg_depend d\n on d.refobjid = auth_users_pg_class.oid\n join pg_catalog.pg_rewrite r\n on r.oid = d.objid\n join pg_catalog.pg_class c\n on c.oid = r.ev_class\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n join pg_catalog.pg_class pg_class_auth_users\n on d.refobjid = pg_class_auth_users.oid\nwhere\n d.deptype = 'n'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n -- Exclude self\n and c.relname <> '0002_auth_users_exposed'\n -- There are 3 insecure configurations\n and\n (\n -- Materialized views don't support RLS so this is insecure by default\n (c.relkind in ('m')) -- m for materialized view\n or\n -- Standard View, accessible to anon or authenticated that is security_definer\n (\n c.relkind = 'v' -- v for view\n -- Exclude security invoker views\n and not (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n )\n )\n or\n -- Standard View, security invoker, but no RLS enabled on auth.users\n (\n c.relkind in ('v') -- v for view\n -- is security invoker\n and (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n )\n and not pg_class_auth_users.relrowsecurity\n )\n )\ngroup by\n n.nspname,\n c.relname,\n c.oid)", + "0002_auth_users_exposed": "(\nselect\n 'auth_users_exposed' as name,\n 'Exposed Auth Users' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as description,\n format(\n 'View/Materialized View \"%s\" in the public schema may expose \\`auth.users\\` data to anon or authenticated roles.',\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'view',\n 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)\n ) as metadata,\n format('auth_users_exposed_%s_%s', n.nspname, c.relname) as cache_key\nfrom\n -- Identify the oid for auth.users\n pg_catalog.pg_class auth_users_pg_class\n join pg_catalog.pg_namespace auth_users_pg_namespace\n on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid\n and auth_users_pg_class.relname = 'users'\n and auth_users_pg_namespace.nspname = 'auth'\n -- Depends on auth.users\n join pg_catalog.pg_depend d\n on d.refobjid = auth_users_pg_class.oid\n join pg_catalog.pg_rewrite r\n on r.oid = d.objid\n join pg_catalog.pg_class c\n on c.oid = r.ev_class\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n join pg_catalog.pg_class pg_class_auth_users\n on d.refobjid = pg_class_auth_users.oid\nwhere\n d.deptype = 'n'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n -- Exclude self\n and c.relname <> '0002_auth_users_exposed'\n -- There are 3 insecure configurations\n and\n (\n -- Materialized views don't support RLS so this is insecure by default\n (c.relkind in ('m')) -- m for materialized view\n or\n -- Standard View, accessible to anon or authenticated that is security_definer\n (\n c.relkind = 'v' -- v for view\n -- Exclude security invoker views\n and not (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n )\n )\n or\n -- Standard View, security invoker, but no RLS enabled on auth.users\n (\n c.relkind in ('v') -- v for view\n -- is security invoker\n and (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n )\n and not pg_class_auth_users.relrowsecurity\n )\n )\ngroup by\n n.nspname,\n c.relname,\n c.oid)", "0003_auth_rls_initplan": "(\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n pc.relrowsecurity as is_rls_active,\n polname as policy_name,\n polpermissive as is_permissive, -- if not, then restrictive\n (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles,\n case polcmd\n when 'r' then 'SELECT'\n when 'a' then 'INSERT'\n when 'w' then 'UPDATE'\n when 'd' then 'DELETE'\n when '*' then 'ALL'\n end as command,\n qual,\n with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n)\nselect\n 'auth_rls_initplan' as name,\n 'Auth RLS Initialization Plan' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if calls to \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row' as description,\n format(\n 'Table \\`%s.%s\\` has a row level security policy \\`%s\\` that re-evaluates an auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \\`auth.()\\` with \\`(select auth.())\\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.',\n schema_name,\n table_name,\n policy_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table'\n ) as metadata,\n format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as cache_key\nfrom\n policies\nwhere\n is_rls_active\n and schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and (\n -- Example: auth.uid()\n (\n qual like '%auth.uid()%'\n and lower(qual) not like '%select auth.uid()%'\n )\n or (\n qual like '%auth.jwt()%'\n and lower(qual) not like '%select auth.jwt()%'\n )\n or (\n qual like '%auth.role()%'\n and lower(qual) not like '%select auth.role()%'\n )\n or (\n qual like '%auth.email()%'\n and lower(qual) not like '%select auth.email()%'\n )\n or (\n with_check like '%auth.uid()%'\n and lower(with_check) not like '%select auth.uid()%'\n )\n or (\n with_check like '%auth.jwt()%'\n and lower(with_check) not like '%select auth.jwt()%'\n )\n or (\n with_check like '%auth.role()%'\n and lower(with_check) not like '%select auth.role()%'\n )\n or (\n with_check like '%auth.email()%'\n and lower(with_check) not like '%select auth.email()%'\n )\n ))", "0004_no_primary_key": "(\nselect\n 'no_primary_key' as name,\n 'No Primary Key' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as description,\n format(\n 'Table \\`%s.%s\\` does not have a primary key',\n pgns.nspname,\n pgc.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as remediation,\n jsonb_build_object(\n 'schema', pgns.nspname,\n 'name', pgc.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'no_primary_key_%s_%s',\n pgns.nspname,\n pgc.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class pgc\n join pg_catalog.pg_namespace pgns\n on pgns.oid = pgc.relnamespace\n left join pg_catalog.pg_index pgi\n on pgi.indrelid = pgc.oid\n left join pg_catalog.pg_depend dep\n on pgc.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n pgc.relkind = 'r' -- regular tables\n and pgns.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n pgc.oid,\n pgns.nspname,\n pgc.relname\nhaving\n max(coalesce(pgi.indisprimary, false)::int) = 0)", "0005_unused_index": "(\nselect\n 'unused_index' as name,\n 'Unused Index' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects if an index has never been used and may be a candidate for removal.' as description,\n format(\n 'Index \\`%s\\` on table \\`%s.%s\\` has not been used',\n psui.indexrelname,\n psui.schemaname,\n psui.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as remediation,\n jsonb_build_object(\n 'schema', psui.schemaname,\n 'name', psui.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'unused_index_%s_%s_%s',\n psui.schemaname,\n psui.relname,\n psui.indexrelname\n ) as cache_key\n\nfrom\n pg_catalog.pg_stat_user_indexes psui\n join pg_catalog.pg_index pi\n on psui.indexrelid = pi.indexrelid\n left join pg_catalog.pg_depend dep\n on psui.relid = dep.objid\n and dep.deptype = 'e'\nwhere\n psui.idx_scan = 0\n and not pi.indisunique\n and not pi.indisprimary\n and dep.objid is null -- exclude tables owned by extensions\n and psui.schemaname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n ))", @@ -8,10 +8,10 @@ "0007_policy_exists_rls_disabled": "(\nselect\n 'policy_exists_rls_disabled' as name,\n 'Policy Exists RLS Disabled' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as description,\n format(\n 'Table \\`%s.%s\\` has RLS policies but RLS is not enabled on the table. Policies include %s.',\n n.nspname,\n c.relname,\n array_agg(p.polname order by p.polname)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'policy_exists_rls_disabled_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_policy p\n join pg_catalog.pg_class c\n on p.polrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'r' -- regular tables\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n -- RLS is disabled\n and not c.relrowsecurity\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relname)", "0008_rls_enabled_no_policy": "(\nselect\n 'rls_enabled_no_policy' as name,\n 'RLS Enabled No Policy' as title,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as description,\n format(\n 'Table \\`%s.%s\\` has RLS enabled, but no policies exist',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_enabled_no_policy_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n left join pg_catalog.pg_policy p\n on p.polrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'r' -- regular tables\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n -- RLS is enabled\n and c.relrowsecurity\n and p.polname is null\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relname)", "0009_duplicate_index": "(\nselect\n 'duplicate_index' as name,\n 'Duplicate Index' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['PERFORMANCE'] as categories,\n 'Detects cases where two ore more identical indexes exist.' as description,\n format(\n 'Table \\`%s.%s\\` has identical indexes %s. Drop all except one of them',\n n.nspname,\n c.relname,\n array_agg(pi.indexname order by pi.indexname)\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', case\n when c.relkind = 'r' then 'table'\n when c.relkind = 'm' then 'materialized view'\n else 'ERROR'\n end,\n 'indexes', array_agg(pi.indexname order by pi.indexname)\n ) as metadata,\n format(\n 'duplicate_index_%s_%s_%s',\n n.nspname,\n c.relname,\n array_agg(pi.indexname order by pi.indexname)\n ) as cache_key\nfrom\n pg_catalog.pg_indexes pi\n join pg_catalog.pg_namespace n\n on n.nspname = pi.schemaname\n join pg_catalog.pg_class c\n on pi.tablename = c.relname\n and n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind in ('r', 'm') -- tables and materialized views\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude tables owned by extensions\ngroup by\n n.nspname,\n c.relkind,\n c.relname,\n replace(pi.indexdef, pi.indexname, '')\nhaving\n count(*) > 1)", - "0010_security_definer_view": "(\nselect\n 'security_definer_view' as name,\n 'Security Definer View' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects views that are SECURITY DEFINER meaning that they ignore row level security (RLS) policies.' as description,\n format(\n 'View \\`%s.%s\\` is SECURITY DEFINER',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'view'\n ) as metadata,\n format(\n 'security_definer_view_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'v'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude views owned by extensions\n and not (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n ))", + "0010_security_definer_view": "(\nselect\n 'security_definer_view' as name,\n 'Security Definer View' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as description,\n format(\n 'View \\`%s.%s\\` is defined with the SECURITY DEFINER property',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'view'\n ) as metadata,\n format(\n 'security_definer_view_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'v'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude views owned by extensions\n and not (\n lower(coalesce(c.reloptions::text,'{}'))::text[]\n && array[\n 'security_invoker=1',\n 'security_invoker=true',\n 'security_invoker=yes',\n 'security_invoker=on'\n ]\n ))", "0011_function_search_path_mutable": "(\nselect\n 'function_search_path_mutable' as name,\n 'Function Search Path Mutable' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects functions with a mutable search_path parameter which could fail to execute successfully for some roles.' as description,\n format(\n 'Function \\`%s.%s\\` has a role mutable search_path',\n n.nspname,\n p.proname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', p.proname,\n 'type', 'function'\n ) as metadata,\n format(\n 'function_search_path_mutable_%s_%s_%s',\n n.nspname,\n p.proname,\n md5(p.prosrc) -- required when function is polymorphic\n ) as cache_key\nfrom\n pg_catalog.pg_proc p\n join pg_catalog.pg_namespace n\n on p.pronamespace = n.oid\n left join pg_catalog.pg_depend dep\n on p.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null -- exclude functions owned by extensions\n -- Search path not set to ''\n and not coalesce(p.proconfig, '{}') && array['search_path=\"\"'])", - "0013_rls_disabled_in_public": "(\nselect\n 'rls_disabled_in_public' as name,\n 'RLS Disabled in Public' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) has not been enabled on a table in the \\`public\\` schema.' as description,\n format(\n 'Table \\`%s.%s\\` is public, but RLS has not been enabled.',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_disabled_in_public_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\nwhere\n c.relkind = 'r' -- regular tables\n -- RLS is disabled\n and not c.relrowsecurity\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n ))", + "0013_rls_disabled_in_public": "(\nselect\n 'rls_disabled_in_public' as name,\n 'RLS Disabled in Public' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as description,\n format(\n 'Table \\`%s.%s\\` is public, but RLS has not been enabled.',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_disabled_in_public_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\nwhere\n c.relkind = 'r' -- regular tables\n -- RLS is disabled\n and not c.relrowsecurity\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n ))", "0014_extension_in_public": "(\nselect\n 'extension_in_public' as name,\n 'Extension in Public' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects extensions installed in the \\`public\\` schema.' as description,\n format(\n 'Extension \\`%s\\` is installed in the public schema. Move it to another schema.',\n pe.extname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as remediation,\n jsonb_build_object(\n 'schema', pe.extnamespace::regnamespace,\n 'name', pe.extname,\n 'type', 'extension'\n ) as metadata,\n format(\n 'extension_in_public_%s',\n pe.extname\n ) as cache_key\nfrom\n pg_catalog.pg_extension pe\nwhere\n -- plpgsql is installed by default in public and outside user control\n -- confirmed safe\n pe.extname not in ('plpgsql')\n -- Scoping this to public is not optimal. Ideally we would use the postgres\n -- search path. That currently isn't available via SQL. In other lints\n -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that\n -- is not appropriate here as it would evaluate true for the extensions schema\n and pe.extnamespace::regnamespace::text = 'public')", "0015_rls_references_user_metadata": "(\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n polname as policy_name,\n qual,\n with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n)\nselect\n 'rls_references_user_metadata' as name,\n 'RLS references user metadata' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as description,\n format(\n 'Table \\`%s.%s\\` has a row level security policy \\`%s\\` that references Supabase Auth \\`user_metadata\\`. \\`user_metadata\\` is editable by end users and should never be used in a security context.',\n schema_name,\n table_name,\n policy_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table'\n ) as metadata,\n format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as cache_key\nfrom\n policies\nwhere\n schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and (\n -- Example: auth.jwt() -> 'user_metadata'\n -- False positives are possible, but it isn't practical to string match\n -- If false positive rate is too high, this expression can iterate\n qual like '%auth.jwt()%user_metadata%'\n or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'\n or with_check like '%auth.jwt()%user_metadata%'\n or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'\n ))", - "0016_materialized_view_in_api": "(\nselect\n 'materialized_view_in_api' as name,\n 'Materialized View in API' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects materialized views that are potentially accessible over APIs.' as description,\n format(\n 'Materialized view \\`%s.%s\\` is selectable by anon or authenticated roles',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'materialized view'\n ) as metadata,\n format(\n 'materialized_view_in_api_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'm'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null)" + "0016_materialized_view_in_api": "(\nselect\n 'materialized_view_in_api' as name,\n 'Materialized View in API' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects materialized views that are potentially accessible over the Data APIs.' as description,\n format(\n 'Materialized view \\`%s.%s\\` is selectable by anon or authenticated roles',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'materialized view'\n ) as metadata,\n format(\n 'materialized_view_in_api_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'm'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null)" } \ No newline at end of file diff --git a/splinter.sql b/splinter.sql index 5bdd32b..444ddc7 100644 --- a/splinter.sql +++ b/splinter.sql @@ -80,7 +80,7 @@ select 'ERROR' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in the public schema, potentially compromising user data security.' as description, + 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as description, format( 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.', c.relname @@ -570,9 +570,9 @@ select 'ERROR' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects views that are SECURITY DEFINER meaning that they ignore row level security (RLS) policies.' as description, + 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as description, format( - 'View \`%s.%s\` is SECURITY DEFINER', + 'View \`%s.%s\` is defined with the SECURITY DEFINER property', n.nspname, c.relname ) as detail, @@ -662,7 +662,7 @@ select 'ERROR' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects cases where row level security (RLS) has not been enabled on a table in the \`public\` schema.' as description, + 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as description, format( 'Table \`%s.%s\` is public, but RLS has not been enabled.', n.nspname, @@ -792,7 +792,7 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects materialized views that are potentially accessible over APIs.' as description, + 'Detects materialized views that are potentially accessible over the Data APIs.' as description, format( 'Materialized view \`%s.%s\` is selectable by anon or authenticated roles', n.nspname,