From 86db066f195f762a4d0409342bf529978e21277d Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 30 Dec 2025 15:26:05 +0000 Subject: [PATCH 1/7] feat: add lint for detecting sensitive columns exposed via API This commit introduces a new lint that identifies tables exposed via the Supabase Data APIs containing columns with potentially sensitive data (e.g., passwords, SSNs, credit card numbers) without Row Level Security (RLS) enabled. It includes a detailed SQL view and documentation outlining the rationale, patterns detected, and recommended resolutions for mitigating security risks. --- bin/installcheck | 2 +- docs/0023_sensitive_columns_exposed.md | 110 +++++++++++++++++ lints/0023_sensitive_columns_exposed.sql | 111 +++++++++++++++++ splinter.sql | 113 +++++++++++++++++- .../0023_sensitive_columns_exposed.out | 82 +++++++++++++ test/sql/0023_sensitive_columns_exposed.sql | 65 ++++++++++ 6 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 docs/0023_sensitive_columns_exposed.md create mode 100644 lints/0023_sensitive_columns_exposed.sql create mode 100644 test/expected/0023_sensitive_columns_exposed.out create mode 100644 test/sql/0023_sensitive_columns_exposed.sql diff --git a/bin/installcheck b/bin/installcheck index 2d78838..efcf60c 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 -d contrib_regression # Run tests ${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS} 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/lints/0023_sensitive_columns_exposed.sql b/lints/0023_sensitive_columns_exposed.sql new file mode 100644 index 0000000..798d068 --- /dev/null +++ b/lints/0023_sensitive_columns_exposed.sql @@ -0,0 +1,111 @@ +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', 'pass', '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', + 'diagnosis', '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 + 'fingerprint', 'biometric', '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) + lower(a.attname) like '%' || sp.pattern || '%' + or lower(a.attname) = sp.pattern + -- Also check for common variations with underscores/hyphens removed + or replace(replace(lower(a.attname), '_', ''), '-', '') like '%' || replace(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/splinter.sql b/splinter.sql index b203632..e99cc06 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1147,4 +1147,115 @@ 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', 'pass', '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', + 'diagnosis', '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 + 'fingerprint', 'biometric', '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) + lower(a.attname) like '%' || sp.pattern || '%' + or lower(a.attname) = sp.pattern + -- Also check for common variations with underscores/hyphens removed + or replace(replace(lower(a.attname), '_', ''), '-', '') like '%' || replace(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) \ 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..4cccbbb --- /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_hash 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_hash, 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": ["pass", "password", "ssn"], "sensitive_columns": ["password_hash", "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_number 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/sql/0023_sensitive_columns_exposed.sql b/test/sql/0023_sensitive_columns_exposed.sql new file mode 100644 index 0000000..51638ef --- /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_hash 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_number 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; From f64a736320f7169ec3952823566762735ee5c5f7 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 30 Dec 2025 15:31:01 +0000 Subject: [PATCH 2/7] feat: add lint for detecting sensitive columns exposed via API This commit introduces a new lint that identifies tables exposed via the Supabase Data APIs containing columns with potentially sensitive data (e.g., passwords, SSNs, credit card numbers) without Row Level Security (RLS) enabled. It includes a detailed SQL view and documentation outlining the rationale, patterns detected, and recommended resolutions for mitigating security risks. --- bin/installcheck | 2 +- docs/0024_permissive_rls_policy.md | 128 +++++++++++++++++++ lints/0024_permissive_rls_policy.sql | 124 ++++++++++++++++++ splinter.sql | 126 +++++++++++++++++- test/expected/0024_permissive_rls_policy.out | 117 +++++++++++++++++ test/sql/0024_permissive_rls_policy.sql | 100 +++++++++++++++ 6 files changed, 595 insertions(+), 2 deletions(-) create mode 100644 docs/0024_permissive_rls_policy.md create mode 100644 lints/0024_permissive_rls_policy.sql create mode 100644 test/expected/0024_permissive_rls_policy.out create mode 100644 test/sql/0024_permissive_rls_policy.sql diff --git a/bin/installcheck b/bin/installcheck index efcf60c..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 -f lints/0023*.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/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/0024_permissive_rls_policy.sql b/lints/0024_permissive_rls_policy.sql new file mode 100644 index 0000000..a3fb52d --- /dev/null +++ b/lints/0024_permissive_rls_policy.sql @@ -0,0 +1,124 @@ +create view lint."0024_permissive_rls_policy" 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 + 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 + case when ( + -- Literal true + lower(trim(coalesce(qual, ''))) = 'true' + -- 1=1 or similar tautologies + or lower(trim(coalesce(qual, ''))) ~ '^[\s\(]*1\s*=\s*1[\s\)]*$' + or lower(trim(coalesce(qual, ''))) ~ '^[\s\(]*''[^'']*''\s*=\s*''[^'']*''[\s\)]*$' + -- Empty or null qual on permissive policy means allow all for SELECT + or (qual is null and is_permissive and command in ('SELECT', 'ALL')) + ) then true else false end as has_permissive_using, + -- Check for always-true WITH CHECK clause patterns + case when ( + lower(trim(coalesce(with_check, ''))) = 'true' + or lower(trim(coalesce(with_check, ''))) ~ '^[\s\(]*1\s*=\s*1[\s\)]*$' + or lower(trim(coalesce(with_check, ''))) ~ '^[\s\(]*''[^'']*''\s*=\s*''[^'']*''[\s\)]*$' + -- Empty with_check on permissive INSERT/UPDATE policy means allow all + or (with_check is null and is_permissive and command in ('INSERT', 'UPDATE', 'ALL')) + ) 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 + 'permissive_rls_policy' as name, + 'Permissive RLS Policy' 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)\`, which effectively allow unrestricted access and may indicate a security misconfiguration.' as description, + format( + 'Table `%s.%s` has a permissive 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( + 'permissive_rls_policy_%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 e99cc06..6d235ae 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1258,4 +1258,128 @@ group by table_name order by schema_name, - table_name) \ No newline at end of file + 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 + 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 + case when ( + -- Literal true + lower(trim(coalesce(qual, ''))) = 'true' + -- 1=1 or similar tautologies + or lower(trim(coalesce(qual, ''))) ~ '^[\s\(]*1\s*=\s*1[\s\)]*$' + or lower(trim(coalesce(qual, ''))) ~ '^[\s\(]*''[^'']*''\s*=\s*''[^'']*''[\s\)]*$' + -- Empty or null qual on permissive policy means allow all for SELECT + or (qual is null and is_permissive and command in ('SELECT', 'ALL')) + ) then true else false end as has_permissive_using, + -- Check for always-true WITH CHECK clause patterns + case when ( + lower(trim(coalesce(with_check, ''))) = 'true' + or lower(trim(coalesce(with_check, ''))) ~ '^[\s\(]*1\s*=\s*1[\s\)]*$' + or lower(trim(coalesce(with_check, ''))) ~ '^[\s\(]*''[^'']*''\s*=\s*''[^'']*''[\s\)]*$' + -- Empty with_check on permissive INSERT/UPDATE policy means allow all + or (with_check is null and is_permissive and command in ('INSERT', 'UPDATE', 'ALL')) + ) 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 + 'permissive_rls_policy' as name, + 'Permissive RLS Policy' 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)\`, which effectively allow unrestricted access and may indicate a security misconfiguration.' as description, + format( + 'Table `%s.%s` has a permissive 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( + 'permissive_rls_policy_%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/0024_permissive_rls_policy.out b/test/expected/0024_permissive_rls_policy.out new file mode 100644 index 0000000..ec24f99 --- /dev/null +++ b/test/expected/0024_permissive_rls_policy.out @@ -0,0 +1,117 @@ +begin; + set local search_path = ''; + set local pgrst.db_schemas = 'public'; + -- 0 issues - no tables yet + select * from lint."0024_permissive_rls_policy"; + 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; + -- Create a permissive policy with USING (true) - should be flagged + create policy "allow_all_select" + on public.posts + for select + to authenticated + using (true); + -- 1 issue - policy with USING (true) + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_permissive_rls_policy"; + policy_name | command +------------------+--------- + allow_all_select | SELECT +(1 row) + + -- Create another policy with 1=1 tautology + create policy "allow_all_insert" + on public.posts + for insert + to authenticated + with check (1=1); + -- 2 issues + select count(*) from lint."0024_permissive_rls_policy"; + count +------- + 2 +(1 row) + + -- Drop the bad policies + drop policy "allow_all_select" on public.posts; + drop policy "allow_all_insert" on public.posts; + -- Create a proper policy + create policy "users_own_posts" + on public.posts + for select + to authenticated + using (user_id = auth.uid()); + -- 0 issues - proper policy + select * from lint."0024_permissive_rls_policy"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Test with anon role + create table public.secrets( + id int primary key, + data text + ); + alter table public.secrets enable row level security; + create policy "bad_anon_policy" + on public.secrets + for select + to anon + using (true); + -- 1 issue - permissive policy for anon + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_permissive_rls_policy"; + policy_name | command +-----------------+--------- + bad_anon_policy | SELECT +(1 row) + + -- Test that policy for non-anon/authenticated role is not flagged + drop policy "bad_anon_policy" on public.secrets; + create role custom_role; + create policy "custom_role_policy" + on public.secrets + for select + to custom_role + using (true); + -- 0 issues - policy is for custom_role, not anon/authenticated + select * from lint."0024_permissive_rls_policy"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Test public (all roles) policy - should be flagged + drop policy "custom_role_policy" on public.secrets; + create policy "public_policy" + on public.secrets + for select + using (true); -- applies to all roles by default + -- 1 issue - policy applies to public + select metadata->>'policy_name' as policy_name from lint."0024_permissive_rls_policy"; + policy_name +--------------- + public_policy +(1 row) + + -- Test restrictive policy with true - should NOT be flagged (less dangerous) + drop policy "public_policy" on public.secrets; + create policy "restrictive_true" + on public.secrets + as restrictive + for select + to authenticated + using (true); + -- 0 issues - restrictive policies are not flagged + select * from lint."0024_permissive_rls_policy"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + +rollback; diff --git a/test/sql/0024_permissive_rls_policy.sql b/test/sql/0024_permissive_rls_policy.sql new file mode 100644 index 0000000..3ce61bc --- /dev/null +++ b/test/sql/0024_permissive_rls_policy.sql @@ -0,0 +1,100 @@ +begin; + set local search_path = ''; + set local pgrst.db_schemas = 'public'; + + -- 0 issues - no tables yet + select * from lint."0024_permissive_rls_policy"; + + -- 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; + + -- Create a permissive policy with USING (true) - should be flagged + create policy "allow_all_select" + on public.posts + for select + to authenticated + using (true); + + -- 1 issue - policy with USING (true) + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_permissive_rls_policy"; + + -- Create another policy with 1=1 tautology + create policy "allow_all_insert" + on public.posts + for insert + to authenticated + with check (1=1); + + -- 2 issues + select count(*) from lint."0024_permissive_rls_policy"; + + -- Drop the bad policies + drop policy "allow_all_select" on public.posts; + drop policy "allow_all_insert" on public.posts; + + -- Create a proper policy + create policy "users_own_posts" + on public.posts + for select + to authenticated + using (user_id = auth.uid()); + + -- 0 issues - proper policy + select * from lint."0024_permissive_rls_policy"; + + -- Test with anon role + create table public.secrets( + id int primary key, + data text + ); + alter table public.secrets enable row level security; + + create policy "bad_anon_policy" + on public.secrets + for select + to anon + using (true); + + -- 1 issue - permissive policy for anon + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_permissive_rls_policy"; + + -- Test that policy for non-anon/authenticated role is not flagged + drop policy "bad_anon_policy" on public.secrets; + create role custom_role; + create policy "custom_role_policy" + on public.secrets + for select + to custom_role + using (true); + + -- 0 issues - policy is for custom_role, not anon/authenticated + select * from lint."0024_permissive_rls_policy"; + + -- Test public (all roles) policy - should be flagged + drop policy "custom_role_policy" on public.secrets; + create policy "public_policy" + on public.secrets + for select + using (true); -- applies to all roles by default + + -- 1 issue - policy applies to public + select metadata->>'policy_name' as policy_name from lint."0024_permissive_rls_policy"; + + -- Test restrictive policy with true - should NOT be flagged (less dangerous) + drop policy "public_policy" on public.secrets; + create policy "restrictive_true" + on public.secrets + as restrictive + for select + to authenticated + using (true); + + -- 0 issues - restrictive policies are not flagged + select * from lint."0024_permissive_rls_policy"; + +rollback; From 34550b0843bbf6ffab425aae4119a444543cb8e2 Mon Sep 17 00:00:00 2001 From: Oliver Rice Date: Wed, 31 Dec 2025 13:28:58 -0600 Subject: [PATCH 3/7] add new lints to the unionable test --- dockerfiles/docker-compose.yml | 6 +++++- test/expected/queries_are_unionable.out | 6 +++++- test/sql/queries_are_unionable.sql | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) 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/test/expected/queries_are_unionable.out b/test/expected/queries_are_unionable.out index cf0e50e..e44868b 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_permissive_rls_policy"; name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) diff --git a/test/sql/queries_are_unionable.sql b/test/sql/queries_are_unionable.sql index fadfda1..645fd65 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_permissive_rls_policy"; rollback; From ff89d4c1bafb1dab5437357aea99b58e89b1eeb9 Mon Sep 17 00:00:00 2001 From: Oliver Rice Date: Wed, 31 Dec 2025 13:36:33 -0600 Subject: [PATCH 4/7] show full record on first test case for rls police (true) --- test/expected/0024_permissive_rls_policy.out | 8 ++++---- test/sql/0024_permissive_rls_policy.sql | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/expected/0024_permissive_rls_policy.out b/test/expected/0024_permissive_rls_policy.out index ec24f99..a27e1bb 100644 --- a/test/expected/0024_permissive_rls_policy.out +++ b/test/expected/0024_permissive_rls_policy.out @@ -21,10 +21,10 @@ begin; to authenticated using (true); -- 1 issue - policy with USING (true) - select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_permissive_rls_policy"; - policy_name | command -------------------+--------- - allow_all_select | SELECT + select * from lint."0024_permissive_rls_policy"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +-----------------------+-----------------------+-------+----------+------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------- + permissive_rls_policy | Permissive RLS Policy | WARN | EXTERNAL | {SECURITY} | Detects RLS policies that use overly permissive expressions like \`USING (true)\` or \`WITH CHECK (true)\`, which effectively allow unrestricted access and may indicate a security misconfiguration. | Table `public.posts` has a permissive RLS policy `allow_all_select` for `SELECT` that allows unrestricted access (USING clause is always true). This effectively bypasses row-level security for authenticated. | https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy | {"name": "posts", "qual": "true", "type": "table", "roles": ["authenticated"], "schema": "public", "command": "SELECT", "with_check": null, "policy_name": "allow_all_select", "permissive_using": true, "permissive_with_check": false} | permissive_rls_policy_public_posts_allow_all_select (1 row) -- Create another policy with 1=1 tautology diff --git a/test/sql/0024_permissive_rls_policy.sql b/test/sql/0024_permissive_rls_policy.sql index 3ce61bc..051dacb 100644 --- a/test/sql/0024_permissive_rls_policy.sql +++ b/test/sql/0024_permissive_rls_policy.sql @@ -21,7 +21,7 @@ begin; using (true); -- 1 issue - policy with USING (true) - select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_permissive_rls_policy"; + select * from lint."0024_permissive_rls_policy"; -- Create another policy with 1=1 tautology create policy "allow_all_insert" From abd47e11b9c06c96a468cdaa2ab24de10c0aa5ed Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 4 Jan 2026 01:50:19 +0000 Subject: [PATCH 5/7] feat: update sensitive column detection and add RLS policy checks --- lints/0023_sensitive_columns_exposed.sql | 13 +- ...cy.sql => 0024_rls_policy_always_true.sql} | 34 +++-- .../0023_sensitive_columns_exposed.out | 10 +- test/expected/0024_permissive_rls_policy.out | 117 -------------- test/expected/0024_rls_policy_always_true.out | 143 ++++++++++++++++++ test/expected/queries_are_unionable.out | 2 +- test/sql/0023_sensitive_columns_exposed.sql | 4 +- ...cy.sql => 0024_rls_policy_always_true.sql} | 54 +++++-- test/sql/queries_are_unionable.sql | 2 +- 9 files changed, 214 insertions(+), 165 deletions(-) rename lints/{0024_permissive_rls_policy.sql => 0024_rls_policy_always_true.sql} (74%) delete mode 100644 test/expected/0024_permissive_rls_policy.out create mode 100644 test/expected/0024_rls_policy_always_true.out rename test/sql/{0024_permissive_rls_policy.sql => 0024_rls_policy_always_true.sql} (55%) diff --git a/lints/0023_sensitive_columns_exposed.sql b/lints/0023_sensitive_columns_exposed.sql index 798d068..b0002ef 100644 --- a/lints/0023_sensitive_columns_exposed.sql +++ b/lints/0023_sensitive_columns_exposed.sql @@ -5,7 +5,7 @@ create view lint."0023_sensitive_columns_exposed" as with sensitive_patterns as ( select unnest(array[ -- Authentication & Credentials - 'password', 'passwd', 'pwd', 'pass', 'passphrase', + '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', @@ -23,14 +23,14 @@ with sensitive_patterns as ( -- Health & Medical 'health_record', 'medical_record', 'patient_id', 'insurance_number', 'health_insurance', 'medical_insurance', - 'diagnosis', 'treatment', + '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 - 'fingerprint', 'biometric', 'facial_recognition' + 'facial_recognition' ]) as pattern ), exposed_tables as ( @@ -69,11 +69,8 @@ sensitive_columns as ( and not a.attisdropped cross join sensitive_patterns sp where - -- Match column name against sensitive patterns (case insensitive) - lower(a.attname) like '%' || sp.pattern || '%' - or lower(a.attname) = sp.pattern - -- Also check for common variations with underscores/hyphens removed - or replace(replace(lower(a.attname), '_', ''), '-', '') like '%' || replace(sp.pattern, '_', '') || '%' + -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants + replace(lower(a.attname), '-', '_') = sp.pattern ) select 'sensitive_columns_exposed' as name, diff --git a/lints/0024_permissive_rls_policy.sql b/lints/0024_rls_policy_always_true.sql similarity index 74% rename from lints/0024_permissive_rls_policy.sql rename to lints/0024_rls_policy_always_true.sql index a3fb52d..b9b3649 100644 --- a/lints/0024_permissive_rls_policy.sql +++ b/lints/0024_rls_policy_always_true.sql @@ -1,4 +1,4 @@ -create view lint."0024_permissive_rls_policy" as +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 @@ -40,20 +40,22 @@ 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 ( - -- Literal true - lower(trim(coalesce(qual, ''))) = 'true' - -- 1=1 or similar tautologies - or lower(trim(coalesce(qual, ''))) ~ '^[\s\(]*1\s*=\s*1[\s\)]*$' - or lower(trim(coalesce(qual, ''))) ~ '^[\s\(]*''[^'']*''\s*=\s*''[^'']*''[\s\)]*$' - -- Empty or null qual on permissive policy means allow all for SELECT - or (qual is null and is_permissive and command in ('SELECT', 'ALL')) + command in ('UPDATE', 'DELETE', 'ALL') + and ( + -- Literal true or (true) + replace(replace(replace(lower(coalesce(qual, '')), ' ', ''), E'\n', ''), E'\t', '') in ('true', '(true)') + -- (1=1) tautology + or replace(replace(replace(lower(coalesce(qual, '')), ' ', ''), E'\n', ''), E'\t', '') in ('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 ( - lower(trim(coalesce(with_check, ''))) = 'true' - or lower(trim(coalesce(with_check, ''))) ~ '^[\s\(]*1\s*=\s*1[\s\)]*$' - or lower(trim(coalesce(with_check, ''))) ~ '^[\s\(]*''[^'']*''\s*=\s*''[^'']*''[\s\)]*$' + replace(replace(replace(lower(coalesce(with_check, '')), ' ', ''), E'\n', ''), E'\t', '') in ('true', '(true)') + or replace(replace(replace(lower(coalesce(with_check, '')), ' ', ''), E'\n', ''), E'\t', '') in ('1=1', '(1=1)') -- Empty with_check on permissive INSERT/UPDATE policy means allow all or (with_check is null and is_permissive and command in ('INSERT', 'UPDATE', 'ALL')) ) then true else false end as has_permissive_with_check @@ -75,14 +77,14 @@ permissive_patterns as ( ) ) select - 'permissive_rls_policy' as name, - 'Permissive RLS Policy' as title, + '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)\`, which effectively allow unrestricted access and may indicate a security misconfiguration.' as description, + '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 a permissive RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.', + '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, @@ -109,7 +111,7 @@ select 'permissive_with_check', has_permissive_with_check ) as metadata, format( - 'permissive_rls_policy_%s_%s_%s', + 'rls_policy_always_true_%s_%s_%s', schema_name, table_name, policy_name diff --git a/test/expected/0023_sensitive_columns_exposed.out b/test/expected/0023_sensitive_columns_exposed.out index 4cccbbb..3cb9d0e 100644 --- a/test/expected/0023_sensitive_columns_exposed.out +++ b/test/expected/0023_sensitive_columns_exposed.out @@ -11,21 +11,21 @@ begin; create table public.users( id int primary key, username text not null, - password_hash 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_hash, 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": ["pass", "password", "ssn"], "sensitive_columns": ["password_hash", "ssn"]} | sensitive_columns_exposed_public_users + 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_number text, + credit_card text, cvv text, amount numeric ); diff --git a/test/expected/0024_permissive_rls_policy.out b/test/expected/0024_permissive_rls_policy.out deleted file mode 100644 index a27e1bb..0000000 --- a/test/expected/0024_permissive_rls_policy.out +++ /dev/null @@ -1,117 +0,0 @@ -begin; - set local search_path = ''; - set local pgrst.db_schemas = 'public'; - -- 0 issues - no tables yet - select * from lint."0024_permissive_rls_policy"; - 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; - -- Create a permissive policy with USING (true) - should be flagged - create policy "allow_all_select" - on public.posts - for select - to authenticated - using (true); - -- 1 issue - policy with USING (true) - select * from lint."0024_permissive_rls_policy"; - name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------------------------+-----------------------+-------+----------+------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------------------------- - permissive_rls_policy | Permissive RLS Policy | WARN | EXTERNAL | {SECURITY} | Detects RLS policies that use overly permissive expressions like \`USING (true)\` or \`WITH CHECK (true)\`, which effectively allow unrestricted access and may indicate a security misconfiguration. | Table `public.posts` has a permissive RLS policy `allow_all_select` for `SELECT` that allows unrestricted access (USING clause is always true). This effectively bypasses row-level security for authenticated. | https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy | {"name": "posts", "qual": "true", "type": "table", "roles": ["authenticated"], "schema": "public", "command": "SELECT", "with_check": null, "policy_name": "allow_all_select", "permissive_using": true, "permissive_with_check": false} | permissive_rls_policy_public_posts_allow_all_select -(1 row) - - -- Create another policy with 1=1 tautology - create policy "allow_all_insert" - on public.posts - for insert - to authenticated - with check (1=1); - -- 2 issues - select count(*) from lint."0024_permissive_rls_policy"; - count -------- - 2 -(1 row) - - -- Drop the bad policies - drop policy "allow_all_select" on public.posts; - drop policy "allow_all_insert" on public.posts; - -- Create a proper policy - create policy "users_own_posts" - on public.posts - for select - to authenticated - using (user_id = auth.uid()); - -- 0 issues - proper policy - select * from lint."0024_permissive_rls_policy"; - name | title | level | facing | categories | description | detail | remediation | metadata | cache_key -------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- -(0 rows) - - -- Test with anon role - create table public.secrets( - id int primary key, - data text - ); - alter table public.secrets enable row level security; - create policy "bad_anon_policy" - on public.secrets - for select - to anon - using (true); - -- 1 issue - permissive policy for anon - select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_permissive_rls_policy"; - policy_name | command ------------------+--------- - bad_anon_policy | SELECT -(1 row) - - -- Test that policy for non-anon/authenticated role is not flagged - drop policy "bad_anon_policy" on public.secrets; - create role custom_role; - create policy "custom_role_policy" - on public.secrets - for select - to custom_role - using (true); - -- 0 issues - policy is for custom_role, not anon/authenticated - select * from lint."0024_permissive_rls_policy"; - name | title | level | facing | categories | description | detail | remediation | metadata | cache_key -------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- -(0 rows) - - -- Test public (all roles) policy - should be flagged - drop policy "custom_role_policy" on public.secrets; - create policy "public_policy" - on public.secrets - for select - using (true); -- applies to all roles by default - -- 1 issue - policy applies to public - select metadata->>'policy_name' as policy_name from lint."0024_permissive_rls_policy"; - policy_name ---------------- - public_policy -(1 row) - - -- Test restrictive policy with true - should NOT be flagged (less dangerous) - drop policy "public_policy" on public.secrets; - create policy "restrictive_true" - on public.secrets - as restrictive - for select - to authenticated - using (true); - -- 0 issues - restrictive policies are not flagged - select * from lint."0024_permissive_rls_policy"; - 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..0857370 --- /dev/null +++ b/test/expected/0024_rls_policy_always_true.out @@ -0,0 +1,143 @@ +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; + -- Create a permissive policy with USING (true) for SELECT - should NOT be flagged + -- SELECT with (true) is often intentional for public read access + create policy "allow_all_select" + on public.posts + for select + to authenticated + using (true); + -- 0 issues - SELECT with USING (true) is allowed + select * from lint."0024_rls_policy_always_true"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Create another policy with 1=1 tautology + create policy "allow_all_insert" + on public.posts + for insert + to authenticated + with check (1=1); + -- 1 issue - only INSERT with WITH CHECK (1=1) is flagged + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 1 +(1 row) + + -- Drop the bad policies + drop policy "allow_all_select" on public.posts; + drop policy "allow_all_insert" on public.posts; + -- Create a proper policy + create policy "users_own_posts" + on public.posts + for select + to authenticated + using (user_id = auth.uid()); + -- 0 issues - proper policy + select * from lint."0024_rls_policy_always_true"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Test with anon role + create table public.secrets( + id int primary key, + data text + ); + alter table public.secrets enable row level security; + create policy "bad_anon_policy" + on public.secrets + for select + to anon + using (true); + -- 0 issues - SELECT with USING (true) is allowed even for anon + select * from lint."0024_rls_policy_always_true"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Test that policy for non-anon/authenticated role is not flagged + drop policy "bad_anon_policy" on public.secrets; + create role custom_role; + create policy "custom_role_policy" + on public.secrets + for select + to custom_role + using (true); + -- 0 issues - policy is for custom_role, not anon/authenticated + select * from lint."0024_rls_policy_always_true"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Test public (all roles) SELECT policy - should NOT be flagged + drop policy "custom_role_policy" on public.secrets; + create policy "public_policy" + on public.secrets + for select + using (true); -- applies to all roles by default + -- 0 issues - SELECT with USING (true) is allowed + select * from lint."0024_rls_policy_always_true"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + -- Test UPDATE with USING (true) - should be flagged + drop policy "public_policy" on public.secrets; + create policy "bad_update_policy" + on public.secrets + for update + to authenticated + using (true); + -- 1 issue - UPDATE with USING (true) is dangerous + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + policy_name | command +-------------------+--------- + bad_update_policy | UPDATE +(1 row) + + -- Test DELETE with USING (true) - should be flagged + drop policy "bad_update_policy" on public.secrets; + create policy "bad_delete_policy" + on public.secrets + for delete + to authenticated + using (true); + -- 1 issue - DELETE with USING (true) is dangerous + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + policy_name | command +-------------------+--------- + bad_delete_policy | DELETE +(1 row) + + drop policy "bad_delete_policy" on public.secrets; + -- Test restrictive policy with true - should NOT be flagged (less dangerous) + create policy "restrictive_true" + on public.secrets + as restrictive + for select + to authenticated + using (true); + -- 0 issues - restrictive policies are not flagged + select * from lint."0024_rls_policy_always_true"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + +rollback; diff --git a/test/expected/queries_are_unionable.out b/test/expected/queries_are_unionable.out index e44868b..b745bf6 100644 --- a/test/expected/queries_are_unionable.out +++ b/test/expected/queries_are_unionable.out @@ -44,7 +44,7 @@ begin; union all select * from lint."0023_sensitive_columns_exposed" union all - select * from lint."0024_permissive_rls_policy"; + 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 index 51638ef..e3d921f 100644 --- a/test/sql/0023_sensitive_columns_exposed.sql +++ b/test/sql/0023_sensitive_columns_exposed.sql @@ -9,7 +9,7 @@ begin; create table public.users( id int primary key, username text not null, - password_hash text not null, + password text not null, ssn text ); @@ -20,7 +20,7 @@ begin; create table public.payments( id int primary key, user_id int references public.users(id), - credit_card_number text, + credit_card text, cvv text, amount numeric ); diff --git a/test/sql/0024_permissive_rls_policy.sql b/test/sql/0024_rls_policy_always_true.sql similarity index 55% rename from test/sql/0024_permissive_rls_policy.sql rename to test/sql/0024_rls_policy_always_true.sql index 051dacb..9a26696 100644 --- a/test/sql/0024_permissive_rls_policy.sql +++ b/test/sql/0024_rls_policy_always_true.sql @@ -3,7 +3,7 @@ begin; set local pgrst.db_schemas = 'public'; -- 0 issues - no tables yet - select * from lint."0024_permissive_rls_policy"; + select * from lint."0024_rls_policy_always_true"; -- Create a table with RLS enabled create table public.posts( @@ -13,15 +13,16 @@ begin; ); alter table public.posts enable row level security; - -- Create a permissive policy with USING (true) - should be flagged + -- Create a permissive policy with USING (true) for SELECT - should NOT be flagged + -- SELECT with (true) is often intentional for public read access create policy "allow_all_select" on public.posts for select to authenticated using (true); - -- 1 issue - policy with USING (true) - select * from lint."0024_permissive_rls_policy"; + -- 0 issues - SELECT with USING (true) is allowed + select * from lint."0024_rls_policy_always_true"; -- Create another policy with 1=1 tautology create policy "allow_all_insert" @@ -30,8 +31,8 @@ begin; to authenticated with check (1=1); - -- 2 issues - select count(*) from lint."0024_permissive_rls_policy"; + -- 1 issue - only INSERT with WITH CHECK (1=1) is flagged + select count(*) from lint."0024_rls_policy_always_true"; -- Drop the bad policies drop policy "allow_all_select" on public.posts; @@ -45,7 +46,7 @@ begin; using (user_id = auth.uid()); -- 0 issues - proper policy - select * from lint."0024_permissive_rls_policy"; + select * from lint."0024_rls_policy_always_true"; -- Test with anon role create table public.secrets( @@ -60,8 +61,8 @@ begin; to anon using (true); - -- 1 issue - permissive policy for anon - select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_permissive_rls_policy"; + -- 0 issues - SELECT with USING (true) is allowed even for anon + select * from lint."0024_rls_policy_always_true"; -- Test that policy for non-anon/authenticated role is not flagged drop policy "bad_anon_policy" on public.secrets; @@ -73,20 +74,43 @@ begin; using (true); -- 0 issues - policy is for custom_role, not anon/authenticated - select * from lint."0024_permissive_rls_policy"; + select * from lint."0024_rls_policy_always_true"; - -- Test public (all roles) policy - should be flagged + -- Test public (all roles) SELECT policy - should NOT be flagged drop policy "custom_role_policy" on public.secrets; create policy "public_policy" on public.secrets for select using (true); -- applies to all roles by default - -- 1 issue - policy applies to public - select metadata->>'policy_name' as policy_name from lint."0024_permissive_rls_policy"; + -- 0 issues - SELECT with USING (true) is allowed + select * from lint."0024_rls_policy_always_true"; - -- Test restrictive policy with true - should NOT be flagged (less dangerous) + -- Test UPDATE with USING (true) - should be flagged drop policy "public_policy" on public.secrets; + create policy "bad_update_policy" + on public.secrets + for update + to authenticated + using (true); + + -- 1 issue - UPDATE with USING (true) is dangerous + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + + -- Test DELETE with USING (true) - should be flagged + drop policy "bad_update_policy" on public.secrets; + create policy "bad_delete_policy" + on public.secrets + for delete + to authenticated + using (true); + + -- 1 issue - DELETE with USING (true) is dangerous + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + + drop policy "bad_delete_policy" on public.secrets; + + -- Test restrictive policy with true - should NOT be flagged (less dangerous) create policy "restrictive_true" on public.secrets as restrictive @@ -95,6 +119,6 @@ begin; using (true); -- 0 issues - restrictive policies are not flagged - select * from lint."0024_permissive_rls_policy"; + select * from lint."0024_rls_policy_always_true"; rollback; diff --git a/test/sql/queries_are_unionable.sql b/test/sql/queries_are_unionable.sql index 645fd65..f493a07 100644 --- a/test/sql/queries_are_unionable.sql +++ b/test/sql/queries_are_unionable.sql @@ -46,6 +46,6 @@ begin; union all select * from lint."0023_sensitive_columns_exposed" union all - select * from lint."0024_permissive_rls_policy"; + select * from lint."0024_rls_policy_always_true"; rollback; From 9c5f79a67d2fbd508852932b879917826e3d750f Mon Sep 17 00:00:00 2001 From: Oliver Rice Date: Sun, 4 Jan 2026 12:51:15 -0600 Subject: [PATCH 6/7] extract duplicate logic --- lints/0024_rls_policy_always_true.sql | 13 ++++---- splinter.sql | 46 +++++++++++++-------------- 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/lints/0024_rls_policy_always_true.sql b/lints/0024_rls_policy_always_true.sql index b9b3649..a32f425 100644 --- a/lints/0024_rls_policy_always_true.sql +++ b/lints/0024_rls_policy_always_true.sql @@ -19,7 +19,10 @@ with policies as ( when '*' then 'ALL' end as command, pb.qual, - pb.with_check + 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 @@ -44,18 +47,14 @@ permissive_patterns as ( case when ( command in ('UPDATE', 'DELETE', 'ALL') and ( - -- Literal true or (true) - replace(replace(replace(lower(coalesce(qual, '')), ' ', ''), E'\n', ''), E'\t', '') in ('true', '(true)') - -- (1=1) tautology - or replace(replace(replace(lower(coalesce(qual, '')), ' ', ''), E'\n', ''), E'\t', '') in ('1=1', '(1=1)') + 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 ( - replace(replace(replace(lower(coalesce(with_check, '')), ' ', ''), E'\n', ''), E'\t', '') in ('true', '(true)') - or replace(replace(replace(lower(coalesce(with_check, '')), ' ', ''), E'\n', ''), E'\t', '') in ('1=1', '(1=1)') + normalized_with_check in ('true', '(true)', '1=1', '(1=1)') -- Empty with_check on permissive INSERT/UPDATE policy means allow all or (with_check is null and is_permissive and command in ('INSERT', 'UPDATE', 'ALL')) ) then true else false end as has_permissive_with_check diff --git a/splinter.sql b/splinter.sql index 6d235ae..2766482 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1155,7 +1155,7 @@ union all with sensitive_patterns as ( select unnest(array[ -- Authentication & Credentials - 'password', 'passwd', 'pwd', 'pass', 'passphrase', + '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', @@ -1173,14 +1173,14 @@ with sensitive_patterns as ( -- Health & Medical 'health_record', 'medical_record', 'patient_id', 'insurance_number', 'health_insurance', 'medical_insurance', - 'diagnosis', 'treatment', + '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 - 'fingerprint', 'biometric', 'facial_recognition' + 'facial_recognition' ]) as pattern ), exposed_tables as ( @@ -1219,11 +1219,8 @@ sensitive_columns as ( and not a.attisdropped cross join sensitive_patterns sp where - -- Match column name against sensitive patterns (case insensitive) - lower(a.attname) like '%' || sp.pattern || '%' - or lower(a.attname) = sp.pattern - -- Also check for common variations with underscores/hyphens removed - or replace(replace(lower(a.attname), '_', ''), '-', '') like '%' || replace(sp.pattern, '_', '') || '%' + -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants + replace(lower(a.attname), '-', '_') = sp.pattern ) select 'sensitive_columns_exposed' as name, @@ -1280,7 +1277,10 @@ with policies as ( when '*' then 'ALL' end as command, pb.qual, - pb.with_check + 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 @@ -1301,20 +1301,18 @@ 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 ( - -- Literal true - lower(trim(coalesce(qual, ''))) = 'true' - -- 1=1 or similar tautologies - or lower(trim(coalesce(qual, ''))) ~ '^[\s\(]*1\s*=\s*1[\s\)]*$' - or lower(trim(coalesce(qual, ''))) ~ '^[\s\(]*''[^'']*''\s*=\s*''[^'']*''[\s\)]*$' - -- Empty or null qual on permissive policy means allow all for SELECT - or (qual is null and is_permissive and command in ('SELECT', 'ALL')) + 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 ( - lower(trim(coalesce(with_check, ''))) = 'true' - or lower(trim(coalesce(with_check, ''))) ~ '^[\s\(]*1\s*=\s*1[\s\)]*$' - or lower(trim(coalesce(with_check, ''))) ~ '^[\s\(]*''[^'']*''\s*=\s*''[^'']*''[\s\)]*$' + normalized_with_check in ('true', '(true)', '1=1', '(1=1)') -- Empty with_check on permissive INSERT/UPDATE policy means allow all or (with_check is null and is_permissive and command in ('INSERT', 'UPDATE', 'ALL')) ) then true else false end as has_permissive_with_check @@ -1336,14 +1334,14 @@ permissive_patterns as ( ) ) select - 'permissive_rls_policy' as name, - 'Permissive RLS Policy' as title, + '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)\`, which effectively allow unrestricted access and may indicate a security misconfiguration.' as description, + '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 a permissive RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.', + '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, @@ -1370,7 +1368,7 @@ select 'permissive_with_check', has_permissive_with_check ) as metadata, format( - 'permissive_rls_policy_%s_%s_%s', + 'rls_policy_always_true_%s_%s_%s', schema_name, table_name, policy_name From d1799dc544c8406f08fe24a4f18d7abbb8a63de8 Mon Sep 17 00:00:00 2001 From: Oliver Rice Date: Sun, 4 Jan 2026 13:01:27 -0600 Subject: [PATCH 7/7] handle edge case empty with_check with/without using fallback --- lints/0024_rls_policy_always_true.sql | 7 +- splinter.sql | 7 +- test/expected/0024_rls_policy_always_true.out | 352 +++++++++++++----- test/sql/0024_rls_policy_always_true.sql | 280 ++++++++++---- 4 files changed, 485 insertions(+), 161 deletions(-) diff --git a/lints/0024_rls_policy_always_true.sql b/lints/0024_rls_policy_always_true.sql index a32f425..40c7a01 100644 --- a/lints/0024_rls_policy_always_true.sql +++ b/lints/0024_rls_policy_always_true.sql @@ -55,8 +55,11 @@ permissive_patterns as ( -- Check for always-true WITH CHECK clause patterns case when ( normalized_with_check in ('true', '(true)', '1=1', '(1=1)') - -- Empty with_check on permissive INSERT/UPDATE policy means allow all - or (with_check is null and is_permissive and command in ('INSERT', 'UPDATE', 'ALL')) + -- 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 diff --git a/splinter.sql b/splinter.sql index 2766482..14cba60 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1313,8 +1313,11 @@ permissive_patterns as ( -- Check for always-true WITH CHECK clause patterns case when ( normalized_with_check in ('true', '(true)', '1=1', '(1=1)') - -- Empty with_check on permissive INSERT/UPDATE policy means allow all - or (with_check is null and is_permissive and command in ('INSERT', 'UPDATE', 'ALL')) + -- 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 diff --git a/test/expected/0024_rls_policy_always_true.out b/test/expected/0024_rls_policy_always_true.out index 0857370..35fc690 100644 --- a/test/expected/0024_rls_policy_always_true.out +++ b/test/expected/0024_rls_policy_always_true.out @@ -14,130 +14,298 @@ begin; title text ); alter table public.posts enable row level security; - -- Create a permissive policy with USING (true) for SELECT - should NOT be flagged + ---------------------------------------- + -- Test: SELECT with USING (true) should NOT be flagged -- SELECT with (true) is often intentional for public read access - create policy "allow_all_select" + ---------------------------------------- + create policy "select_using_true" on public.posts for select to authenticated using (true); - -- 0 issues - SELECT with USING (true) is allowed - select * from lint."0024_rls_policy_always_true"; - name | title | level | facing | categories | description | detail | remediation | metadata | cache_key -------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- -(0 rows) + 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) - -- Create another policy with 1=1 tautology - create policy "allow_all_insert" + 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); - -- 1 issue - only INSERT with WITH CHECK (1=1) is flagged - select count(*) from lint."0024_rls_policy_always_true"; - count -------- - 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 the bad policies - drop policy "allow_all_select" on public.posts; - drop policy "allow_all_insert" on public.posts; - -- Create a proper policy - create policy "users_own_posts" + 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 select + for update to authenticated - using (user_id = auth.uid()); - -- 0 issues - proper policy - select * from lint."0024_rls_policy_always_true"; - name | title | level | facing | categories | description | detail | remediation | metadata | cache_key -------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- -(0 rows) + 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) - -- Test with anon role - create table public.secrets( - id int primary key, - data text - ); - alter table public.secrets enable row level security; - create policy "bad_anon_policy" - on public.secrets - for select - to anon + 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); - -- 0 issues - SELECT with USING (true) is allowed even for anon - select * from lint."0024_rls_policy_always_true"; - name | title | level | facing | categories | description | detail | remediation | metadata | cache_key -------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- -(0 rows) + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) - -- Test that policy for non-anon/authenticated role is not flagged - drop policy "bad_anon_policy" on public.secrets; + 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.secrets - for select + on public.posts + for update to custom_role using (true); - -- 0 issues - policy is for custom_role, not anon/authenticated - select * from lint."0024_rls_policy_always_true"; - name | title | level | facing | categories | description | detail | remediation | metadata | cache_key -------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- -(0 rows) + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) - -- Test public (all roles) SELECT policy - should NOT be flagged - drop policy "custom_role_policy" on public.secrets; - create policy "public_policy" - on public.secrets - for select - using (true); -- applies to all roles by default - -- 0 issues - SELECT with USING (true) is allowed - select * from lint."0024_rls_policy_always_true"; - name | title | level | facing | categories | description | detail | remediation | metadata | cache_key -------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- -(0 rows) + 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) - -- Test UPDATE with USING (true) - should be flagged - drop policy "public_policy" on public.secrets; - create policy "bad_update_policy" - on public.secrets + 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 - to authenticated using (true); - -- 1 issue - UPDATE with USING (true) is dangerous - select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; - policy_name | command --------------------+--------- - bad_update_policy | UPDATE + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +-------------------- + public_update_true (1 row) - -- Test DELETE with USING (true) - should be flagged - drop policy "bad_update_policy" on public.secrets; - create policy "bad_delete_policy" - on public.secrets - for delete + 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); - -- 1 issue - DELETE with USING (true) is dangerous - select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; - policy_name | command --------------------+--------- - bad_delete_policy | DELETE + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 (1 row) - drop policy "bad_delete_policy" on public.secrets; - -- Test restrictive policy with true - should NOT be flagged (less dangerous) - create policy "restrictive_true" - on public.secrets - as restrictive - for select + 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); - -- 0 issues - restrictive policies are not flagged - select * from lint."0024_rls_policy_always_true"; - name | title | level | facing | categories | description | detail | remediation | metadata | cache_key -------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- -(0 rows) + 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/sql/0024_rls_policy_always_true.sql b/test/sql/0024_rls_policy_always_true.sql index 9a26696..f175e4c 100644 --- a/test/sql/0024_rls_policy_always_true.sql +++ b/test/sql/0024_rls_policy_always_true.sql @@ -13,112 +13,262 @@ begin; ); alter table public.posts enable row level security; - -- Create a permissive policy with USING (true) for SELECT - should NOT be flagged + ---------------------------------------- + -- Test: SELECT with USING (true) should NOT be flagged -- SELECT with (true) is often intentional for public read access - create policy "allow_all_select" + ---------------------------------------- + create policy "select_using_true" on public.posts for select to authenticated using (true); - -- 0 issues - SELECT with USING (true) is allowed - select * from lint."0024_rls_policy_always_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"; - -- Create another policy with 1=1 tautology - create policy "allow_all_insert" + 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); - -- 1 issue - only INSERT with WITH CHECK (1=1) is flagged - select count(*) from lint."0024_rls_policy_always_true"; + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; - -- Drop the bad policies - drop policy "allow_all_select" on public.posts; - drop policy "allow_all_insert" on public.posts; + drop policy "insert_with_check_1eq1" on public.posts; - -- Create a proper policy - create policy "users_own_posts" + ---------------------------------------- + -- Test: UPDATE with WITH CHECK (true) SHOULD be flagged + ---------------------------------------- + create policy "update_with_check_true" on public.posts - for select + for update to authenticated - using (user_id = auth.uid()); + using (user_id = auth.uid()) + with check (true); - -- 0 issues - proper policy - select * from lint."0024_rls_policy_always_true"; + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; - -- Test with anon role - create table public.secrets( - id int primary key, - data text - ); - alter table public.secrets enable row level security; + drop policy "update_with_check_true" on public.posts; - create policy "bad_anon_policy" - on public.secrets - for select - to anon + ---------------------------------------- + -- 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); - -- 0 issues - SELECT with USING (true) is allowed even for anon - select * from lint."0024_rls_policy_always_true"; + select count(*) from lint."0024_rls_policy_always_true"; - -- Test that policy for non-anon/authenticated role is not flagged - drop policy "bad_anon_policy" on public.secrets; + 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.secrets - for select + on public.posts + for update to custom_role using (true); - -- 0 issues - policy is for custom_role, not anon/authenticated - select * from lint."0024_rls_policy_always_true"; + select count(*) from lint."0024_rls_policy_always_true"; - -- Test public (all roles) SELECT policy - should NOT be flagged - drop policy "custom_role_policy" on public.secrets; - create policy "public_policy" - on public.secrets - for select - using (true); -- applies to all roles by default + drop policy "custom_role_policy" on public.posts; - -- 0 issues - SELECT with USING (true) is allowed - select * from lint."0024_rls_policy_always_true"; + ---------------------------------------- + -- 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 UPDATE with USING (true) - should be flagged - drop policy "public_policy" on public.secrets; - create policy "bad_update_policy" - on public.secrets + ---------------------------------------- + -- Test: Policy for public (all roles) SHOULD be flagged for UPDATE + ---------------------------------------- + create policy "public_update_true" + on public.posts for update - to authenticated using (true); - -- 1 issue - UPDATE with USING (true) is dangerous - select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; - -- Test DELETE with USING (true) - should be flagged - drop policy "bad_update_policy" on public.secrets; - create policy "bad_delete_policy" - on public.secrets - for delete + 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); - -- 1 issue - DELETE with USING (true) is dangerous - select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + select count(*) from lint."0024_rls_policy_always_true"; - drop policy "bad_delete_policy" on public.secrets; + drop table public.no_rls_table; - -- Test restrictive policy with true - should NOT be flagged (less dangerous) - create policy "restrictive_true" - on public.secrets - as restrictive - for select + ---------------------------------------- + -- 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); - -- 0 issues - restrictive policies are not flagged - select * from lint."0024_rls_policy_always_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;