diff --git a/crates/pgls_configuration/src/splinter/rules.rs b/crates/pgls_configuration/src/splinter/rules.rs index 9ec948e87..60df29073 100644 --- a/crates/pgls_configuration/src/splinter/rules.rs +++ b/crates/pgls_configuration/src/splinter/rules.rs @@ -540,6 +540,9 @@ pub struct Security { #[doc = "RLS Enabled No Policy: Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created."] #[serde(skip_serializing_if = "Option::is_none")] pub rls_enabled_no_policy: Option>, + #[doc = "RLS Policy Always True: 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."] + #[serde(skip_serializing_if = "Option::is_none")] + pub rls_policy_always_true: Option>, #[doc = "RLS references user metadata: Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy."] #[serde(skip_serializing_if = "Option::is_none")] pub rls_references_user_metadata: @@ -547,6 +550,9 @@ pub struct Security { #[doc = "Security Definer View: Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user"] #[serde(skip_serializing_if = "Option::is_none")] pub security_definer_view: Option>, + #[doc = "Sensitive Columns Exposed: Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection."] + #[serde(skip_serializing_if = "Option::is_none")] + pub sensitive_columns_exposed: Option>, #[doc = "Unsupported reg types: Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade."] #[serde(skip_serializing_if = "Option::is_none")] pub unsupported_reg_types: Option>, @@ -565,8 +571,10 @@ impl Security { "policyExistsRlsDisabled", "rlsDisabledInPublic", "rlsEnabledNoPolicy", + "rlsPolicyAlwaysTrue", "rlsReferencesUserMetadata", "securityDefinerView", + "sensitiveColumnsExposed", "unsupportedRegTypes", ]; const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ @@ -584,6 +592,8 @@ impl Security { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -600,6 +610,8 @@ impl Security { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -671,21 +683,31 @@ impl Security { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } - if let Some(rule) = self.rls_references_user_metadata.as_ref() { + if let Some(rule) = self.rls_policy_always_true.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); } } - if let Some(rule) = self.security_definer_view.as_ref() { + if let Some(rule) = self.rls_references_user_metadata.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); } } - if let Some(rule) = self.unsupported_reg_types.as_ref() { + if let Some(rule) = self.security_definer_view.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } } + if let Some(rule) = self.sensitive_columns_exposed.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); + } + } + if let Some(rule) = self.unsupported_reg_types.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -745,21 +767,31 @@ impl Security { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } - if let Some(rule) = self.rls_references_user_metadata.as_ref() { + if let Some(rule) = self.rls_policy_always_true.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); } } - if let Some(rule) = self.security_definer_view.as_ref() { + if let Some(rule) = self.rls_references_user_metadata.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); } } - if let Some(rule) = self.unsupported_reg_types.as_ref() { + if let Some(rule) = self.security_definer_view.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } } + if let Some(rule) = self.sensitive_columns_exposed.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); + } + } + if let Some(rule) = self.unsupported_reg_types.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -800,8 +832,10 @@ impl Security { "policyExistsRlsDisabled" => Severity::Error, "rlsDisabledInPublic" => Severity::Error, "rlsEnabledNoPolicy" => Severity::Information, + "rlsPolicyAlwaysTrue" => Severity::Warning, "rlsReferencesUserMetadata" => Severity::Error, "securityDefinerView" => Severity::Error, + "sensitiveColumnsExposed" => Severity::Error, "unsupportedRegTypes" => Severity::Warning, _ => unreachable!(), } @@ -855,6 +889,10 @@ impl Security { .rls_enabled_no_policy .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "rlsPolicyAlwaysTrue" => self + .rls_policy_always_true + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "rlsReferencesUserMetadata" => self .rls_references_user_metadata .as_ref() @@ -863,6 +901,10 @@ impl Security { .security_definer_view .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "sensitiveColumnsExposed" => self + .sensitive_columns_exposed + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "unsupportedRegTypes" => self .unsupported_reg_types .as_ref() @@ -996,6 +1038,17 @@ impl Security { } } } + if let Some(conf) = &self.rls_policy_always_true { + if let Some(options) = conf.get_options_ref() { + if !options.ignore.is_empty() { + let mut m = pgls_matcher::Matcher::new(pgls_matcher::MatchOptions::default()); + for p in &options.ignore { + let _ = m.add_pattern(p); + } + matchers.insert("rlsPolicyAlwaysTrue", m); + } + } + } if let Some(conf) = &self.rls_references_user_metadata { if let Some(options) = conf.get_options_ref() { if !options.ignore.is_empty() { @@ -1018,6 +1071,17 @@ impl Security { } } } + if let Some(conf) = &self.sensitive_columns_exposed { + if let Some(options) = conf.get_options_ref() { + if !options.ignore.is_empty() { + let mut m = pgls_matcher::Matcher::new(pgls_matcher::MatchOptions::default()); + for p in &options.ignore { + let _ = m.add_pattern(p); + } + matchers.insert("sensitiveColumnsExposed", m); + } + } + } if let Some(conf) = &self.unsupported_reg_types { if let Some(options) = conf.get_options_ref() { if !options.ignore.is_empty() { diff --git a/crates/pgls_diagnostics_categories/src/categories.rs b/crates/pgls_diagnostics_categories/src/categories.rs index a2756af45..b7f3b573d 100644 --- a/crates/pgls_diagnostics_categories/src/categories.rs +++ b/crates/pgls_diagnostics_categories/src/categories.rs @@ -95,8 +95,10 @@ define_categories! { "splinter/security/policyExistsRlsDisabled": "https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled", "splinter/security/rlsDisabledInPublic": "https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public", "splinter/security/rlsEnabledNoPolicy": "https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy", + "splinter/security/rlsPolicyAlwaysTrue": "https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy", "splinter/security/rlsReferencesUserMetadata": "https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata", "splinter/security/securityDefinerView": "https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view", + "splinter/security/sensitiveColumnsExposed": "https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed", "splinter/security/unsupportedRegTypes": "https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types", // splinter rules end ; diff --git a/crates/pgls_splinter/build.rs b/crates/pgls_splinter/build.rs index ba3df6f11..29861dd8b 100644 --- a/crates/pgls_splinter/build.rs +++ b/crates/pgls_splinter/build.rs @@ -2,7 +2,7 @@ use std::fs; use std::io::Write; use std::path::Path; -const EXPECTED_COMMIT: &str = "27ea2ece65464213e466cd969cc61b6940d16219"; +const EXPECTED_COMMIT: &str = "b9de3a3001cbdf01dc1da327acae0700c07f0110"; const REPO: &str = "supabase/splinter"; fn main() { diff --git a/crates/pgls_splinter/src/registry.rs b/crates/pgls_splinter/src/registry.rs index 3b2a81350..9af0bb5b5 100644 --- a/crates/pgls_splinter/src/registry.rs +++ b/crates/pgls_splinter/src/registry.rs @@ -37,8 +37,10 @@ pub fn get_sql_file_path(rule_name: &str) -> Option<&'static str> { "policyExistsRlsDisabled" => Some("vendor/security/policy_exists_rls_disabled.sql"), "rlsDisabledInPublic" => Some("vendor/security/rls_disabled_in_public.sql"), "rlsEnabledNoPolicy" => Some("vendor/security/rls_enabled_no_policy.sql"), + "rlsPolicyAlwaysTrue" => Some("vendor/security/rls_policy_always_true.sql"), "rlsReferencesUserMetadata" => Some("vendor/security/rls_references_user_metadata.sql"), "securityDefinerView" => Some("vendor/security/security_definer_view.sql"), + "sensitiveColumnsExposed" => Some("vendor/security/sensitive_columns_exposed.sql"), "tableBloat" => Some("vendor/performance/table_bloat.sql"), "unindexedForeignKeys" => Some("vendor/performance/unindexed_foreign_keys.sql"), "unsupportedRegTypes" => Some("vendor/security/unsupported_reg_types.sql"), @@ -128,6 +130,11 @@ pub fn get_sql_content(rule_name: &str) -> Option<&'static str> { "/", "vendor/security/rls_enabled_no_policy.sql" ))), + "rlsPolicyAlwaysTrue" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/rls_policy_always_true.sql" + ))), "rlsReferencesUserMetadata" => Some(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/", @@ -138,6 +145,11 @@ pub fn get_sql_content(rule_name: &str) -> Option<&'static str> { "/", "vendor/security/security_definer_view.sql" ))), + "sensitiveColumnsExposed" => Some(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/", + "vendor/security/sensitive_columns_exposed.sql" + ))), "tableBloat" => Some(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/", @@ -166,7 +178,7 @@ pub fn get_sql_content(rule_name: &str) -> Option<&'static str> { #[doc = r""] #[doc = r" This calls the trait constants from the generated rule types"] pub fn get_rule_metadata_fields(rule_name: &str) -> Option<(&'static str, &'static str, bool)> { - match rule_name { "authRlsInitplan" => Some ((< crate :: rules :: performance :: auth_rls_initplan :: AuthRlsInitplan as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: auth_rls_initplan :: AuthRlsInitplan as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: auth_rls_initplan :: AuthRlsInitplan as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "authUsersExposed" => Some ((< crate :: rules :: security :: auth_users_exposed :: AuthUsersExposed as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: auth_users_exposed :: AuthUsersExposed as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: auth_users_exposed :: AuthUsersExposed as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "duplicateIndex" => Some ((< crate :: rules :: performance :: duplicate_index :: DuplicateIndex as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: duplicate_index :: DuplicateIndex as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: duplicate_index :: DuplicateIndex as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "extensionInPublic" => Some ((< crate :: rules :: security :: extension_in_public :: ExtensionInPublic as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: extension_in_public :: ExtensionInPublic as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: extension_in_public :: ExtensionInPublic as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "extensionVersionsOutdated" => Some ((< crate :: rules :: security :: extension_versions_outdated :: ExtensionVersionsOutdated as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: extension_versions_outdated :: ExtensionVersionsOutdated as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: extension_versions_outdated :: ExtensionVersionsOutdated as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "fkeyToAuthUnique" => Some ((< crate :: rules :: security :: fkey_to_auth_unique :: FkeyToAuthUnique as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: fkey_to_auth_unique :: FkeyToAuthUnique as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: fkey_to_auth_unique :: FkeyToAuthUnique as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "foreignTableInApi" => Some ((< crate :: rules :: security :: foreign_table_in_api :: ForeignTableInApi as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: foreign_table_in_api :: ForeignTableInApi as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: foreign_table_in_api :: ForeignTableInApi as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "functionSearchPathMutable" => Some ((< crate :: rules :: security :: function_search_path_mutable :: FunctionSearchPathMutable as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: function_search_path_mutable :: FunctionSearchPathMutable as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: function_search_path_mutable :: FunctionSearchPathMutable as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "insecureQueueExposedInApi" => Some ((< crate :: rules :: security :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "materializedViewInApi" => Some ((< crate :: rules :: security :: materialized_view_in_api :: MaterializedViewInApi as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: materialized_view_in_api :: MaterializedViewInApi as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: materialized_view_in_api :: MaterializedViewInApi as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "multiplePermissivePolicies" => Some ((< crate :: rules :: performance :: multiple_permissive_policies :: MultiplePermissivePolicies as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: multiple_permissive_policies :: MultiplePermissivePolicies as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: multiple_permissive_policies :: MultiplePermissivePolicies as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "noPrimaryKey" => Some ((< crate :: rules :: performance :: no_primary_key :: NoPrimaryKey as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: no_primary_key :: NoPrimaryKey as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: no_primary_key :: NoPrimaryKey as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "policyExistsRlsDisabled" => Some ((< crate :: rules :: security :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsDisabledInPublic" => Some ((< crate :: rules :: security :: rls_disabled_in_public :: RlsDisabledInPublic as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_disabled_in_public :: RlsDisabledInPublic as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_disabled_in_public :: RlsDisabledInPublic as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsEnabledNoPolicy" => Some ((< crate :: rules :: security :: rls_enabled_no_policy :: RlsEnabledNoPolicy as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_enabled_no_policy :: RlsEnabledNoPolicy as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_enabled_no_policy :: RlsEnabledNoPolicy as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsReferencesUserMetadata" => Some ((< crate :: rules :: security :: rls_references_user_metadata :: RlsReferencesUserMetadata as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_references_user_metadata :: RlsReferencesUserMetadata as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_references_user_metadata :: RlsReferencesUserMetadata as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "securityDefinerView" => Some ((< crate :: rules :: security :: security_definer_view :: SecurityDefinerView as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: security_definer_view :: SecurityDefinerView as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: security_definer_view :: SecurityDefinerView as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "tableBloat" => Some ((< crate :: rules :: performance :: table_bloat :: TableBloat as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: table_bloat :: TableBloat as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: table_bloat :: TableBloat as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "unindexedForeignKeys" => Some ((< crate :: rules :: performance :: unindexed_foreign_keys :: UnindexedForeignKeys as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: unindexed_foreign_keys :: UnindexedForeignKeys as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: unindexed_foreign_keys :: UnindexedForeignKeys as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "unsupportedRegTypes" => Some ((< crate :: rules :: security :: unsupported_reg_types :: UnsupportedRegTypes as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: unsupported_reg_types :: UnsupportedRegTypes as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: unsupported_reg_types :: UnsupportedRegTypes as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "unusedIndex" => Some ((< crate :: rules :: performance :: unused_index :: UnusedIndex as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: unused_index :: UnusedIndex as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: unused_index :: UnusedIndex as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , _ => None , } + match rule_name { "authRlsInitplan" => Some ((< crate :: rules :: performance :: auth_rls_initplan :: AuthRlsInitplan as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: auth_rls_initplan :: AuthRlsInitplan as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: auth_rls_initplan :: AuthRlsInitplan as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "authUsersExposed" => Some ((< crate :: rules :: security :: auth_users_exposed :: AuthUsersExposed as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: auth_users_exposed :: AuthUsersExposed as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: auth_users_exposed :: AuthUsersExposed as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "duplicateIndex" => Some ((< crate :: rules :: performance :: duplicate_index :: DuplicateIndex as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: duplicate_index :: DuplicateIndex as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: duplicate_index :: DuplicateIndex as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "extensionInPublic" => Some ((< crate :: rules :: security :: extension_in_public :: ExtensionInPublic as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: extension_in_public :: ExtensionInPublic as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: extension_in_public :: ExtensionInPublic as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "extensionVersionsOutdated" => Some ((< crate :: rules :: security :: extension_versions_outdated :: ExtensionVersionsOutdated as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: extension_versions_outdated :: ExtensionVersionsOutdated as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: extension_versions_outdated :: ExtensionVersionsOutdated as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "fkeyToAuthUnique" => Some ((< crate :: rules :: security :: fkey_to_auth_unique :: FkeyToAuthUnique as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: fkey_to_auth_unique :: FkeyToAuthUnique as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: fkey_to_auth_unique :: FkeyToAuthUnique as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "foreignTableInApi" => Some ((< crate :: rules :: security :: foreign_table_in_api :: ForeignTableInApi as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: foreign_table_in_api :: ForeignTableInApi as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: foreign_table_in_api :: ForeignTableInApi as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "functionSearchPathMutable" => Some ((< crate :: rules :: security :: function_search_path_mutable :: FunctionSearchPathMutable as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: function_search_path_mutable :: FunctionSearchPathMutable as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: function_search_path_mutable :: FunctionSearchPathMutable as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "insecureQueueExposedInApi" => Some ((< crate :: rules :: security :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "materializedViewInApi" => Some ((< crate :: rules :: security :: materialized_view_in_api :: MaterializedViewInApi as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: materialized_view_in_api :: MaterializedViewInApi as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: materialized_view_in_api :: MaterializedViewInApi as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "multiplePermissivePolicies" => Some ((< crate :: rules :: performance :: multiple_permissive_policies :: MultiplePermissivePolicies as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: multiple_permissive_policies :: MultiplePermissivePolicies as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: multiple_permissive_policies :: MultiplePermissivePolicies as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "noPrimaryKey" => Some ((< crate :: rules :: performance :: no_primary_key :: NoPrimaryKey as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: no_primary_key :: NoPrimaryKey as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: no_primary_key :: NoPrimaryKey as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "policyExistsRlsDisabled" => Some ((< crate :: rules :: security :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsDisabledInPublic" => Some ((< crate :: rules :: security :: rls_disabled_in_public :: RlsDisabledInPublic as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_disabled_in_public :: RlsDisabledInPublic as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_disabled_in_public :: RlsDisabledInPublic as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsEnabledNoPolicy" => Some ((< crate :: rules :: security :: rls_enabled_no_policy :: RlsEnabledNoPolicy as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_enabled_no_policy :: RlsEnabledNoPolicy as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_enabled_no_policy :: RlsEnabledNoPolicy as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsPolicyAlwaysTrue" => Some ((< crate :: rules :: security :: rls_policy_always_true :: RlsPolicyAlwaysTrue as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_policy_always_true :: RlsPolicyAlwaysTrue as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_policy_always_true :: RlsPolicyAlwaysTrue as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "rlsReferencesUserMetadata" => Some ((< crate :: rules :: security :: rls_references_user_metadata :: RlsReferencesUserMetadata as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: rls_references_user_metadata :: RlsReferencesUserMetadata as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: rls_references_user_metadata :: RlsReferencesUserMetadata as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "securityDefinerView" => Some ((< crate :: rules :: security :: security_definer_view :: SecurityDefinerView as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: security_definer_view :: SecurityDefinerView as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: security_definer_view :: SecurityDefinerView as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "sensitiveColumnsExposed" => Some ((< crate :: rules :: security :: sensitive_columns_exposed :: SensitiveColumnsExposed as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: sensitive_columns_exposed :: SensitiveColumnsExposed as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: sensitive_columns_exposed :: SensitiveColumnsExposed as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "tableBloat" => Some ((< crate :: rules :: performance :: table_bloat :: TableBloat as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: table_bloat :: TableBloat as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: table_bloat :: TableBloat as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "unindexedForeignKeys" => Some ((< crate :: rules :: performance :: unindexed_foreign_keys :: UnindexedForeignKeys as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: unindexed_foreign_keys :: UnindexedForeignKeys as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: unindexed_foreign_keys :: UnindexedForeignKeys as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "unsupportedRegTypes" => Some ((< crate :: rules :: security :: unsupported_reg_types :: UnsupportedRegTypes as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: security :: unsupported_reg_types :: UnsupportedRegTypes as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: security :: unsupported_reg_types :: UnsupportedRegTypes as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , "unusedIndex" => Some ((< crate :: rules :: performance :: unused_index :: UnusedIndex as crate :: rule :: SplinterRule > :: DESCRIPTION , < crate :: rules :: performance :: unused_index :: UnusedIndex as crate :: rule :: SplinterRule > :: REMEDIATION , < crate :: rules :: performance :: unused_index :: UnusedIndex as crate :: rule :: SplinterRule > :: REQUIRES_SUPABASE ,)) , _ => None , } } #[doc = r" Get metadata for a rule (camelCase name)"] #[doc = r" Returns None if rule not found"] @@ -231,12 +243,18 @@ pub fn get_rule_category(rule_name: &str) -> Option<&'static ::pgls_diagnostics: "rls_enabled_no_policy" => Some(::pgls_diagnostics::category!( "splinter/security/rlsEnabledNoPolicy" )), + "rls_policy_always_true" => Some(::pgls_diagnostics::category!( + "splinter/security/rlsPolicyAlwaysTrue" + )), "rls_references_user_metadata" => Some(::pgls_diagnostics::category!( "splinter/security/rlsReferencesUserMetadata" )), "security_definer_view" => Some(::pgls_diagnostics::category!( "splinter/security/securityDefinerView" )), + "sensitive_columns_exposed" => Some(::pgls_diagnostics::category!( + "splinter/security/sensitiveColumnsExposed" + )), "table_bloat" => Some(::pgls_diagnostics::category!( "splinter/performance/tableBloat" )), @@ -272,8 +290,10 @@ pub fn rule_requires_supabase(rule_name: &str) -> bool { "policyExistsRlsDisabled" => false, "rlsDisabledInPublic" => true, "rlsEnabledNoPolicy" => false, + "rlsPolicyAlwaysTrue" => true, "rlsReferencesUserMetadata" => true, "securityDefinerView" => true, + "sensitiveColumnsExposed" => true, "tableBloat" => false, "unindexedForeignKeys" => false, "unsupportedRegTypes" => false, diff --git a/crates/pgls_splinter/src/rules/security/mod.rs b/crates/pgls_splinter/src/rules/security/mod.rs index 1d4eb9c0f..e8a2b4c3b 100644 --- a/crates/pgls_splinter/src/rules/security/mod.rs +++ b/crates/pgls_splinter/src/rules/security/mod.rs @@ -12,7 +12,9 @@ pub mod materialized_view_in_api; pub mod policy_exists_rls_disabled; pub mod rls_disabled_in_public; pub mod rls_enabled_no_policy; +pub mod rls_policy_always_true; pub mod rls_references_user_metadata; pub mod security_definer_view; +pub mod sensitive_columns_exposed; pub mod unsupported_reg_types; -::pgls_analyse::declare_lint_group! { pub Security { name : "security" , rules : [self :: auth_users_exposed :: AuthUsersExposed , self :: extension_in_public :: ExtensionInPublic , self :: extension_versions_outdated :: ExtensionVersionsOutdated , self :: fkey_to_auth_unique :: FkeyToAuthUnique , self :: foreign_table_in_api :: ForeignTableInApi , self :: function_search_path_mutable :: FunctionSearchPathMutable , self :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi , self :: materialized_view_in_api :: MaterializedViewInApi , self :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled , self :: rls_disabled_in_public :: RlsDisabledInPublic , self :: rls_enabled_no_policy :: RlsEnabledNoPolicy , self :: rls_references_user_metadata :: RlsReferencesUserMetadata , self :: security_definer_view :: SecurityDefinerView , self :: unsupported_reg_types :: UnsupportedRegTypes ,] } } +::pgls_analyse::declare_lint_group! { pub Security { name : "security" , rules : [self :: auth_users_exposed :: AuthUsersExposed , self :: extension_in_public :: ExtensionInPublic , self :: extension_versions_outdated :: ExtensionVersionsOutdated , self :: fkey_to_auth_unique :: FkeyToAuthUnique , self :: foreign_table_in_api :: ForeignTableInApi , self :: function_search_path_mutable :: FunctionSearchPathMutable , self :: insecure_queue_exposed_in_api :: InsecureQueueExposedInApi , self :: materialized_view_in_api :: MaterializedViewInApi , self :: policy_exists_rls_disabled :: PolicyExistsRlsDisabled , self :: rls_disabled_in_public :: RlsDisabledInPublic , self :: rls_enabled_no_policy :: RlsEnabledNoPolicy , self :: rls_policy_always_true :: RlsPolicyAlwaysTrue , self :: rls_references_user_metadata :: RlsReferencesUserMetadata , self :: security_definer_view :: SecurityDefinerView , self :: sensitive_columns_exposed :: SensitiveColumnsExposed , self :: unsupported_reg_types :: UnsupportedRegTypes ,] } } diff --git a/crates/pgls_splinter/src/rules/security/rls_policy_always_true.rs b/crates/pgls_splinter/src/rules/security/rls_policy_always_true.rs new file mode 100644 index 000000000..b6a2ca0d7 --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/rls_policy_always_true.rs @@ -0,0 +1,12 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +::pgls_analyse::declare_rule! { # [doc = "# RLS Policy Always True\n\nDetects 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.\n\n**Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). It will be automatically skipped if these roles don't exist in your database.\n\n## SQL Query\n\n```sql\n(\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n pc.relrowsecurity as is_rls_active,\n pa.polname as policy_name,\n pa.polpermissive as is_permissive,\n pa.polroles as role_oids,\n (select array_agg(r::regrole::text) from unnest(pa.polroles) as x(r)) as roles,\n case pa.polcmd\n when 'r' then 'SELECT'\n when 'a' then 'INSERT'\n when 'w' then 'UPDATE'\n when 'd' then 'DELETE'\n when '*' then 'ALL'\n end as command,\n pb.qual,\n pb.with_check,\n -- Normalize expressions by removing whitespace and lowercasing\n replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\\n', ''), E'\\t', '') as normalized_qual,\n replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\\n', ''), E'\\t', '') as normalized_with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n where\n pc.relkind = 'r' -- regular tables\n and nsp.nspname not in (\n '_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'\n )\n),\npermissive_patterns as (\n select\n p.*,\n -- Check for always-true USING clause patterns\n -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE\n case when (\n command in ('UPDATE', 'DELETE', 'ALL')\n and (\n normalized_qual in ('true', '(true)', '1=1', '(1=1)')\n -- Empty or null qual on permissive policy means allow all\n or (qual is null and is_permissive)\n )\n ) then true else false end as has_permissive_using,\n -- Check for always-true WITH CHECK clause patterns\n case when (\n normalized_with_check in ('true', '(true)', '1=1', '(1=1)')\n -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on)\n or (with_check is null and is_permissive and command = 'INSERT')\n -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes\n or (with_check is null and is_permissive and command in ('UPDATE', 'ALL')\n and normalized_qual in ('true', '(true)', '1=1', '(1=1)'))\n ) then true else false end as has_permissive_with_check\n from\n policies p\n where\n -- Only check tables with RLS enabled (otherwise it's a different lint)\n is_rls_active\n -- Only check permissive policies (restrictive policies with true are less dangerous)\n and is_permissive\n -- Only flag policies that apply to anon or authenticated roles (or public/all roles)\n and (\n role_oids = array[0::oid] -- public (all roles)\n or exists (\n select 1\n from unnest(role_oids) as r\n where r::regrole::text in ('anon', 'authenticated')\n )\n )\n)\nselect\n 'rls_policy_always_true' as \"name!\",\n 'RLS Policy Always True' as \"title!\",\n 'WARN' as \"level!\",\n 'EXTERNAL' as \"facing!\",\n array['SECURITY'] as \"categories!\",\n '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!\",\n format(\n 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.',\n schema_name,\n table_name,\n policy_name,\n command,\n case\n when has_permissive_using and has_permissive_with_check then ' (both USING and WITH CHECK are always true)'\n when has_permissive_using then ' (USING clause is always true)'\n when has_permissive_with_check then ' (WITH CHECK clause is always true)'\n else ''\n end,\n array_to_string(roles, ', ')\n ) as \"detail!\",\n 'https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy' as \"remediation!\",\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table',\n 'policy_name', policy_name,\n 'command', command,\n 'roles', roles,\n 'qual', qual,\n 'with_check', with_check,\n 'permissive_using', has_permissive_using,\n 'permissive_with_check', has_permissive_with_check\n ) as \"metadata!\",\n format(\n 'rls_policy_always_true_%s_%s_%s',\n schema_name,\n table_name,\n policy_name\n ) as \"cache_key!\"\nfrom\n permissive_patterns\nwhere\n has_permissive_using or has_permissive_with_check\norder by\n schema_name,\n table_name,\n policy_name)\n```\n\n## Configuration\n\nEnable or disable this rule in your configuration:\n\n```json\n{\n \"splinter\": {\n \"rules\": {\n \"security\": {\n \"rlsPolicyAlwaysTrue\": \"warn\"\n }\n }\n }\n}\n```\n\n## Remediation\n\nSee: "] pub RlsPolicyAlwaysTrue { version : "1.0.0" , name : "rlsPolicyAlwaysTrue" , severity : pgls_diagnostics :: Severity :: Warning , recommended : true , } } +impl SplinterRule for RlsPolicyAlwaysTrue { + const SQL_FILE_PATH: &'static str = "security/rls_policy_always_true.sql"; + const DESCRIPTION: &'static str = "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."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy"; + const REQUIRES_SUPABASE: bool = true; +} diff --git a/crates/pgls_splinter/src/rules/security/sensitive_columns_exposed.rs b/crates/pgls_splinter/src/rules/security/sensitive_columns_exposed.rs new file mode 100644 index 000000000..81f9df296 --- /dev/null +++ b/crates/pgls_splinter/src/rules/security/sensitive_columns_exposed.rs @@ -0,0 +1,11 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] +use crate::rule::SplinterRule; +::pgls_analyse::declare_rule! { # [doc = "# Sensitive Columns Exposed\n\nDetects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.\n\n**Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). It will be automatically skipped if these roles don't exist in your database.\n\n## SQL Query\n\n```sql\n(\nwith sensitive_patterns as (\n select unnest(array[\n -- Authentication & Credentials\n 'password', 'passwd', 'pwd', 'passphrase',\n 'secret', 'secret_key', 'private_key', 'api_key', 'apikey',\n 'auth_key', 'token', 'jwt', 'access_token', 'refresh_token',\n 'oauth_token', 'session_token', 'bearer_token', 'auth_code',\n 'session_id', 'session_key', 'session_secret',\n 'recovery_code', 'backup_code', 'verification_code',\n 'otp', 'two_factor', '2fa_secret', '2fa_code',\n -- Personal Identifiers\n 'ssn', 'social_security', 'social_security_number',\n 'driver_license', 'drivers_license', 'license_number',\n 'passport_number', 'passport_id', 'national_id', 'tax_id',\n -- Financial Information\n 'credit_card', 'card_number', 'cvv', 'cvc', 'cvn',\n 'bank_account', 'account_number', 'routing_number',\n 'iban', 'swift_code', 'bic',\n -- Health & Medical\n 'health_record', 'medical_record', 'patient_id',\n 'insurance_number', 'health_insurance', 'medical_insurance',\n 'treatment',\n -- Device Identifiers\n 'mac_address', 'macaddr', 'imei', 'device_uuid',\n -- Digital Keys & Certificates\n 'pgp_key', 'gpg_key', 'ssh_key', 'certificate',\n 'license_key', 'activation_key',\n -- Biometric Data\n 'facial_recognition'\n ]) as pattern\n),\nexposed_tables as (\n select\n n.nspname as schema_name,\n c.relname as table_name,\n c.oid as table_oid\n from\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n where\n c.relkind = 'r' -- regular tables\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n -- Only flag tables without RLS enabled\n and not c.relrowsecurity\n),\nsensitive_columns as (\n select\n et.schema_name,\n et.table_name,\n a.attname as column_name,\n sp.pattern as matched_pattern\n from\n exposed_tables et\n join pg_catalog.pg_attribute a\n on a.attrelid = et.table_oid\n and a.attnum > 0\n and not a.attisdropped\n cross join sensitive_patterns sp\n where\n -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants\n replace(lower(a.attname), '-', '_') = sp.pattern\n)\nselect\n 'sensitive_columns_exposed' as \"name!\",\n 'Sensitive Columns Exposed' as \"title!\",\n 'ERROR' as \"level!\",\n 'EXTERNAL' as \"facing!\",\n array['SECURITY'] as \"categories!\",\n 'Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.' as \"description!\",\n format(\n 'Table `%s.%s` is exposed via API without RLS and contains potentially sensitive column(s): %s. This may lead to data exposure.',\n schema_name,\n table_name,\n string_agg(distinct column_name, ', ' order by column_name)\n ) as \"detail!\",\n 'https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed' as \"remediation!\",\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table',\n 'sensitive_columns', array_agg(distinct column_name order by column_name),\n 'matched_patterns', array_agg(distinct matched_pattern order by matched_pattern)\n ) as \"metadata!\",\n format(\n 'sensitive_columns_exposed_%s_%s',\n schema_name,\n table_name\n ) as \"cache_key!\"\nfrom\n sensitive_columns\ngroup by\n schema_name,\n table_name\norder by\n schema_name,\n table_name)\n```\n\n## Configuration\n\nEnable or disable this rule in your configuration:\n\n```json\n{\n \"splinter\": {\n \"rules\": {\n \"security\": {\n \"sensitiveColumnsExposed\": \"warn\"\n }\n }\n }\n}\n```\n\n## Remediation\n\nSee: "] pub SensitiveColumnsExposed { version : "1.0.0" , name : "sensitiveColumnsExposed" , severity : pgls_diagnostics :: Severity :: Error , recommended : true , } } +impl SplinterRule for SensitiveColumnsExposed { + const SQL_FILE_PATH: &'static str = "security/sensitive_columns_exposed.sql"; + const DESCRIPTION: &'static str = "Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed"; + const REQUIRES_SUPABASE: bool = true; +} diff --git a/crates/pgls_splinter/vendor/COMMIT_SHA.txt b/crates/pgls_splinter/vendor/COMMIT_SHA.txt index 7ca1b8f8c..6ab10524b 100644 --- a/crates/pgls_splinter/vendor/COMMIT_SHA.txt +++ b/crates/pgls_splinter/vendor/COMMIT_SHA.txt @@ -1 +1 @@ -27ea2ece65464213e466cd969cc61b6940d16219 \ No newline at end of file +b9de3a3001cbdf01dc1da327acae0700c07f0110 \ No newline at end of file diff --git a/crates/pgls_splinter/vendor/security/rls_policy_always_true.sql b/crates/pgls_splinter/vendor/security/rls_policy_always_true.sql new file mode 100644 index 000000000..6a4d2b73e --- /dev/null +++ b/crates/pgls_splinter/vendor/security/rls_policy_always_true.sql @@ -0,0 +1,133 @@ +-- meta: name = rlsPolicyAlwaysTrue +-- meta: title = RLS Policy Always True +-- meta: severity = WARN +-- meta: category = SECURITY +-- meta: 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. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy +-- meta: requires_supabase = true + +( +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + pc.relrowsecurity as is_rls_active, + pa.polname as policy_name, + pa.polpermissive as is_permissive, + pa.polroles as role_oids, + (select array_agg(r::regrole::text) from unnest(pa.polroles) as x(r)) as roles, + case pa.polcmd + when 'r' then 'SELECT' + when 'a' then 'INSERT' + when 'w' then 'UPDATE' + when 'd' then 'DELETE' + when '*' then 'ALL' + end as command, + pb.qual, + pb.with_check, + -- Normalize expressions by removing whitespace and lowercasing + replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_qual, + replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_with_check + from + pg_catalog.pg_policy pa + join pg_catalog.pg_class pc + on pa.polrelid = pc.oid + join pg_catalog.pg_namespace nsp + on pc.relnamespace = nsp.oid + join pg_catalog.pg_policies pb + on pc.relname = pb.tablename + and nsp.nspname = pb.schemaname + and pa.polname = pb.policyname + where + pc.relkind = 'r' -- regular tables + and nsp.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) +), +permissive_patterns as ( + select + p.*, + -- Check for always-true USING clause patterns + -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE + case when ( + command in ('UPDATE', 'DELETE', 'ALL') + and ( + normalized_qual in ('true', '(true)', '1=1', '(1=1)') + -- Empty or null qual on permissive policy means allow all + or (qual is null and is_permissive) + ) + ) then true else false end as has_permissive_using, + -- Check for always-true WITH CHECK clause patterns + case when ( + normalized_with_check in ('true', '(true)', '1=1', '(1=1)') + -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on) + or (with_check is null and is_permissive and command = 'INSERT') + -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes + or (with_check is null and is_permissive and command in ('UPDATE', 'ALL') + and normalized_qual in ('true', '(true)', '1=1', '(1=1)')) + ) then true else false end as has_permissive_with_check + from + policies p + where + -- Only check tables with RLS enabled (otherwise it's a different lint) + is_rls_active + -- Only check permissive policies (restrictive policies with true are less dangerous) + and is_permissive + -- Only flag policies that apply to anon or authenticated roles (or public/all roles) + and ( + role_oids = array[0::oid] -- public (all roles) + or exists ( + select 1 + from unnest(role_oids) as r + where r::regrole::text in ('anon', 'authenticated') + ) + ) +) +select + 'rls_policy_always_true' as "name!", + 'RLS Policy Always True' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects RLS policies that use overly permissive expressions like USING (true) or WITH CHECK (true) for UPDATE, DELETE, or INSERT operations. SELECT policies with USING (true) are intentionally excluded as this pattern is often used deliberately for public read access.' as "description!", + format( + 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.', + schema_name, + table_name, + policy_name, + command, + case + when has_permissive_using and has_permissive_with_check then ' (both USING and WITH CHECK are always true)' + when has_permissive_using then ' (USING clause is always true)' + when has_permissive_with_check then ' (WITH CHECK clause is always true)' + else '' + end, + array_to_string(roles, ', ') + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'policy_name', policy_name, + 'command', command, + 'roles', roles, + 'qual', qual, + 'with_check', with_check, + 'permissive_using', has_permissive_using, + 'permissive_with_check', has_permissive_with_check + ) as "metadata!", + format( + 'rls_policy_always_true_%s_%s_%s', + schema_name, + table_name, + policy_name + ) as "cache_key!" +from + permissive_patterns +where + has_permissive_using or has_permissive_with_check +order by + schema_name, + table_name, + policy_name) diff --git a/crates/pgls_splinter/vendor/security/sensitive_columns_exposed.sql b/crates/pgls_splinter/vendor/security/sensitive_columns_exposed.sql new file mode 100644 index 000000000..74db1d0c9 --- /dev/null +++ b/crates/pgls_splinter/vendor/security/sensitive_columns_exposed.sql @@ -0,0 +1,113 @@ +-- meta: name = sensitiveColumnsExposed +-- meta: title = Sensitive Columns Exposed +-- meta: severity = ERROR +-- meta: category = SECURITY +-- meta: description = Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection. +-- meta: remediation = https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed +-- meta: requires_supabase = true + +( +with sensitive_patterns as ( + select unnest(array[ + -- Authentication & Credentials + 'password', 'passwd', 'pwd', 'passphrase', + 'secret', 'secret_key', 'private_key', 'api_key', 'apikey', + 'auth_key', 'token', 'jwt', 'access_token', 'refresh_token', + 'oauth_token', 'session_token', 'bearer_token', 'auth_code', + 'session_id', 'session_key', 'session_secret', + 'recovery_code', 'backup_code', 'verification_code', + 'otp', 'two_factor', '2fa_secret', '2fa_code', + -- Personal Identifiers + 'ssn', 'social_security', 'social_security_number', + 'driver_license', 'drivers_license', 'license_number', + 'passport_number', 'passport_id', 'national_id', 'tax_id', + -- Financial Information + 'credit_card', 'card_number', 'cvv', 'cvc', 'cvn', + 'bank_account', 'account_number', 'routing_number', + 'iban', 'swift_code', 'bic', + -- Health & Medical + 'health_record', 'medical_record', 'patient_id', + 'insurance_number', 'health_insurance', 'medical_insurance', + 'treatment', + -- Device Identifiers + 'mac_address', 'macaddr', 'imei', 'device_uuid', + -- Digital Keys & Certificates + 'pgp_key', 'gpg_key', 'ssh_key', 'certificate', + 'license_key', 'activation_key', + -- Biometric Data + 'facial_recognition' + ]) as pattern +), +exposed_tables as ( + select + n.nspname as schema_name, + c.relname as table_name, + c.oid as table_oid + from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + where + c.relkind = 'r' -- regular tables + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + -- Only flag tables without RLS enabled + and not c.relrowsecurity +), +sensitive_columns as ( + select + et.schema_name, + et.table_name, + a.attname as column_name, + sp.pattern as matched_pattern + from + exposed_tables et + join pg_catalog.pg_attribute a + on a.attrelid = et.table_oid + and a.attnum > 0 + and not a.attisdropped + cross join sensitive_patterns sp + where + -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants + replace(lower(a.attname), '-', '_') = sp.pattern +) +select + 'sensitive_columns_exposed' as "name!", + 'Sensitive Columns Exposed' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.' as "description!", + format( + 'Table `%s.%s` is exposed via API without RLS and contains potentially sensitive column(s): %s. This may lead to data exposure.', + schema_name, + table_name, + string_agg(distinct column_name, ', ' order by column_name) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'sensitive_columns', array_agg(distinct column_name order by column_name), + 'matched_patterns', array_agg(distinct matched_pattern order by matched_pattern) + ) as "metadata!", + format( + 'sensitive_columns_exposed_%s_%s', + schema_name, + table_name + ) as "cache_key!" +from + sensitive_columns +group by + schema_name, + table_name +order by + schema_name, + table_name) diff --git a/docs/reference/database_rule_sources.md b/docs/reference/database_rule_sources.md index 22fd9a783..b1530a538 100644 --- a/docs/reference/database_rule_sources.md +++ b/docs/reference/database_rule_sources.md @@ -32,8 +32,10 @@ _No exclusive rules available._ | [policyExistsRlsDisabled](https://github.com/supabase/splinter) | [policyExistsRlsDisabled](./rules/policy-exists-rls-disabled.md) | | [rlsDisabledInPublic](https://github.com/supabase/splinter) | [rlsDisabledInPublic](./rules/rls-disabled-in-public.md) | | [rlsEnabledNoPolicy](https://github.com/supabase/splinter) | [rlsEnabledNoPolicy](./rules/rls-enabled-no-policy.md) | +| [rlsPolicyAlwaysTrue](https://github.com/supabase/splinter) | [rlsPolicyAlwaysTrue](./rules/rls-policy-always-true.md) | | [rlsReferencesUserMetadata](https://github.com/supabase/splinter) | [rlsReferencesUserMetadata](./rules/rls-references-user-metadata.md) | | [securityDefinerView](https://github.com/supabase/splinter) | [securityDefinerView](./rules/security-definer-view.md) | +| [sensitiveColumnsExposed](https://github.com/supabase/splinter) | [sensitiveColumnsExposed](./rules/sensitive-columns-exposed.md) | | [unsupportedRegTypes](https://github.com/supabase/splinter) | [unsupportedRegTypes](./rules/unsupported-reg-types.md) | [//]: # (END DATABASE_RULE_SOURCES) diff --git a/docs/reference/database_rules.md b/docs/reference/database_rules.md index 80f2873c8..a4e6da9a6 100644 --- a/docs/reference/database_rules.md +++ b/docs/reference/database_rules.md @@ -42,8 +42,10 @@ Rules that detect potential security vulnerabilities in your database schema. | [policyExistsRlsDisabled](./rules/policy-exists-rls-disabled.md) | Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table. | ✅ | | [rlsDisabledInPublic](./rules/rls-disabled-in-public.md) | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | ✅ ⚡ | | [rlsEnabledNoPolicy](./rules/rls-enabled-no-policy.md) | Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created. | ✅ | +| [rlsPolicyAlwaysTrue](./rules/rls-policy-always-true.md) | 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. | ✅ ⚡ | | [rlsReferencesUserMetadata](./rules/rls-references-user-metadata.md) | Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy. | ✅ ⚡ | | [securityDefinerView](./rules/security-definer-view.md) | Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user | ✅ ⚡ | +| [sensitiveColumnsExposed](./rules/sensitive-columns-exposed.md) | Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection. | ✅ ⚡ | | [unsupportedRegTypes](./rules/unsupported-reg-types.md) | Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade. | ✅ | [//]: # (END SPLINTER_RULES_INDEX) diff --git a/docs/reference/rules/rls-policy-always-true.md b/docs/reference/rules/rls-policy-always-true.md new file mode 100644 index 000000000..23b4e5116 --- /dev/null +++ b/docs/reference/rules/rls-policy-always-true.md @@ -0,0 +1,160 @@ +# rlsPolicyAlwaysTrue + +**Diagnostic Category: `splinter/security/rlsPolicyAlwaysTrue`** + +**Severity**: Warning + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## 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. + +## Remediation + +https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy + +## SQL Query + +```sql +( +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + pc.relrowsecurity as is_rls_active, + pa.polname as policy_name, + pa.polpermissive as is_permissive, + pa.polroles as role_oids, + (select array_agg(r::regrole::text) from unnest(pa.polroles) as x(r)) as roles, + case pa.polcmd + when 'r' then 'SELECT' + when 'a' then 'INSERT' + when 'w' then 'UPDATE' + when 'd' then 'DELETE' + when '*' then 'ALL' + end as command, + pb.qual, + pb.with_check, + -- Normalize expressions by removing whitespace and lowercasing + replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_qual, + replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_with_check + from + pg_catalog.pg_policy pa + join pg_catalog.pg_class pc + on pa.polrelid = pc.oid + join pg_catalog.pg_namespace nsp + on pc.relnamespace = nsp.oid + join pg_catalog.pg_policies pb + on pc.relname = pb.tablename + and nsp.nspname = pb.schemaname + and pa.polname = pb.policyname + where + pc.relkind = 'r' -- regular tables + and nsp.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) +), +permissive_patterns as ( + select + p.*, + -- Check for always-true USING clause patterns + -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE + case when ( + command in ('UPDATE', 'DELETE', 'ALL') + and ( + normalized_qual in ('true', '(true)', '1=1', '(1=1)') + -- Empty or null qual on permissive policy means allow all + or (qual is null and is_permissive) + ) + ) then true else false end as has_permissive_using, + -- Check for always-true WITH CHECK clause patterns + case when ( + normalized_with_check in ('true', '(true)', '1=1', '(1=1)') + -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on) + or (with_check is null and is_permissive and command = 'INSERT') + -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes + or (with_check is null and is_permissive and command in ('UPDATE', 'ALL') + and normalized_qual in ('true', '(true)', '1=1', '(1=1)')) + ) then true else false end as has_permissive_with_check + from + policies p + where + -- Only check tables with RLS enabled (otherwise it's a different lint) + is_rls_active + -- Only check permissive policies (restrictive policies with true are less dangerous) + and is_permissive + -- Only flag policies that apply to anon or authenticated roles (or public/all roles) + and ( + role_oids = array[0::oid] -- public (all roles) + or exists ( + select 1 + from unnest(role_oids) as r + where r::regrole::text in ('anon', 'authenticated') + ) + ) +) +select + 'rls_policy_always_true' as "name!", + 'RLS Policy Always True' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects RLS policies that use overly permissive expressions like USING (true) or WITH CHECK (true) for UPDATE, DELETE, or INSERT operations. SELECT policies with USING (true) are intentionally excluded as this pattern is often used deliberately for public read access.' as "description!", + format( + 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.', + schema_name, + table_name, + policy_name, + command, + case + when has_permissive_using and has_permissive_with_check then ' (both USING and WITH CHECK are always true)' + when has_permissive_using then ' (USING clause is always true)' + when has_permissive_with_check then ' (WITH CHECK clause is always true)' + else '' + end, + array_to_string(roles, ', ') + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'policy_name', policy_name, + 'command', command, + 'roles', roles, + 'qual', qual, + 'with_check', with_check, + 'permissive_using', has_permissive_using, + 'permissive_with_check', has_permissive_with_check + ) as "metadata!", + format( + 'rls_policy_always_true_%s_%s_%s', + schema_name, + table_name, + policy_name + ) as "cache_key!" +from + permissive_patterns +where + has_permissive_using or has_permissive_with_check +order by + schema_name, + table_name, + policy_name) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "rlsPolicyAlwaysTrue": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/sensitive-columns-exposed.md b/docs/reference/rules/sensitive-columns-exposed.md new file mode 100644 index 000000000..3e29ebac6 --- /dev/null +++ b/docs/reference/rules/sensitive-columns-exposed.md @@ -0,0 +1,140 @@ +# sensitiveColumnsExposed + +**Diagnostic Category: `splinter/security/sensitiveColumnsExposed`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection. + +## Remediation + +https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed + +## SQL Query + +```sql +( +with sensitive_patterns as ( + select unnest(array[ + -- Authentication & Credentials + 'password', 'passwd', 'pwd', 'passphrase', + 'secret', 'secret_key', 'private_key', 'api_key', 'apikey', + 'auth_key', 'token', 'jwt', 'access_token', 'refresh_token', + 'oauth_token', 'session_token', 'bearer_token', 'auth_code', + 'session_id', 'session_key', 'session_secret', + 'recovery_code', 'backup_code', 'verification_code', + 'otp', 'two_factor', '2fa_secret', '2fa_code', + -- Personal Identifiers + 'ssn', 'social_security', 'social_security_number', + 'driver_license', 'drivers_license', 'license_number', + 'passport_number', 'passport_id', 'national_id', 'tax_id', + -- Financial Information + 'credit_card', 'card_number', 'cvv', 'cvc', 'cvn', + 'bank_account', 'account_number', 'routing_number', + 'iban', 'swift_code', 'bic', + -- Health & Medical + 'health_record', 'medical_record', 'patient_id', + 'insurance_number', 'health_insurance', 'medical_insurance', + 'treatment', + -- Device Identifiers + 'mac_address', 'macaddr', 'imei', 'device_uuid', + -- Digital Keys & Certificates + 'pgp_key', 'gpg_key', 'ssh_key', 'certificate', + 'license_key', 'activation_key', + -- Biometric Data + 'facial_recognition' + ]) as pattern +), +exposed_tables as ( + select + n.nspname as schema_name, + c.relname as table_name, + c.oid as table_oid + from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + where + c.relkind = 'r' -- regular tables + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + -- Only flag tables without RLS enabled + and not c.relrowsecurity +), +sensitive_columns as ( + select + et.schema_name, + et.table_name, + a.attname as column_name, + sp.pattern as matched_pattern + from + exposed_tables et + join pg_catalog.pg_attribute a + on a.attrelid = et.table_oid + and a.attnum > 0 + and not a.attisdropped + cross join sensitive_patterns sp + where + -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants + replace(lower(a.attname), '-', '_') = sp.pattern +) +select + 'sensitive_columns_exposed' as "name!", + 'Sensitive Columns Exposed' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.' as "description!", + format( + 'Table `%s.%s` is exposed via API without RLS and contains potentially sensitive column(s): %s. This may lead to data exposure.', + schema_name, + table_name, + string_agg(distinct column_name, ', ' order by column_name) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0023_sensitive_columns_exposed' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'sensitive_columns', array_agg(distinct column_name order by column_name), + 'matched_patterns', array_agg(distinct matched_pattern order by matched_pattern) + ) as "metadata!", + format( + 'sensitive_columns_exposed_%s_%s', + schema_name, + table_name + ) as "cache_key!" +from + sensitive_columns +group by + schema_name, + table_name +order by + schema_name, + table_name) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "sensitiveColumnsExposed": "error" + } + } + } +} +``` diff --git a/docs/schema.json b/docs/schema.json index d43c461ab..eed4379cc 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -1539,6 +1539,17 @@ } ] }, + "rlsPolicyAlwaysTrue": { + "description": "RLS Policy Always True: 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.", + "anyOf": [ + { + "$ref": "#/definitions/SplinterRuleConfiguration" + }, + { + "type": "null" + } + ] + }, "rlsReferencesUserMetadata": { "description": "RLS references user metadata: Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.", "anyOf": [ @@ -1561,6 +1572,17 @@ } ] }, + "sensitiveColumnsExposed": { + "description": "Sensitive Columns Exposed: Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.", + "anyOf": [ + { + "$ref": "#/definitions/SplinterRuleConfiguration" + }, + { + "type": "null" + } + ] + }, "unsupportedRegTypes": { "description": "Unsupported reg types: Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.", "anyOf": [ diff --git a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts index 2c8c55936..4d4a25d4d 100644 --- a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts @@ -135,8 +135,10 @@ export type Category = | "splinter/security/policyExistsRlsDisabled" | "splinter/security/rlsDisabledInPublic" | "splinter/security/rlsEnabledNoPolicy" + | "splinter/security/rlsPolicyAlwaysTrue" | "splinter/security/rlsReferencesUserMetadata" | "splinter/security/securityDefinerView" + | "splinter/security/sensitiveColumnsExposed" | "splinter/security/unsupportedRegTypes" | "stdin" | "check" @@ -971,6 +973,10 @@ export interface Security { * RLS Enabled No Policy: Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created. */ rlsEnabledNoPolicy?: RuleConfiguration_for_SplinterRuleOptions; + /** + * RLS Policy Always True: 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. + */ + rlsPolicyAlwaysTrue?: RuleConfiguration_for_SplinterRuleOptions; /** * RLS references user metadata: Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy. */ @@ -979,6 +985,10 @@ export interface Security { * Security Definer View: Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user */ securityDefinerView?: RuleConfiguration_for_SplinterRuleOptions; + /** + * Sensitive Columns Exposed: Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection. + */ + sensitiveColumnsExposed?: RuleConfiguration_for_SplinterRuleOptions; /** * Unsupported reg types: Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade. */ diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index 2c8c55936..4d4a25d4d 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -135,8 +135,10 @@ export type Category = | "splinter/security/policyExistsRlsDisabled" | "splinter/security/rlsDisabledInPublic" | "splinter/security/rlsEnabledNoPolicy" + | "splinter/security/rlsPolicyAlwaysTrue" | "splinter/security/rlsReferencesUserMetadata" | "splinter/security/securityDefinerView" + | "splinter/security/sensitiveColumnsExposed" | "splinter/security/unsupportedRegTypes" | "stdin" | "check" @@ -971,6 +973,10 @@ export interface Security { * RLS Enabled No Policy: Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created. */ rlsEnabledNoPolicy?: RuleConfiguration_for_SplinterRuleOptions; + /** + * RLS Policy Always True: 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. + */ + rlsPolicyAlwaysTrue?: RuleConfiguration_for_SplinterRuleOptions; /** * RLS references user metadata: Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy. */ @@ -979,6 +985,10 @@ export interface Security { * Security Definer View: Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user */ securityDefinerView?: RuleConfiguration_for_SplinterRuleOptions; + /** + * Sensitive Columns Exposed: Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection. + */ + sensitiveColumnsExposed?: RuleConfiguration_for_SplinterRuleOptions; /** * Unsupported reg types: Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade. */