diff --git a/bin/installcheck b/bin/installcheck index 2d78838..3ad5b36 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -52,7 +52,7 @@ else fi # Execute the test fixtures -psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -d contrib_regression +psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -d contrib_regression # Run tests ${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS} diff --git a/dockerfiles/docker-compose.yml b/dockerfiles/docker-compose.yml index b97c294..4f62b91 100644 --- a/dockerfiles/docker-compose.yml +++ b/dockerfiles/docker-compose.yml @@ -13,5 +13,9 @@ services: interval: 5s timeout: 5s retries: 10 + volumes: + - ../results:/home/splinter/results_out command: - - ./bin/installcheck + - bash + - -c + - "./bin/installcheck; cp /home/splinter/regression.diffs /home/splinter/regression.out /home/splinter/results/* /home/splinter/results_out/ 2>/dev/null || true" diff --git a/docs/0023_sensitive_columns_exposed.md b/docs/0023_sensitive_columns_exposed.md new file mode 100644 index 0000000..4eaed50 --- /dev/null +++ b/docs/0023_sensitive_columns_exposed.md @@ -0,0 +1,110 @@ + +Level: ERROR + +### Rationale + +Tables exposed via the Supabase Data APIs that contain columns with potentially sensitive data (such as passwords, SSNs, credit card numbers, API keys, or other PII) pose a significant security risk when Row Level Security (RLS) is not enabled. Without RLS, anyone with access to the project's URL and an anonymous or authenticated role can read all data in these tables, potentially exposing sensitive user information. + +This lint identifies tables that: +1. Are accessible via the Data API (in exposed schemas like `public`) +2. Have RLS disabled +3. Contain columns with names matching common sensitive data patterns + +### Sensitive Column Patterns Detected + +The following categories of sensitive data are detected: + +**Authentication & Credentials:** +- `password`, `passwd`, `pwd`, `secret`, `api_key`, `token`, `jwt`, `access_token`, `refresh_token`, `session_token`, `auth_code`, `otp`, `2fa_secret` + +**Personal Identifiers:** +- `ssn`, `social_security`, `driver_license`, `passport_number`, `national_id`, `tax_id` + +**Financial Information:** +- `credit_card`, `card_number`, `cvv`, `bank_account`, `account_number`, `routing_number`, `iban`, `swift_code` + +**Health & Medical:** +- `health_record`, `medical_record`, `patient_id`, `insurance_number`, `diagnosis` + +**Device & Digital Identifiers:** +- `mac_address`, `imei`, `device_uuid`, `ssh_key`, `pgp_key`, `certificate` + +**Biometric Data:** +- `fingerprint`, `biometric`, `facial_recognition` + +### How to Resolve + +**Option 1: Enable Row Level Security (Recommended)** + +Enable RLS on the table and create appropriate policies: + +```sql +-- Enable RLS +alter table . enable row level security; + +-- Create a policy that restricts access +create policy "Users can only view their own data" +on .
+for select +using (auth.uid() = user_id); +``` + +**Option 2: Remove sensitive columns from the table** + +If the data doesn't need to be stored, remove the sensitive columns: + +```sql +alter table .
drop column ; +``` + +**Option 3: Move sensitive data to a separate, protected table** + +Store sensitive data in a separate table with proper RLS: + +```sql +-- Create a protected table for sensitive data +create table .
_secure ( + id uuid primary key references .
(id), + text +); + +-- Enable RLS on the secure table +alter table .
_secure enable row level security; + +-- Remove from the exposed table +alter table .
drop column ; +``` + +**Option 4: Remove the schema from API exposure** + +If the table should not be accessible via APIs at all, remove the schema from the [Exposed schemas in API settings](https://supabase.com/dashboard/project/_/settings/api). + +### Example + +Given the schema: + +```sql +create table public.users( + id uuid primary key, + email text not null, + password_hash text not null, + ssn text, + created_at timestamptz default now() +); + +grant select on public.users to anon, authenticated; +``` + +This table is flagged because it contains sensitive columns (`password_hash`, `ssn`) and is accessible via the API without RLS protection. Any user with the project URL can query this table and retrieve all user passwords and social security numbers. + +To fix, enable RLS and create appropriate policies: + +```sql +alter table public.users enable row level security; + +-- Allow users to only read their own data +create policy "Users can view own profile" +on public.users +for select +using (auth.uid() = id); +``` diff --git a/docs/0024_permissive_rls_policy.md b/docs/0024_permissive_rls_policy.md new file mode 100644 index 0000000..5ae699d --- /dev/null +++ b/docs/0024_permissive_rls_policy.md @@ -0,0 +1,128 @@ + +Level: WARN + +### Rationale + +Row Level Security (RLS) policies that use always-true expressions like `USING (true)` or `WITH CHECK (true)` effectively bypass the security that RLS is meant to provide. While RLS appears to be enabled on the table, these permissive policies allow unrestricted access to all rows for the specified roles. + +This is a common misconfiguration that occurs when: +- Developers create placeholder policies during development and forget to update them +- Policies are incorrectly configured with the assumption that other policies will restrict access +- Copy-paste errors from documentation examples + +### Patterns Detected + +The lint identifies policies with these always-true patterns: + +**USING Clause (controls which rows can be read):** +- `USING (true)` - explicitly allows reading all rows +- `USING (1=1)` - tautology that always evaluates to true +- `USING ('a'='a')` - string comparison tautology +- Missing USING clause on permissive SELECT policies + +**WITH CHECK Clause (controls which rows can be written):** +- `WITH CHECK (true)` - allows writing any row +- `WITH CHECK (1=1)` - tautology that always evaluates to true +- Missing WITH CHECK clause on permissive INSERT/UPDATE policies + +### Security Impact + +When a permissive policy with `USING (true)` exists: +- **For SELECT**: Any user with the specified role can read ALL rows in the table +- **For INSERT**: Any user can insert ANY data into the table +- **For UPDATE**: Any user can modify ANY row in the table +- **For DELETE**: Any user can delete ANY row from the table + +This is particularly dangerous when the policy applies to `anon` or `authenticated` roles, as it exposes data to all API users. + +### How to Resolve + +**Option 1: Add proper row-level conditions** + +Replace the permissive policy with one that properly restricts access: + +```sql +-- Instead of: USING (true) +-- Use a proper condition: +drop policy "allow_all" on public.posts; + +create policy "users_own_posts" +on public.posts +for select +using (auth.uid() = user_id); +``` + +**Option 2: Use restrictive policies in combination** + +If you need a base permissive policy, combine it with restrictive policies: + +```sql +-- Base permissive policy +create policy "authenticated_access" +on public.posts +for select +to authenticated +using (true); + +-- Restrictive policy to limit access +create policy "only_published" +on public.posts +as restrictive +for select +to authenticated +using (status = 'published' or auth.uid() = user_id); +``` + +**Option 3: Remove the policy if RLS is not needed** + +If you don't need row-level restrictions, consider whether RLS should be disabled: + +```sql +drop policy "allow_all" on public.posts; +alter table public.posts disable row level security; +``` + +Note: Only disable RLS if you're certain the table should be fully accessible. + +### Example + +Given this problematic configuration: + +```sql +create table public.user_data( + id uuid primary key, + user_id uuid references auth.users(id), + sensitive_info text +); + +alter table public.user_data enable row level security; + +-- This policy defeats the purpose of RLS! +create policy "allow_all_select" +on public.user_data +for select +to authenticated +using (true); +``` + +The `allow_all_select` policy allows ANY authenticated user to read ALL rows, including other users' sensitive information. + +Fix by adding a proper condition: + +```sql +drop policy "allow_all_select" on public.user_data; + +create policy "users_own_data" +on public.user_data +for select +to authenticated +using (auth.uid() = user_id); +``` + +### False Positives + +In some cases, `USING (true)` may be intentional: +- Public read-only tables (e.g., blog posts, product catalogs) +- Tables where access is controlled by other means (e.g., API layer) + +If the policy is intentional, you can document why in a comment or consider suppressing this lint for specific tables. diff --git a/lints/0023_sensitive_columns_exposed.sql b/lints/0023_sensitive_columns_exposed.sql new file mode 100644 index 0000000..b0002ef --- /dev/null +++ b/lints/0023_sensitive_columns_exposed.sql @@ -0,0 +1,108 @@ +create view lint."0023_sensitive_columns_exposed" as + +-- Detects tables exposed via API that contain columns with sensitive names +-- Inspired by patterns from security scanners that detect PII/credential exposure +with sensitive_patterns as ( + select unnest(array[ + -- Authentication & Credentials + 'password', 'passwd', 'pwd', 'passphrase', + 'secret', 'secret_key', 'private_key', 'api_key', 'apikey', + 'auth_key', 'token', 'jwt', 'access_token', 'refresh_token', + 'oauth_token', 'session_token', 'bearer_token', 'auth_code', + 'session_id', 'session_key', 'session_secret', + 'recovery_code', 'backup_code', 'verification_code', + 'otp', 'two_factor', '2fa_secret', '2fa_code', + -- Personal Identifiers + 'ssn', 'social_security', 'social_security_number', + 'driver_license', 'drivers_license', 'license_number', + 'passport_number', 'passport_id', 'national_id', 'tax_id', + -- Financial Information + 'credit_card', 'card_number', 'cvv', 'cvc', 'cvn', + 'bank_account', 'account_number', 'routing_number', + 'iban', 'swift_code', 'bic', + -- Health & Medical + 'health_record', 'medical_record', 'patient_id', + 'insurance_number', 'health_insurance', 'medical_insurance', + 'treatment', + -- Device Identifiers + 'mac_address', 'macaddr', 'imei', 'device_uuid', + -- Digital Keys & Certificates + 'pgp_key', 'gpg_key', 'ssh_key', 'certificate', + 'license_key', 'activation_key', + -- Biometric Data + 'facial_recognition' + ]) as pattern +), +exposed_tables as ( + select + n.nspname as schema_name, + c.relname as table_name, + c.oid as table_oid + from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + where + c.relkind = 'r' -- regular tables + and ( + 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 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', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + -- Only flag tables without RLS enabled + and not c.relrowsecurity +), +sensitive_columns as ( + select + et.schema_name, + et.table_name, + a.attname as column_name, + sp.pattern as matched_pattern + from + exposed_tables et + join pg_catalog.pg_attribute a + on a.attrelid = et.table_oid + and a.attnum > 0 + and not a.attisdropped + cross join sensitive_patterns sp + where + -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants + replace(lower(a.attname), '-', '_') = sp.pattern +) +select + 'sensitive_columns_exposed' as name, + 'Sensitive Columns Exposed' as title, + 'ERROR' as level, + 'EXTERNAL' as facing, + array['SECURITY'] as categories, + 'Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.' as description, + format( + 'Table `%s.%s` is exposed via API without RLS and contains potentially sensitive column(s): %s. This may lead to data exposure.', + schema_name, + table_name, + string_agg(distinct column_name, ', ' order by column_name) + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed' as remediation, + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'sensitive_columns', array_agg(distinct column_name order by column_name), + 'matched_patterns', array_agg(distinct matched_pattern order by matched_pattern) + ) as metadata, + format( + 'sensitive_columns_exposed_%s_%s', + schema_name, + table_name + ) as cache_key +from + sensitive_columns +group by + schema_name, + table_name +order by + schema_name, + table_name; diff --git a/lints/0024_rls_policy_always_true.sql b/lints/0024_rls_policy_always_true.sql new file mode 100644 index 0000000..40c7a01 --- /dev/null +++ b/lints/0024_rls_policy_always_true.sql @@ -0,0 +1,128 @@ +create view lint."0024_rls_policy_always_true" as + +-- Detects RLS policies that are overly permissive (e.g., USING (true), USING (1=1)) +-- These policies effectively disable row-level security while giving a false sense of security +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + pc.relrowsecurity as is_rls_active, + pa.polname as policy_name, + pa.polpermissive as is_permissive, + pa.polroles as role_oids, + (select array_agg(r::regrole::text) from unnest(pa.polroles) as x(r)) as roles, + case pa.polcmd + when 'r' then 'SELECT' + when 'a' then 'INSERT' + when 'w' then 'UPDATE' + when 'd' then 'DELETE' + when '*' then 'ALL' + end as command, + pb.qual, + pb.with_check, + -- Normalize expressions by removing whitespace and lowercasing + replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_qual, + replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_with_check + from + pg_catalog.pg_policy pa + join pg_catalog.pg_class pc + on pa.polrelid = pc.oid + join pg_catalog.pg_namespace nsp + on pc.relnamespace = nsp.oid + join pg_catalog.pg_policies pb + on pc.relname = pb.tablename + and nsp.nspname = pb.schemaname + and pa.polname = pb.policyname + where + pc.relkind = 'r' -- regular tables + and nsp.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', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) +), +permissive_patterns as ( + select + p.*, + -- Check for always-true USING clause patterns + -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE + case when ( + command in ('UPDATE', 'DELETE', 'ALL') + and ( + normalized_qual in ('true', '(true)', '1=1', '(1=1)') + -- Empty or null qual on permissive policy means allow all + or (qual is null and is_permissive) + ) + ) then true else false end as has_permissive_using, + -- Check for always-true WITH CHECK clause patterns + case when ( + normalized_with_check in ('true', '(true)', '1=1', '(1=1)') + -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on) + or (with_check is null and is_permissive and command = 'INSERT') + -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes + or (with_check is null and is_permissive and command in ('UPDATE', 'ALL') + and normalized_qual in ('true', '(true)', '1=1', '(1=1)')) + ) then true else false end as has_permissive_with_check + from + policies p + where + -- Only check tables with RLS enabled (otherwise it's a different lint) + is_rls_active + -- Only check permissive policies (restrictive policies with true are less dangerous) + and is_permissive + -- Only flag policies that apply to anon or authenticated roles (or public/all roles) + and ( + role_oids = array[0::oid] -- public (all roles) + or exists ( + select 1 + from unnest(role_oids) as r + where r::regrole::text in ('anon', 'authenticated') + ) + ) +) +select + 'rls_policy_always_true' as name, + 'RLS Policy Always True' as title, + 'WARN' as level, + 'EXTERNAL' as facing, + array['SECURITY'] as categories, + 'Detects RLS policies that use overly permissive expressions like \`USING (true)\` or \`WITH CHECK (true)\` for UPDATE, DELETE, or INSERT operations. SELECT policies with \`USING (true)\` are intentionally excluded as this pattern is often used deliberately for public read access.' as description, + format( + 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.', + schema_name, + table_name, + policy_name, + command, + case + when has_permissive_using and has_permissive_with_check then ' (both USING and WITH CHECK are always true)' + when has_permissive_using then ' (USING clause is always true)' + when has_permissive_with_check then ' (WITH CHECK clause is always true)' + else '' + end, + array_to_string(roles, ', ') + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy' as remediation, + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'policy_name', policy_name, + 'command', command, + 'roles', roles, + 'qual', qual, + 'with_check', with_check, + 'permissive_using', has_permissive_using, + 'permissive_with_check', has_permissive_with_check + ) as metadata, + format( + 'rls_policy_always_true_%s_%s_%s', + schema_name, + table_name, + policy_name + ) as cache_key +from + permissive_patterns +where + has_permissive_using or has_permissive_with_check +order by + schema_name, + table_name, + policy_name; diff --git a/splinter.sql b/splinter.sql index b203632..14cba60 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1147,4 +1147,240 @@ where and ext.default_version is not null and ext.installed_version != ext.default_version order by - ext.name) \ No newline at end of file + ext.name) +union all +( +-- Detects tables exposed via API that contain columns with sensitive names +-- Inspired by patterns from security scanners that detect PII/credential exposure +with sensitive_patterns as ( + select unnest(array[ + -- Authentication & Credentials + 'password', 'passwd', 'pwd', 'passphrase', + 'secret', 'secret_key', 'private_key', 'api_key', 'apikey', + 'auth_key', 'token', 'jwt', 'access_token', 'refresh_token', + 'oauth_token', 'session_token', 'bearer_token', 'auth_code', + 'session_id', 'session_key', 'session_secret', + 'recovery_code', 'backup_code', 'verification_code', + 'otp', 'two_factor', '2fa_secret', '2fa_code', + -- Personal Identifiers + 'ssn', 'social_security', 'social_security_number', + 'driver_license', 'drivers_license', 'license_number', + 'passport_number', 'passport_id', 'national_id', 'tax_id', + -- Financial Information + 'credit_card', 'card_number', 'cvv', 'cvc', 'cvn', + 'bank_account', 'account_number', 'routing_number', + 'iban', 'swift_code', 'bic', + -- Health & Medical + 'health_record', 'medical_record', 'patient_id', + 'insurance_number', 'health_insurance', 'medical_insurance', + 'treatment', + -- Device Identifiers + 'mac_address', 'macaddr', 'imei', 'device_uuid', + -- Digital Keys & Certificates + 'pgp_key', 'gpg_key', 'ssh_key', 'certificate', + 'license_key', 'activation_key', + -- Biometric Data + 'facial_recognition' + ]) as pattern +), +exposed_tables as ( + select + n.nspname as schema_name, + c.relname as table_name, + c.oid as table_oid + from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + where + c.relkind = 'r' -- regular tables + and ( + 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 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', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + -- Only flag tables without RLS enabled + and not c.relrowsecurity +), +sensitive_columns as ( + select + et.schema_name, + et.table_name, + a.attname as column_name, + sp.pattern as matched_pattern + from + exposed_tables et + join pg_catalog.pg_attribute a + on a.attrelid = et.table_oid + and a.attnum > 0 + and not a.attisdropped + cross join sensitive_patterns sp + where + -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants + replace(lower(a.attname), '-', '_') = sp.pattern +) +select + 'sensitive_columns_exposed' as name, + 'Sensitive Columns Exposed' as title, + 'ERROR' as level, + 'EXTERNAL' as facing, + array['SECURITY'] as categories, + 'Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.' as description, + format( + 'Table `%s.%s` is exposed via API without RLS and contains potentially sensitive column(s): %s. This may lead to data exposure.', + schema_name, + table_name, + string_agg(distinct column_name, ', ' order by column_name) + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed' as remediation, + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'sensitive_columns', array_agg(distinct column_name order by column_name), + 'matched_patterns', array_agg(distinct matched_pattern order by matched_pattern) + ) as metadata, + format( + 'sensitive_columns_exposed_%s_%s', + schema_name, + table_name + ) as cache_key +from + sensitive_columns +group by + schema_name, + table_name +order by + schema_name, + table_name) +union all +( +-- Detects RLS policies that are overly permissive (e.g., USING (true), USING (1=1)) +-- These policies effectively disable row-level security while giving a false sense of security +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + pc.relrowsecurity as is_rls_active, + pa.polname as policy_name, + pa.polpermissive as is_permissive, + pa.polroles as role_oids, + (select array_agg(r::regrole::text) from unnest(pa.polroles) as x(r)) as roles, + case pa.polcmd + when 'r' then 'SELECT' + when 'a' then 'INSERT' + when 'w' then 'UPDATE' + when 'd' then 'DELETE' + when '*' then 'ALL' + end as command, + pb.qual, + pb.with_check, + -- Normalize expressions by removing whitespace and lowercasing + replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_qual, + replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_with_check + from + pg_catalog.pg_policy pa + join pg_catalog.pg_class pc + on pa.polrelid = pc.oid + join pg_catalog.pg_namespace nsp + on pc.relnamespace = nsp.oid + join pg_catalog.pg_policies pb + on pc.relname = pb.tablename + and nsp.nspname = pb.schemaname + and pa.polname = pb.policyname + where + pc.relkind = 'r' -- regular tables + and nsp.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', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) +), +permissive_patterns as ( + select + p.*, + -- Check for always-true USING clause patterns + -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE + case when ( + command in ('UPDATE', 'DELETE', 'ALL') + and ( + normalized_qual in ('true', '(true)', '1=1', '(1=1)') + -- Empty or null qual on permissive policy means allow all + or (qual is null and is_permissive) + ) + ) then true else false end as has_permissive_using, + -- Check for always-true WITH CHECK clause patterns + case when ( + normalized_with_check in ('true', '(true)', '1=1', '(1=1)') + -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on) + or (with_check is null and is_permissive and command = 'INSERT') + -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes + or (with_check is null and is_permissive and command in ('UPDATE', 'ALL') + and normalized_qual in ('true', '(true)', '1=1', '(1=1)')) + ) then true else false end as has_permissive_with_check + from + policies p + where + -- Only check tables with RLS enabled (otherwise it's a different lint) + is_rls_active + -- Only check permissive policies (restrictive policies with true are less dangerous) + and is_permissive + -- Only flag policies that apply to anon or authenticated roles (or public/all roles) + and ( + role_oids = array[0::oid] -- public (all roles) + or exists ( + select 1 + from unnest(role_oids) as r + where r::regrole::text in ('anon', 'authenticated') + ) + ) +) +select + 'rls_policy_always_true' as name, + 'RLS Policy Always True' as title, + 'WARN' as level, + 'EXTERNAL' as facing, + array['SECURITY'] as categories, + 'Detects RLS policies that use overly permissive expressions like \`USING (true)\` or \`WITH CHECK (true)\` for UPDATE, DELETE, or INSERT operations. SELECT policies with \`USING (true)\` are intentionally excluded as this pattern is often used deliberately for public read access.' as description, + format( + 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.', + schema_name, + table_name, + policy_name, + command, + case + when has_permissive_using and has_permissive_with_check then ' (both USING and WITH CHECK are always true)' + when has_permissive_using then ' (USING clause is always true)' + when has_permissive_with_check then ' (WITH CHECK clause is always true)' + else '' + end, + array_to_string(roles, ', ') + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy' as remediation, + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'policy_name', policy_name, + 'command', command, + 'roles', roles, + 'qual', qual, + 'with_check', with_check, + 'permissive_using', has_permissive_using, + 'permissive_with_check', has_permissive_with_check + ) as metadata, + format( + 'rls_policy_always_true_%s_%s_%s', + schema_name, + table_name, + policy_name + ) as cache_key +from + permissive_patterns +where + has_permissive_using or has_permissive_with_check +order by + schema_name, + table_name, + policy_name) \ No newline at end of file diff --git a/test/expected/0023_sensitive_columns_exposed.out b/test/expected/0023_sensitive_columns_exposed.out new file mode 100644 index 0000000..3cb9d0e --- /dev/null +++ b/test/expected/0023_sensitive_columns_exposed.out @@ -0,0 +1,82 @@ +begin; + set local search_path = ''; + set local pgrst.db_schemas = 'public'; + -- 0 issues - no tables yet + select * from lint."0023_sensitive_columns_exposed"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Create a table with sensitive columns (password, ssn) + create table public.users( + id int primary key, + username text not null, + password text not null, + ssn text + ); + -- 1 issue - table has sensitive columns without RLS + select * from lint."0023_sensitive_columns_exposed"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +---------------------------+---------------------------+-------+----------+------------+------------------------------------------------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------+---------------------------------------- + sensitive_columns_exposed | Sensitive Columns Exposed | ERROR | EXTERNAL | {SECURITY} | Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection. | Table `public.users` is exposed via API without RLS and contains potentially sensitive column(s): password, ssn. This may lead to data exposure. | https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed | {"name": "users", "type": "table", "schema": "public", "matched_patterns": ["password", "ssn"], "sensitive_columns": ["password", "ssn"]} | sensitive_columns_exposed_public_users +(1 row) + + -- Create another table with financial data + create table public.payments( + id int primary key, + user_id int references public.users(id), + credit_card text, + cvv text, + amount numeric + ); + -- 2 issues - both tables have sensitive columns + select count(*) from lint."0023_sensitive_columns_exposed"; + count +------- + 2 +(1 row) + + -- Resolve the issue by enabling RLS on users table + alter table public.users enable row level security; + -- 1 issue - only payments table now + select count(*) from lint."0023_sensitive_columns_exposed"; + count +------- + 1 +(1 row) + + -- Enable RLS on payments table + alter table public.payments enable row level security; + -- 0 issues - all tables protected + select * from lint."0023_sensitive_columns_exposed"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Create a table without sensitive columns + create table public.posts( + id int primary key, + title text, + content text, + created_at timestamptz default now() + ); + -- 0 issues - posts table has no sensitive columns + select * from lint."0023_sensitive_columns_exposed"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Confirm that the lint only applies to `public` tables + create schema private_schema; + create table private_schema.secrets( + id int primary key, + api_key text, + secret_token text + ); + -- 0 issues - private_schema is not exposed via API + select * from lint."0023_sensitive_columns_exposed"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + +rollback; diff --git a/test/expected/0024_rls_policy_always_true.out b/test/expected/0024_rls_policy_always_true.out new file mode 100644 index 0000000..35fc690 --- /dev/null +++ b/test/expected/0024_rls_policy_always_true.out @@ -0,0 +1,311 @@ +begin; + set local search_path = ''; + set local pgrst.db_schemas = 'public'; + -- 0 issues - no tables yet + select * from lint."0024_rls_policy_always_true"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Create a table with RLS enabled + create table public.posts( + id int primary key, + user_id uuid, + title text + ); + alter table public.posts enable row level security; + ---------------------------------------- + -- Test: SELECT with USING (true) should NOT be flagged + -- SELECT with (true) is often intentional for public read access + ---------------------------------------- + create policy "select_using_true" + on public.posts + for select + to authenticated + using (true); + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) + + drop policy "select_using_true" on public.posts; + ---------------------------------------- + -- Test: UPDATE with USING (true) SHOULD be flagged + ---------------------------------------- + create policy "update_using_true" + on public.posts + for update + to authenticated + using (true); + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + policy_name | command +-------------------+--------- + update_using_true | UPDATE +(1 row) + + drop policy "update_using_true" on public.posts; + ---------------------------------------- + -- Test: DELETE with USING (true) SHOULD be flagged + ---------------------------------------- + create policy "delete_using_true" + on public.posts + for delete + to authenticated + using (true); + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + policy_name | command +-------------------+--------- + delete_using_true | DELETE +(1 row) + + drop policy "delete_using_true" on public.posts; + ---------------------------------------- + -- Test: ALL command with USING (true) SHOULD be flagged + ---------------------------------------- + create policy "all_using_true" + on public.posts + for all + to authenticated + using (true); + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + policy_name | command +----------------+--------- + all_using_true | ALL +(1 row) + + drop policy "all_using_true" on public.posts; + ---------------------------------------- + -- Test: INSERT with WITH CHECK (true) SHOULD be flagged + ---------------------------------------- + create policy "insert_with_check_true" + on public.posts + for insert + to authenticated + with check (true); + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + policy_name | command +------------------------+--------- + insert_with_check_true | INSERT +(1 row) + + drop policy "insert_with_check_true" on public.posts; + ---------------------------------------- + -- Test: INSERT with WITH CHECK (1=1) SHOULD be flagged + ---------------------------------------- + create policy "insert_with_check_1eq1" + on public.posts + for insert + to authenticated + with check (1=1); + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + policy_name | command +------------------------+--------- + insert_with_check_1eq1 | INSERT +(1 row) + + drop policy "insert_with_check_1eq1" on public.posts; + ---------------------------------------- + -- Test: UPDATE with WITH CHECK (true) SHOULD be flagged + ---------------------------------------- + create policy "update_with_check_true" + on public.posts + for update + to authenticated + using (user_id = auth.uid()) + with check (true); + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + policy_name | command +------------------------+--------- + update_with_check_true | UPDATE +(1 row) + + drop policy "update_with_check_true" on public.posts; + ---------------------------------------- + -- Test: UPDATE with both USING (true) and WITH CHECK (true) SHOULD be flagged + -- and should show "both USING and WITH CHECK are always true" + ---------------------------------------- + create policy "update_both_true" + on public.posts + for update + to authenticated + using (true) + with check (true); + select + metadata->>'policy_name' as policy_name, + metadata->>'command' as command, + metadata->>'permissive_using' as permissive_using, + metadata->>'permissive_with_check' as permissive_with_check + from lint."0024_rls_policy_always_true"; + policy_name | command | permissive_using | permissive_with_check +------------------+---------+------------------+----------------------- + update_both_true | UPDATE | true | true +(1 row) + + drop policy "update_both_true" on public.posts; + ---------------------------------------- + -- Test: Whitespace and case variations should be normalized + -- Using TRUE with extra spaces + ---------------------------------------- + create policy "update_using_TRUE_spaces" + on public.posts + for update + to authenticated + using ( TRUE ); + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +-------------------------- + update_using_TRUE_spaces +(1 row) + + drop policy "update_using_TRUE_spaces" on public.posts; + ---------------------------------------- + -- Test: Restrictive policy with true should NOT be flagged + ---------------------------------------- + create policy "restrictive_true" + on public.posts + as restrictive + for update + to authenticated + using (true); + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) + + drop policy "restrictive_true" on public.posts; + ---------------------------------------- + -- Test: Policy for custom role (not anon/authenticated) should NOT be flagged + ---------------------------------------- + create role custom_role; + create policy "custom_role_policy" + on public.posts + for update + to custom_role + using (true); + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) + + drop policy "custom_role_policy" on public.posts; + ---------------------------------------- + -- Test: Policy for anon role SHOULD be flagged + ---------------------------------------- + create policy "anon_update_true" + on public.posts + for update + to anon + using (true); + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +------------------ + anon_update_true +(1 row) + + drop policy "anon_update_true" on public.posts; + ---------------------------------------- + -- Test: Policy for public (all roles) SHOULD be flagged for UPDATE + ---------------------------------------- + create policy "public_update_true" + on public.posts + for update + using (true); + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +-------------------- + public_update_true +(1 row) + + drop policy "public_update_true" on public.posts; + ---------------------------------------- + -- Test: Table without RLS enabled should NOT be flagged + ---------------------------------------- + create table public.no_rls_table( + id int primary key + ); + -- RLS not enabled + create policy "no_rls_policy" + on public.no_rls_table + for update + to authenticated + using (true); + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) + + drop table public.no_rls_table; + ---------------------------------------- + -- Test: Proper policy with actual USING and WITH CHECK should NOT be flagged + ---------------------------------------- + create policy "proper_policy" + on public.posts + for update + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) + + drop policy "proper_policy" on public.posts; + ---------------------------------------- + -- Test: ALL command with proper USING but no WITH CHECK should NOT be flagged + -- PostgreSQL uses USING for WITH CHECK when not specified + ---------------------------------------- + create policy "all_proper_using_no_with_check" + on public.posts + for all + to authenticated + using (user_id = auth.uid()); + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) + + drop policy "all_proper_using_no_with_check" on public.posts; + ---------------------------------------- + -- Test: ALL command with USING (true) and no WITH CHECK SHOULD be flagged + -- Both permissive_using (for UPDATE/DELETE) and permissive_with_check (USING falls back) + ---------------------------------------- + create policy "all_true_using_no_with_check" + on public.posts + for all + to authenticated + using (true); + select + metadata->>'policy_name' as policy_name, + metadata->>'permissive_using' as permissive_using, + metadata->>'permissive_with_check' as permissive_with_check + from lint."0024_rls_policy_always_true"; + policy_name | permissive_using | permissive_with_check +------------------------------+------------------+----------------------- + all_true_using_no_with_check | true | true +(1 row) + + drop policy "all_true_using_no_with_check" on public.posts; + ---------------------------------------- + -- Test: INSERT with no WITH CHECK (null) SHOULD be flagged + ---------------------------------------- + create policy "insert_no_with_check" + on public.posts + for insert + to authenticated; + select + metadata->>'policy_name' as policy_name, + metadata->>'permissive_with_check' as permissive_with_check + from lint."0024_rls_policy_always_true"; + policy_name | permissive_with_check +----------------------+----------------------- + insert_no_with_check | true +(1 row) + + drop policy "insert_no_with_check" on public.posts; +rollback; diff --git a/test/expected/queries_are_unionable.out b/test/expected/queries_are_unionable.out index cf0e50e..b745bf6 100644 --- a/test/expected/queries_are_unionable.out +++ b/test/expected/queries_are_unionable.out @@ -40,7 +40,11 @@ begin; union all select * from lint."0021_fkey_to_auth_unique" union all - select * from lint."0022_extension_versions_outdated"; + select * from lint."0022_extension_versions_outdated" + union all + select * from lint."0023_sensitive_columns_exposed" + union all + select * from lint."0024_rls_policy_always_true"; name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) diff --git a/test/sql/0023_sensitive_columns_exposed.sql b/test/sql/0023_sensitive_columns_exposed.sql new file mode 100644 index 0000000..e3d921f --- /dev/null +++ b/test/sql/0023_sensitive_columns_exposed.sql @@ -0,0 +1,65 @@ +begin; + set local search_path = ''; + set local pgrst.db_schemas = 'public'; + + -- 0 issues - no tables yet + select * from lint."0023_sensitive_columns_exposed"; + + -- Create a table with sensitive columns (password, ssn) + create table public.users( + id int primary key, + username text not null, + password text not null, + ssn text + ); + + -- 1 issue - table has sensitive columns without RLS + select * from lint."0023_sensitive_columns_exposed"; + + -- Create another table with financial data + create table public.payments( + id int primary key, + user_id int references public.users(id), + credit_card text, + cvv text, + amount numeric + ); + + -- 2 issues - both tables have sensitive columns + select count(*) from lint."0023_sensitive_columns_exposed"; + + -- Resolve the issue by enabling RLS on users table + alter table public.users enable row level security; + + -- 1 issue - only payments table now + select count(*) from lint."0023_sensitive_columns_exposed"; + + -- Enable RLS on payments table + alter table public.payments enable row level security; + + -- 0 issues - all tables protected + select * from lint."0023_sensitive_columns_exposed"; + + -- Create a table without sensitive columns + create table public.posts( + id int primary key, + title text, + content text, + created_at timestamptz default now() + ); + + -- 0 issues - posts table has no sensitive columns + select * from lint."0023_sensitive_columns_exposed"; + + -- Confirm that the lint only applies to `public` tables + create schema private_schema; + create table private_schema.secrets( + id int primary key, + api_key text, + secret_token text + ); + + -- 0 issues - private_schema is not exposed via API + select * from lint."0023_sensitive_columns_exposed"; + +rollback; diff --git a/test/sql/0024_rls_policy_always_true.sql b/test/sql/0024_rls_policy_always_true.sql new file mode 100644 index 0000000..f175e4c --- /dev/null +++ b/test/sql/0024_rls_policy_always_true.sql @@ -0,0 +1,274 @@ +begin; + set local search_path = ''; + set local pgrst.db_schemas = 'public'; + + -- 0 issues - no tables yet + select * from lint."0024_rls_policy_always_true"; + + -- Create a table with RLS enabled + create table public.posts( + id int primary key, + user_id uuid, + title text + ); + alter table public.posts enable row level security; + + ---------------------------------------- + -- Test: SELECT with USING (true) should NOT be flagged + -- SELECT with (true) is often intentional for public read access + ---------------------------------------- + create policy "select_using_true" + on public.posts + for select + to authenticated + using (true); + + select count(*) from lint."0024_rls_policy_always_true"; + + drop policy "select_using_true" on public.posts; + + ---------------------------------------- + -- Test: UPDATE with USING (true) SHOULD be flagged + ---------------------------------------- + create policy "update_using_true" + on public.posts + for update + to authenticated + using (true); + + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + + drop policy "update_using_true" on public.posts; + + ---------------------------------------- + -- Test: DELETE with USING (true) SHOULD be flagged + ---------------------------------------- + create policy "delete_using_true" + on public.posts + for delete + to authenticated + using (true); + + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + + drop policy "delete_using_true" on public.posts; + + ---------------------------------------- + -- Test: ALL command with USING (true) SHOULD be flagged + ---------------------------------------- + create policy "all_using_true" + on public.posts + for all + to authenticated + using (true); + + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + + drop policy "all_using_true" on public.posts; + + ---------------------------------------- + -- Test: INSERT with WITH CHECK (true) SHOULD be flagged + ---------------------------------------- + create policy "insert_with_check_true" + on public.posts + for insert + to authenticated + with check (true); + + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + + drop policy "insert_with_check_true" on public.posts; + + ---------------------------------------- + -- Test: INSERT with WITH CHECK (1=1) SHOULD be flagged + ---------------------------------------- + create policy "insert_with_check_1eq1" + on public.posts + for insert + to authenticated + with check (1=1); + + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + + drop policy "insert_with_check_1eq1" on public.posts; + + ---------------------------------------- + -- Test: UPDATE with WITH CHECK (true) SHOULD be flagged + ---------------------------------------- + create policy "update_with_check_true" + on public.posts + for update + to authenticated + using (user_id = auth.uid()) + with check (true); + + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + + drop policy "update_with_check_true" on public.posts; + + ---------------------------------------- + -- Test: UPDATE with both USING (true) and WITH CHECK (true) SHOULD be flagged + -- and should show "both USING and WITH CHECK are always true" + ---------------------------------------- + create policy "update_both_true" + on public.posts + for update + to authenticated + using (true) + with check (true); + + select + metadata->>'policy_name' as policy_name, + metadata->>'command' as command, + metadata->>'permissive_using' as permissive_using, + metadata->>'permissive_with_check' as permissive_with_check + from lint."0024_rls_policy_always_true"; + + drop policy "update_both_true" on public.posts; + + ---------------------------------------- + -- Test: Whitespace and case variations should be normalized + -- Using TRUE with extra spaces + ---------------------------------------- + create policy "update_using_TRUE_spaces" + on public.posts + for update + to authenticated + using ( TRUE ); + + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + + drop policy "update_using_TRUE_spaces" on public.posts; + + ---------------------------------------- + -- Test: Restrictive policy with true should NOT be flagged + ---------------------------------------- + create policy "restrictive_true" + on public.posts + as restrictive + for update + to authenticated + using (true); + + select count(*) from lint."0024_rls_policy_always_true"; + + drop policy "restrictive_true" on public.posts; + + ---------------------------------------- + -- Test: Policy for custom role (not anon/authenticated) should NOT be flagged + ---------------------------------------- + create role custom_role; + create policy "custom_role_policy" + on public.posts + for update + to custom_role + using (true); + + select count(*) from lint."0024_rls_policy_always_true"; + + drop policy "custom_role_policy" on public.posts; + + ---------------------------------------- + -- Test: Policy for anon role SHOULD be flagged + ---------------------------------------- + create policy "anon_update_true" + on public.posts + for update + to anon + using (true); + + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + + drop policy "anon_update_true" on public.posts; + + ---------------------------------------- + -- Test: Policy for public (all roles) SHOULD be flagged for UPDATE + ---------------------------------------- + create policy "public_update_true" + on public.posts + for update + using (true); + + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + + drop policy "public_update_true" on public.posts; + + ---------------------------------------- + -- Test: Table without RLS enabled should NOT be flagged + ---------------------------------------- + create table public.no_rls_table( + id int primary key + ); + -- RLS not enabled + create policy "no_rls_policy" + on public.no_rls_table + for update + to authenticated + using (true); + + select count(*) from lint."0024_rls_policy_always_true"; + + drop table public.no_rls_table; + + ---------------------------------------- + -- Test: Proper policy with actual USING and WITH CHECK should NOT be flagged + ---------------------------------------- + create policy "proper_policy" + on public.posts + for update + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + + select count(*) from lint."0024_rls_policy_always_true"; + + drop policy "proper_policy" on public.posts; + + ---------------------------------------- + -- Test: ALL command with proper USING but no WITH CHECK should NOT be flagged + -- PostgreSQL uses USING for WITH CHECK when not specified + ---------------------------------------- + create policy "all_proper_using_no_with_check" + on public.posts + for all + to authenticated + using (user_id = auth.uid()); + + select count(*) from lint."0024_rls_policy_always_true"; + + drop policy "all_proper_using_no_with_check" on public.posts; + + ---------------------------------------- + -- Test: ALL command with USING (true) and no WITH CHECK SHOULD be flagged + -- Both permissive_using (for UPDATE/DELETE) and permissive_with_check (USING falls back) + ---------------------------------------- + create policy "all_true_using_no_with_check" + on public.posts + for all + to authenticated + using (true); + + select + metadata->>'policy_name' as policy_name, + metadata->>'permissive_using' as permissive_using, + metadata->>'permissive_with_check' as permissive_with_check + from lint."0024_rls_policy_always_true"; + + drop policy "all_true_using_no_with_check" on public.posts; + + ---------------------------------------- + -- Test: INSERT with no WITH CHECK (null) SHOULD be flagged + ---------------------------------------- + create policy "insert_no_with_check" + on public.posts + for insert + to authenticated; + + select + metadata->>'policy_name' as policy_name, + metadata->>'permissive_with_check' as permissive_with_check + from lint."0024_rls_policy_always_true"; + + drop policy "insert_no_with_check" on public.posts; + +rollback; diff --git a/test/sql/queries_are_unionable.sql b/test/sql/queries_are_unionable.sql index fadfda1..f493a07 100644 --- a/test/sql/queries_are_unionable.sql +++ b/test/sql/queries_are_unionable.sql @@ -42,6 +42,10 @@ begin; union all select * from lint."0021_fkey_to_auth_unique" union all - select * from lint."0022_extension_versions_outdated"; + select * from lint."0022_extension_versions_outdated" + union all + select * from lint."0023_sensitive_columns_exposed" + union all + select * from lint."0024_rls_policy_always_true"; rollback;