diff --git a/Cargo.lock b/Cargo.lock index a575a902a..27377ace1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1222,6 +1222,7 @@ dependencies = [ "pgls_env", "pgls_query", "pgls_query_ext", + "pgls_splinter", "pgls_statement_splitter", "pgls_workspace", "pulldown-cmark", @@ -3004,7 +3005,6 @@ dependencies = [ "serde", "serde_json", "sqlx", - "ureq", ] [[package]] diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index a1e3ee745..000000000 --- a/PLAN.md +++ /dev/null @@ -1,907 +0,0 @@ -# Splinter Integration Plan - -## Goal -Integrate splinter into the codegen/rule setup used for the analyser, providing a consistent API (both internally and user-facing) for all types of analysers/linters. - -## Architecture Vision - -### Crate Responsibilities - -**`pgls_analyse`** - Generic framework for all analyzer types -- Generic traits: `RuleMeta`, `RuleGroup`, `GroupCategory`, `RegistryVisitor` -- Shared types: `RuleMetadata`, `RuleCategory` -- Configuration traits (execution-agnostic) -- Macros: `declare_rule!`, `declare_group!`, `declare_category!` - -**`pgls_linter`** (renamed from `pgls_analyser`) - AST-based source code linting -- `LinterRule` trait (extends `RuleMeta`) -- `LinterRuleContext` (wraps AST nodes) -- `LinterDiagnostic` (span-based diagnostics) -- `LinterRuleRegistry` (type-erased executors) - -**`pgls_splinter`** - Database-level linting -- `SplinterRule` trait (extends `RuleMeta`) -- `SplinterRuleRegistry` (metadata-based) -- `SplinterDiagnostic` (db-object-based diagnostics) -- Generated rule types from SQL files - -**`pgls_configuration`** -- `analyser/linter/` - Generated from `pgls_linter` -- `analyser/splinter/` - Generated from `pgls_splinter` -- Per-rule configuration for both - -## Implementation Phases - -### Phase 1: Refactor pgls_analyse ✅ COMPLETED -Extract AST-specific code into pgls_linter, keep only generic framework in pgls_analyse. - -**Tasks:** -- [x] Analyze current `pgls_analyse` exports -- [x] Identify AST-specific vs generic code -- [x] Create new modules in `pgls_analyser`: - - [x] `linter_rule.rs` - LinterRule trait, LinterDiagnostic - - [x] `linter_context.rs` - LinterRuleContext, AnalysedFileContext - - [x] `linter_options.rs` - LinterOptions, LinterRules - - [x] `linter_registry.rs` - LinterRuleRegistry, LinterRegistryVisitor -- [x] Create `pgls_analyse/src/metadata.rs` - Generic traits only -- [x] Update `pgls_analyse/src/registry.rs` - Keep MetadataRegistry only -- [x] Update `pgls_analyse/src/lib.rs` - Export generic framework -- [x] Update `pgls_analyser/src/lib.rs` - Use new modules -- [x] Fix imports in filter.rs (RuleMeta instead of Rule) -- [x] Update generated files (options.rs, registry.rs) -- [x] Fix imports in all rule files -- [x] Add rustc-hash dependency -- [x] Verify compilation completes -- [x] Separate visitor concerns from executor creation -- [x] Update codegen to generate factory function -- [x] Fix all import paths across workspace -- [x] Verify full workspace compiles -- [x] Optimize executor creation (zero-cost abstraction) - -**Resolution:** -Separated two concerns: -1. **Visitor pattern** (generic): Collects rule keys that match the filter - - Implementation in `LinterRuleRegistryBuilder::record_rule` - - Only requires `R: RuleMeta` (satisfies trait) -2. **Executor mapping** (AST-specific): Maps rule keys directly to executors - - Function `get_linter_rule_executor` in `registry.rs` - - Generated by codegen with full type information - - Zero-cost abstraction: no Box, no dyn, no closures - -**Final Implementation:** -- `LinterRuleRegistryBuilder` stores `Vec` from visitor traversal -- `record_rule` only collects keys (generic, no LinterRule bounds) -- `build()` calls `get_linter_rule_executor` for each key -- Generated match statement returns executors directly (no heap allocation) - -**Performance:** -- ✅ No `Box` - returns values directly -- ✅ No closure overhead - simple match statement -- ✅ No dynamic dispatch - static dispatch only -- ✅ Clean codegen - 33 rules map to executors efficiently - -**Design Decisions:** -- ✅ Keep `RuleDiagnostic` generic or make it linter-specific? → **Move to pgls_linter as LinterDiagnostic** (Option A) - - Rationale: Fundamentally different location models (spans vs db objects) - - LinterDiagnostic: span-based - - SplinterDiagnostic: db-object-based - -**Code Classification:** - -AST-specific (move to pgls_analyser): -- `Rule` trait -- `RuleContext` -- `RuleDiagnostic` → `LinterDiagnostic` -- `AnalysedFileContext` -- `RegistryRuleParams` -- `RuleRegistry`, `RuleRegistryBuilder` (AST execution) -- `AnalyserOptions`, `AnalyserRules` (rule options storage) - -Generic (keep in pgls_analyse): -- `RuleMeta` trait -- `RuleMetadata` struct -- `RuleGroup` trait -- `GroupCategory` trait -- `RegistryVisitor` trait -- `RuleCategory` enum -- `RuleSource` enum -- `RuleFilter`, `AnalysisFilter`, `RuleKey`, `GroupKey` -- `MetadataRegistry` -- Macros: `declare_rule!`, `declare_lint_rule!`, `declare_lint_group!`, `declare_category!` - ---- - -### Phase 2: Enhance pgls_splinter ✅ COMPLETED -Add rule type generation and registry similar to linter. - -**Tasks:** -- [x] Create `pgls_splinter/src/rule.rs` with `SplinterRule` trait -- [x] Create `pgls_splinter/src/rules/` directory structure -- [x] Generate rule types from SQL files -- [x] Generate registry with `visit_registry()` function -- [x] Split monolithic SQL files into individual rule files with metadata -- [x] Create codegen to extract metadata from SQL comments -- [x] Generate get_sql_file_path() function for SQL file mapping -- [ ] Update diagnostics to use generated categories (deferred) -- [ ] Update runtime to build dynamic queries (deferred to Phase 3) - -**Structure:** -``` -pgls_splinter/src/ - rules/ - performance/ - unindexed_foreign_keys.rs # Generated - auth_rls_initplan.rs # Generated - ... # 7 total - mod.rs # Generated group - security/ - auth_users_exposed.rs # Generated - ... # 14 total - mod.rs # Generated group - mod.rs # Generated category - rule.rs # Generated SplinterRule trait - registry.rs # Generated visit_registry() + get_sql_file_path() - -pgls_splinter/vendor/ - performance/ - *.sql # 7 individual SQL files with metadata - security/ - *.sql # 14 individual SQL files with metadata -``` - -**Implementation Summary:** -- Implemented Option C (Hybrid Approach) from initial analysis -- SQL files remain source of truth with metadata comments -- Codegen extracts metadata and generates Rust structures -- `SplinterRule` trait extends `RuleMeta` with `sql_file_path()` method -- Registry provides centralized rule discovery via visitor pattern -- Category structure: `Splinter` (Lint) → `Performance`/`Security` (groups) → individual rules -- Successfully compiles without errors - ---- - -### Phase 3: Configuration Integration ✅ COMPLETED -Integrate splinter into the configuration system. - -**Tasks:** -- [x] **Configuration Generation**: - - [x] Create `pgls_configuration/src/analyser/splinter/` directory - - [x] Generate splinter configuration types (Performance/Security groups, Rules struct) - - [x] Update `generate_configuration.rs` to visit splinter registry - - [x] Add `SplinterRulesVisitor` for rule collection - - [x] Generate `crates/pgls_configuration/src/analyser/splinter/rules.rs` with: - - [x] `RuleGroup` enum (Performance, Security) - - [x] `Rules` struct with recommended/all/group fields - - [x] `Performance` struct with 7 rules (using `RuleConfiguration<()>`) - - [x] `Security` struct with 14 rules (using `RuleConfiguration<()>`) - - [x] All helper methods (has_rule, severity, get_enabled_rules, etc.) - - [x] Generate `crates/pgls_configuration/src/generated/splinter.rs` with `push_to_analyser_splinter()` - - [x] Update `analyser/mod.rs` to export splinter config - - [x] Fix imports: use `pgls_analyser::RuleOptions` instead of `pgls_analyse::options::RuleOptions` - - [x] Fix type references: use `LinterRules` instead of `AnalyserRules` - - [x] Run `just gen-lint` successfully - - [x] Verify full workspace compiles - -- [ ] **Documentation Enhancement** (PHASE 5): - - [ ] Add SQL query examples to splinter rule docs - - [ ] Extract SQL from vendor/*.sql files into doc comments - - [ ] Add usage examples and remediation steps - - [ ] Generate rule documentation via docs_codegen - -- [x] **Runtime Integration** (PHASE 5 - COMPLETED): - - [x] Update `run_splinter()` to use visitor pattern with AnalysisFilter - - [x] Build dynamic SQL queries from enabled rules only - - [x] Remove hardcoded SQL query execution (removed load_generic/load_supabase functions) - - [ ] Remove hardcoded category mapping in convert.rs (DEFERRED - requires codegen improvements) - - [ ] Add splinter to RuleSelector enum (DEFERRED - requires design decisions for multi-analyzer support) - -**Codegen Outputs After Phase 3:** -``` -Linter: - - crates/pgls_analyser/src/registry.rs (generated - ✅ DONE) - - crates/pgls_analyser/src/options.rs (generated - ✅ DONE) - - crates/pgls_configuration/src/analyser/linter/rules.rs (generated - ✅ DONE) - - crates/pgls_configuration/src/generated/linter.rs (generated - ✅ DONE) - -Splinter: - - crates/pgls_splinter/src/rules/ (generated - ✅ DONE) - - crates/pgls_splinter/src/rule.rs (generated - ✅ DONE) - - crates/pgls_splinter/src/registry.rs (generated - ✅ DONE) - - crates/pgls_configuration/src/analyser/splinter/mod.rs (created - ✅ DONE) - - crates/pgls_configuration/src/analyser/splinter/rules.rs (generated - ✅ DONE) - - crates/pgls_configuration/src/generated/splinter.rs (generated - ✅ DONE) -``` - -**Implementation Details:** -- Configuration structure mirrors linter configuration for consistency -- Splinter rules use `RuleConfiguration<()>` since they have no rule-specific options -- All 21 rules (7 performance + 14 security) are properly configured with severities from SQL metadata -- Category name in `get_severity_from_code()` correctly uses "splinter" prefix -- No recommended rules by default (RECOMMENDED_RULES_AS_FILTERS is empty) - -**Config File Example:** -```json -{ - "splinter": { - "enabled": true, - "rules": { - "all": true, - "performance": { - "unindexedForeignKeys": "warn", - "noPrimaryKey": "off" - }, - "security": { - "authUsersExposed": "error" - } - } - } -} -``` - -**Notes:** -- ✅ Configuration generation is complete and tested -- ✅ Runtime integration (dynamic SQL query building) completed in Phase 5 -- 📋 Documentation enhancement with SQL examples planned for Phase 5 (Part C) -- 📋 RuleSelector integration deferred (requires design for multi-analyzer support) -- 📋 Category mapping in convert.rs still hardcoded (can be improved with codegen) - ---- - -### Phase 4: Rename pgls_analyser → pgls_linter 📋 PLANNED -Final rename to clarify purpose. - -**Tasks:** -- [ ] Rename crate in Cargo.toml -- [ ] Update all imports -- [ ] Update documentation -- [ ] Update CLAUDE.md / AGENTS.md -- [ ] Verify tests pass - ---- - -### Phase 5: Runtime & Documentation Enhancements ✅ COMPLETED -Advanced features for splinter integration. - -**Part B: Dynamic SQL Query Building** ✅ -**Part C: Enhanced Documentation** ✅ -**Deferred Items: All Completed** ✅ - -**Implementation Summary (Part B):** - -The runtime integration has been completed with the following changes: - -1. **Updated `run_splinter()` signature** (`crates/pgls_splinter/src/lib.rs`): - - Now accepts `filter: &AnalysisFilter<'_>` parameter - - Uses visitor pattern to collect enabled rules based on filter - - Returns early if no rules are enabled (performance optimization) - -2. **Implemented `SplinterRuleCollector` visitor**: - - Properly implements all RegistryVisitor methods (record_category, record_group, record_rule) - - Filters at each level (category, group, rule) for efficiency - - Collects rule names (camelCase) for enabled rules only - -3. **Dynamic SQL query building**: - - Reads individual SQL files from `vendor/` directory based on enabled rules - - Uses `crate::registry::get_sql_file_path()` to map rule names to SQL file paths - - Combines multiple SQL queries with `UNION ALL` - - Only executes SQL for enabled rules (major performance improvement) - -4. **Removed hardcoded functions**: - - Deleted `load_generic_splinter_results()` - - Deleted `load_supabase_splinter_results()` - - Removed Supabase role checking logic (rules are now filtered by configuration) - -5. **Updated test call sites**: - - All tests now pass `AnalysisFilter::default()` to enable all rules - - Maintains backward compatibility for test behavior - -6. **Added manual `FromRow` implementation**: - - `SplinterQueryResult` now implements `FromRow` manually (was using compile-time macro) - - Enables dynamic SQL execution while maintaining type safety - -**Performance Benefits:** -- 🚀 Only enabled rules execute SQL queries -- 🚀 Can disable expensive rules individually via configuration -- 🚀 Example: Disabling 18/21 rules means only 3 SQL queries execute instead of all 21 - -**Deferred Items (Now Completed):** -- ✅ Category mapping in `convert.rs` - **COMPLETED** - - Generated `get_rule_category()` function in `registry.rs` via codegen - - Replaced 120-line match statement with single function call - - Maps snake_case SQL result names to static Category references -- ✅ RuleSelector enum multi-analyzer support - **COMPLETED** - - Added splinter-specific variants: `SplinterGroup`, `SplinterRule` - - Implemented prefix-based parsing (`lint/`, `splinter/`) - - Maintains backward compatibility (tries linter first) -- ✅ Supabase role checking - **COMPLETED** - - Added `requires_supabase` metadata to SQL files - - Generated `rule_requires_supabase()` function - - Implemented in-memory role checking via `SchemaCache` - - Automatically filters Supabase rules when roles don't exist - - Zero configuration needed from users -- ✅ Documentation enhancement (Part C) - **COMPLETED** - ---- - -#### **Part A: Configuration Structure Design** - -**Goal:** Create a splinter configuration structure that mirrors linter but shares common code. - -**Shared Components** (from `analyser/mod.rs`): -- `RuleConfiguration` - Configuration wrapper with severity levels -- `RulePlainConfiguration` - Severity enum (Warn, Error, Info, Off) -- Merge/Deserialize traits - -**New Splinter-Specific Types** (to generate in Phase 3): - -```rust -// analyser/splinter/mod.rs -pub struct SplinterConfiguration { - /// Enable/disable splinter linting - pub enabled: bool, - - /// Splinter rules configuration - pub rules: Rules, - - /// Ignore/include patterns (shared with linter) - pub ignore: StringSet, - pub include: StringSet, -} - -// analyser/splinter/rules.rs (GENERATED) -pub enum RuleGroup { - Performance, - Security, -} - -pub struct Rules { - /// Enable recommended rules - pub recommended: Option, - - /// Enable all rules - pub all: Option, - - /// Performance group - pub performance: Option, - - /// Security group - pub security: Option, -} - -// Performance group (GENERATED) -pub struct Performance { - pub recommended: Option, - pub all: Option, - - // Individual rules - note: using RuleConfiguration<()> since no options - pub unindexed_foreign_keys: Option>, - pub auth_rls_initplan: Option>, - pub duplicate_index: Option>, - pub multiple_permissive_policies: Option>, - pub no_primary_key: Option>, - pub table_bloat: Option>, - pub unused_index: Option>, -} - -impl Performance { - const GROUP_NAME: &'static str = "performance"; - const GROUP_RULES: &'static [&'static str] = &[ - "unindexedForeignKeys", - "authRlsInitplan", - // ... all 7 rules - ]; - - // Methods mirroring Safety group - pub fn has_rule(rule_name: &str) -> Option<&'static str> { /* ... */ } - pub fn severity(rule_name: &str) -> Severity { /* ... */ } - pub fn all_rules_as_filters() -> impl Iterator> { /* ... */ } - pub fn recommended_rules_as_filters() -> impl Iterator> { /* ... */ } - pub fn collect_preset_rules(&self, ...) { /* ... */ } - pub fn get_enabled_rules(&self) -> FxHashSet> { /* ... */ } - pub fn get_disabled_rules(&self) -> FxHashSet> { /* ... */ } -} - -// Security group (GENERATED) - same structure, 14 rules -pub struct Security { /* ... */ } -``` - -**Config File Example:** -```json -{ - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "safety": { - "addSerialColumn": "error" - } - } - }, - "splinter": { - "enabled": true, - "rules": { - "recommended": true, - "performance": { - "unindexedForeignKeys": "warn", - "noPrimaryKey": "off" - }, - "security": { - "all": true, - "authUsersExposed": "error" - } - } - } -} -``` - -**Code Sharing Strategy:** -- ✅ Reuse `RuleConfiguration` with `T = ()` for splinter (no rule options) -- ✅ Reuse severity conversion logic -- ✅ Same `as_enabled_rules()` / `as_disabled_rules()` pattern -- ✅ Same methods for each group (has_rule, severity, etc.) -- ⚠️ RuleGroup enum is separate (linter has Safety, splinter has Performance/Security) -- ⚠️ RuleSelector needs updating to handle both "lint/" and "splinter/" prefixes - ---- - -#### **Part B: Dynamic SQL Query Building** - -**Tasks:** -- [ ] Modify `run_splinter()` signature: - ```rust - pub async fn run_splinter( - params: SplinterParams<'_>, - filter: &AnalysisFilter, // NEW - ) -> Result, sqlx::Error> - ``` - -- [ ] Use visitor pattern to collect enabled rules: - ```rust - struct SplinterRuleCollector<'a> { - filter: &'a AnalysisFilter<'a>, - enabled_rules: Vec, // Rule names in camelCase - } - - impl RegistryVisitor for SplinterRuleCollector<'_> { - fn record_rule(&mut self) { - if self.filter.match_rule::() { - self.enabled_rules.push(R::METADATA.name.to_string()); - } - } - } - ``` - -- [ ] Build dynamic SQL query: - ```rust - let mut collector = SplinterRuleCollector { filter, enabled_rules: Vec::new() }; - crate::registry::visit_registry(&mut collector); - - // Map rule names to SQL file paths - let mut sql_queries = Vec::new(); - for rule_name in &collector.enabled_rules { - if let Some(sql_path) = crate::registry::get_sql_file_path(rule_name) { - let sql = std::fs::read_to_string(sql_path)?; - sql_queries.push(sql); - } - } - - // Combine with UNION ALL (only if enabled rules exist) - if sql_queries.is_empty() { - return Ok(Vec::new()); - } - - let combined_sql = sql_queries.join("\nUNION ALL\n"); - let results = sqlx::query_as::<_, SplinterQueryResult>(&combined_sql) - .fetch_all(params.conn) - .await?; - ``` - -- [ ] Remove hardcoded functions: - - Delete `load_generic_splinter_results()` - - Delete `load_supabase_splinter_results()` - - Remove `check_supabase_roles()` logic (rules are filtered by config) - -- [ ] Update convert.rs: - - Use generated category from `RuleMeta::METADATA.category` instead of `rule_name_to_category()` - - Or better: get category from diagnostic category system - ---- - -#### **Part C: Enhanced Documentation** ✅ - -**Status:** COMPLETED - -**Tasks:** -- [x] Extract SQL queries into rule doc comments -- [x] Add configuration examples to documentation -- [x] Include Supabase requirement warnings -- [x] Link to remediation documentation -- [x] Generate comprehensive doc strings via codegen - -**Implementation:** - -The codegen (`xtask/codegen/src/generate_splinter.rs`) now generates rich documentation for all splinter rules: - -1. **Added `sql_query` field to `SqlRuleMetadata`:** - - Extracts SQL content after metadata comment headers - - Preserves formatting for readability - - Strips metadata lines (`-- meta:` prefix) - -2. **Generated comprehensive doc strings** including: - - **Title and description** from SQL metadata - - **Supabase requirement note** (conditional): - ``` - **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). - It will be automatically skipped if these roles don't exist in your database. - ``` - - **Full SQL query** in code fence with `/// ` prefix on each line - - **Configuration JSON example** showing how to enable/disable: - ```json - { - "splinter": { - "rules": { - "security": { - "authUsersExposed": "warn" - } - } - } - } - ``` - - **Remediation link** to Supabase docs or custom guidance - -3. **Generated documentation visible via `cargo doc`:** - - All 21 rules now have comprehensive documentation - - Developers can view SQL queries directly in IDE - - Easy to understand what each rule checks - -**Example Generated Documentation:** -```rust -#[doc = "/// # Unindexed foreign keys\n///\n/// Identifies foreign key constraints without a covering index, which can impact database performance.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// with foreign_keys as (\n/// select\n/// cl.relnamespace::regnamespace::text as schema_name,\n/// ... [full SQL query]\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"unindexedForeignKeys\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] -pub UnindexedForeignKeys { ... } -``` - -**Files Updated:** -- `xtask/codegen/src/generate_splinter.rs` - Enhanced documentation generation -- All 21 rule files in `crates/pgls_splinter/src/rules/` - Regenerated with rich docs - ---- - -#### **Benefits Summary:** - -**Performance:** -- 🚀 Only execute SQL for enabled rules (vs. running all 21 rules) -- 🚀 Skip expensive queries when rules are disabled -- 🚀 Example: User disables 18/21 rules → only 3 SQL queries execute - -**Consistency:** -- ✅ Same enable/disable mechanism as linter -- ✅ Same configuration file structure -- ✅ Same visitor pattern for rule discovery - -**Maintainability:** -- ✅ No hardcoded SQL queries in Rust -- ✅ SQL files remain source of truth -- ✅ Adding new rules = add SQL file + run codegen -- ✅ No manual category mapping needed - -**Documentation:** -- ✅ Rule docs show actual SQL query -- ✅ Better understanding of what each rule does -- ✅ Easier to debug and customize - ---- - -#### **Migration Path:** - -**Phase 3** (Configuration Integration): -1. Generate splinter configuration types -2. Wire into config system -3. Users can enable/disable via config (but all enabled rules still run) - -**Phase 5** (This Phase - Runtime Optimization): -1. Update `run_splinter()` to use filter -2. Build dynamic SQL queries -3. Performance benefit: only enabled rules execute - -This allows incremental rollout - config works in Phase 3, optimization comes in Phase 5. - ---- - -## Progress Tracking - -### Current Status -- [x] Requirements gathering & design -- [x] Architecture proposal (Option C - Hybrid Approach) -- [x] Phase 1: Refactor pgls_analyse - **COMPLETED** -- [x] Phase 2: Enhance pgls_splinter - **COMPLETED** -- [x] Phase 3: Configuration Integration - **COMPLETED** -- [x] Phase 5 Part B: Runtime Integration - **COMPLETED** -- [x] Phase 5 Part C: Documentation Enhancement - **COMPLETED** -- [x] Phase 5 Deferred Items: Category mapping, RuleSelector, Supabase roles - **COMPLETED** -- [ ] Phase 4: Rename to pgls_linter - **PLANNED** - -### Summary -**✅ Integration Complete (Phases 1-5):** -- Generic framework (`pgls_analyse`) successfully extracted -- Splinter rules generated from SQL files with metadata -- Configuration system mirrors linter structure -- All 21 splinter rules (7 performance + 14 security) properly configured -- Dynamic SQL query building with configuration-based filtering -- Hardcoded category mapping replaced with generated functions -- RuleSelector supports both linter and splinter prefixes -- Automatic Supabase role detection via schema cache -- Comprehensive documentation generated with SQL queries and examples -- Full workspace compiles successfully - -**📋 Remaining Work:** -- Phase 4: Crate rename `pgls_analyser` → `pgls_linter` (planned) - -### Open Questions -None currently - -### Decisions Made -1. Use `LinterRule` (not `ASTRule` or `SourceCodeRule`) for clarity -2. Use `SplinterRule` for database-level rules -3. Keep codegen in xtask (not build.rs) for consistency -4. Mirror file structure between linter and splinter - ---- - -## Testing Strategy -- [x] Full workspace compiles successfully -- [x] Splinter rules generate correctly from SQL -- [x] Configuration generation runs without errors -- [ ] Existing linter tests continue to pass (not verified) -- [ ] Configuration schema validates (not verified) -- [ ] Integration test: enable/disable rules via config (requires runtime integration) -- [ ] Integration test: severity overrides work (requires runtime integration) - -**Status:** Basic compilation testing complete. Full integration testing deferred to when runtime integration is implemented. - ---- - -Last updated: 2025-12-15 - -## Phase 5 Part B Implementation Notes - -**Date Completed:** 2025-12-15 - -**Changes Made:** - -1. **File: `crates/pgls_splinter/src/lib.rs`** - - Added `SplinterRuleCollector` struct implementing `RegistryVisitor` - - Updated `run_splinter()` to accept `AnalysisFilter` parameter - - Implemented dynamic SQL query building from enabled rules - - Removed dependency on hardcoded `load_generic/load_supabase` functions - -2. **File: `crates/pgls_splinter/src/query.rs`** - - Added manual `FromRow` implementation for `SplinterQueryResult` - - Removed `load_generic_splinter_results()` function - - Removed `load_supabase_splinter_results()` function - - Added note explaining the removal - -3. **File: `crates/pgls_splinter/tests/diagnostics.rs`** - - Updated all test call sites to pass `AnalysisFilter::default()` - - Added import for `pgls_analyse::AnalysisFilter` - -**Testing:** -- Full workspace compiles successfully: ✅ -- `cargo check -p pgls_splinter` passes with only generated code warnings: ✅ -- No functional regressions expected (behavior is equivalent but more efficient) - -**Migration Notes for Users:** -- Any code calling `run_splinter()` must now pass an `AnalysisFilter` -- For "run all rules" behavior, use `AnalysisFilter::default()` -- Tests updated to demonstrate correct usage - ---- - -## Phase 5 Deferred Items - Implementation Notes - -**Date Completed:** 2025-12-15 - -### 1. Category Mapping Removal - -**Problem:** `convert.rs` contained a 120-line hardcoded `rule_name_to_category()` function mapping rule names to categories. - -**Solution:** -- Extended codegen to generate `get_rule_category()` function in `registry.rs` -- Maps snake_case SQL result names (e.g., "unindexed_foreign_keys") to static Category references -- Automatically stays in sync with SQL file metadata - -**Files Changed:** -- `xtask/codegen/src/generate_splinter.rs` - Added category lookup generation -- `crates/pgls_splinter/src/registry.rs` - Generated function (auto-generated) -- `crates/pgls_splinter/src/convert.rs` - Replaced match statement with single function call - -**Example:** -```rust -// Before: 120 lines of match statements -fn rule_name_to_category(name: &str, group: &str) -> &'static Category { - match (group, name) { - ("performance", "unindexed_foreign_keys") => category!("splinter/performance/unindexedForeignKeys"), - // ... 60+ more lines - } -} - -// After: Single function call -let category = crate::registry::get_rule_category(&result.name) - .expect("Rule name should map to a valid category"); -``` - ---- - -### 2. RuleSelector Multi-Analyzer Support - -**Problem:** `RuleSelector` enum only supported linter rules (groups: Safety). - -**Solution:** -- Split enum variants into analyzer-specific types: - - `LinterGroup` / `LinterRule` for linter rules - - `SplinterGroup` / `SplinterRule` for splinter rules -- Added prefix-based parsing (`lint/`, `splinter/`) -- Maintained backward compatibility (unprefixed selectors try linter first) - -**Files Changed:** -- `crates/pgls_configuration/src/analyser/mod.rs` - Updated RuleSelector enum and parsing - -**Example Configuration:** -```json -{ - "linter": { - "ignore": ["lint/safety/banDropTable"] // Linter rule with prefix - }, - "overrides": [ - { - "ignore": [ - "splinter/security/authUsersExposed", // Splinter rule with prefix - "multipleAlterTable" // Linter rule (backward compatible) - ] - } - ] -} -``` - ---- - -### 3. Supabase Role Checking - -**Problem:** Supabase-specific rules (9 out of 21) should automatically be skipped on non-Supabase databases without requiring configuration changes. - -**Solution:** -- Added `-- meta: requires_supabase = true` to 9 SQL files -- Generated `rule_requires_supabase()` function in `registry.rs` -- Updated `SplinterParams` to accept optional `SchemaCache` -- Implemented in-memory role checking (looks for `anon`, `authenticated`, `service_role` roles) -- Filters rules before building SQL query (performance optimization) - -**Files Changed:** -- 9 SQL files in `crates/pgls_splinter/vendor/` - Added metadata -- `xtask/codegen/src/generate_splinter.rs` - Extract and generate metadata -- `crates/pgls_splinter/src/lib.rs` - Added role checking logic -- `crates/pgls_splinter/Cargo.toml` - Added `pgls_schema_cache` dependency - -**Example:** -```rust -// Check if Supabase roles exist -let has_supabase_roles = params.schema_cache.map_or(false, |cache| { - let required_roles = ["anon", "authenticated", "service_role"]; - required_roles.iter().all(|role_name| { - cache.roles.iter().any(|role| role.name.as_str() == *role_name) - }) -}); - -// Skip Supabase-specific rules if roles don't exist -for rule_name in &collector.enabled_rules { - if !has_supabase_roles && crate::registry::rule_requires_supabase(rule_name) { - continue; // Automatically skipped - zero config needed! - } - // ... load and execute SQL -} -``` - -**Supabase-Specific Rules:** -1. `authRlsInitplan` (performance) -2. `authUsersExposed` (security) -3. `fkeyToAuthUnique` (security) -4. `foreignTableInApi` (security) -5. `insecureQueueExposedInApi` (security) -6. `materializedViewInApi` (security) -7. `rlsDisabledInPublic` (security) -8. `rlsReferencesUserMetadata` (security) -9. `securityDefinerView` (security) - ---- - -## Phase 5 Part C - Implementation Notes - -**Date Completed:** 2025-12-15 - -### Documentation Enhancement - -**Goal:** Generate comprehensive documentation for all splinter rules, including SQL queries, configuration examples, and remediation links. - -**Implementation:** - -1. **Extended `SqlRuleMetadata` struct:** - - Added `sql_query: String` field - - Added `requires_supabase: bool` field - - Extracts SQL content after metadata comment headers - - Preserves formatting and removes metadata lines - -2. **Generated comprehensive doc strings:** - - Built using `format!` macro with multiple sections - - Includes title, description, Supabase warning, SQL query, configuration example, and remediation link - - Each line prefixed with `/// ` for Rust doc comments - - SQL query wrapped in triple-backtick code fence - -3. **Documentation Sections:** - - **Title and Description**: From SQL metadata - - **Supabase Note** (conditional): Warns about role requirements - - **SQL Query**: Full query in code fence with syntax highlighting - - **Configuration**: JSON example showing how to enable/disable - - **Remediation**: Link to documentation or custom guidance - -**Files Changed:** -- `xtask/codegen/src/generate_splinter.rs` - Added doc string generation -- All 21 rule files in `crates/pgls_splinter/src/rules/` - Regenerated with rich docs - -**Example Output:** -```rust -/// # Unindexed foreign keys -/// -/// Identifies foreign key constraints without a covering index, which can impact database performance. -/// -/// ## SQL Query -/// -/// ```sql -/// with foreign_keys as ( -/// select -/// cl.relnamespace::regnamespace::text as schema_name, -/// cl.relname as table_name, -/// ... -/// ) -/// select * from foreign_keys where ... -/// ``` -/// -/// ## Configuration -/// -/// Enable or disable this rule in your configuration: -/// -/// ```json -/// { -/// "splinter": { -/// "rules": { -/// "performance": { -/// "unindexedForeignKeys": "warn" -/// } -/// } -/// } -/// } -/// ``` -/// -/// ## Remediation -/// -/// See: -pub struct UnindexedForeignKeys { ... } -``` - -**Benefits:** -- ✅ Developers can view SQL queries directly in IDE via hover/goto-definition -- ✅ `cargo doc` generates comprehensive documentation -- ✅ Easy to understand what each rule checks without reading SQL files -- ✅ Configuration examples reduce setup friction -- ✅ Remediation links provide actionable next steps - ---- - -**Overall Phase 5 Status:** ✅ FULLY COMPLETED - -All planned work and deferred items have been successfully implemented: -- Dynamic SQL query building with configuration filtering -- Hardcoded category mapping replaced with generated functions -- Multi-analyzer RuleSelector support -- Automatic Supabase role detection -- Comprehensive documentation generation - -Full workspace compiles successfully with no errors. diff --git a/crates/pgls_analyser/src/lib.rs b/crates/pgls_analyser/src/lib.rs index 5cfd01897..2e14a1361 100644 --- a/crates/pgls_analyser/src/lib.rs +++ b/crates/pgls_analyser/src/lib.rs @@ -19,11 +19,6 @@ pub use linter_registry::{ }; pub use linter_rule::{LinterDiagnostic, LinterRule}; -// For convenience in macros and rule files - keep these shorter names -pub use LinterDiagnostic as RuleDiagnostic; -pub use LinterRule as Rule; -pub use LinterRuleContext as RuleContext; - pub static METADATA: LazyLock = LazyLock::new(|| { let mut metadata = MetadataRegistry::default(); // Use a separate visitor for metadata that implements pgls_analyse::RegistryVisitor diff --git a/crates/pgls_analyser/src/lint/safety/add_serial_column.rs b/crates/pgls_analyser/src/lint/safety/add_serial_column.rs index c249235fa..1fbf15b7e 100644 --- a/crates/pgls_analyser/src/lint/safety/add_serial_column.rs +++ b/crates/pgls_analyser/src/lint/safety/add_serial_column.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -38,10 +38,10 @@ declare_lint_rule! { } } -impl Rule for AddSerialColumn { +impl LinterRule for AddSerialColumn { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { @@ -56,7 +56,7 @@ impl Rule for AddSerialColumn { let type_str = get_type_name(type_name); if is_serial_type(&type_str) { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -86,7 +86,7 @@ impl Rule for AddSerialColumn { if has_stored_generated { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs b/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs index e9b55cc8b..ae06abc1b 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_field_with_default.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -39,10 +39,10 @@ declare_lint_rule! { } } -impl Rule for AddingFieldWithDefault { +impl LinterRule for AddingFieldWithDefault { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Check PostgreSQL version - in 11+, non-volatile defaults are safe @@ -75,7 +75,7 @@ impl Rule for AddingFieldWithDefault { if has_generated { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -102,7 +102,7 @@ impl Rule for AddingFieldWithDefault { if !is_safe_default { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -116,7 +116,7 @@ impl Rule for AddingFieldWithDefault { } else { // Pre PG 11, all defaults cause rewrites diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs b/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs index 5c707339c..b901fe508 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_foreign_key_constraint.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -43,10 +43,10 @@ declare_lint_rule! { } } -impl Rule for AddingForeignKeyConstraint { +impl LinterRule for AddingForeignKeyConstraint { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { @@ -95,7 +95,7 @@ impl Rule for AddingForeignKeyConstraint { fn check_foreign_key_constraint( constraint: &pgls_query::protobuf::Constraint, is_column_constraint: bool, -) -> Option { +) -> Option { // Only check foreign key constraints if constraint.contype() != pgls_query::protobuf::ConstrType::ConstrForeign { return None; @@ -121,7 +121,7 @@ fn check_foreign_key_constraint( }; Some( - RuleDiagnostic::new(rule_category!(), None, markup! { {message} }) + LinterDiagnostic::new(rule_category!(), None, markup! { {message} }) .detail(None, detail) .note(note), ) diff --git a/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs b/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs index bdbbc43b0..d62079faf 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_not_null_field.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -42,10 +42,10 @@ declare_lint_rule! { } } -impl Rule for AddingNotNullField { +impl LinterRule for AddingNotNullField { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // In Postgres 11+, this is less of a concern @@ -60,7 +60,7 @@ impl Rule for AddingNotNullField { for cmd in &stmt.cmds { if let Some(pgls_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { if cmd.subtype() == pgls_query::protobuf::AlterTableType::AtSetNotNull { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs b/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs index c38225271..a09024ff0 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_primary_key_constraint.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -40,10 +40,10 @@ declare_lint_rule! { } } -impl Rule for AddingPrimaryKeyConstraint { +impl LinterRule for AddingPrimaryKeyConstraint { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { @@ -92,12 +92,12 @@ impl Rule for AddingPrimaryKeyConstraint { fn check_for_primary_key_constraint( constraint: &pgls_query::protobuf::Constraint, -) -> Option { +) -> Option { if constraint.contype() == pgls_query::protobuf::ConstrType::ConstrPrimary && constraint.indexname.is_empty() { Some( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/adding_required_field.rs b/crates/pgls_analyser/src/lint/safety/adding_required_field.rs index 22a978d4d..050451ed1 100644 --- a/crates/pgls_analyser/src/lint/safety/adding_required_field.rs +++ b/crates/pgls_analyser/src/lint/safety/adding_required_field.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -25,10 +25,10 @@ declare_lint_rule! { } } -impl Rule for AddingRequiredField { +impl LinterRule for AddingRequiredField { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = vec![]; if let pgls_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() { @@ -47,7 +47,7 @@ impl Rule for AddingRequiredField { == pgls_query::protobuf::AlterTableType::AtAddColumn { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_char_field.rs b/crates/pgls_analyser/src/lint/safety/ban_char_field.rs index f9c948129..3e2a10b05 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_char_field.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_char_field.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -41,10 +41,10 @@ declare_lint_rule! { } } -impl Rule for BanCharField { +impl LinterRule for BanCharField { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::CreateStmt(stmt) = &ctx.stmt() { @@ -78,7 +78,9 @@ impl Rule for BanCharField { } } -fn check_column_for_char_type(col_def: &pgls_query::protobuf::ColumnDef) -> Option { +fn check_column_for_char_type( + col_def: &pgls_query::protobuf::ColumnDef, +) -> Option { if let Some(type_name) = &col_def.type_name { for name_node in &type_name.names { if let Some(pgls_query::NodeEnum::String(name)) = &name_node.node { @@ -87,7 +89,7 @@ fn check_column_for_char_type(col_def: &pgls_query::protobuf::ColumnDef) -> Opti let type_str = name.sval.to_lowercase(); if type_str == "bpchar" || type_str == "char" || type_str == "character" { return Some( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs b/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs index dcc4af04e..6adddcacf 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_concurrent_index_creation_in_transaction.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,10 +27,10 @@ declare_lint_rule! { } } -impl Rule for BanConcurrentIndexCreationInTransaction { +impl LinterRule for BanConcurrentIndexCreationInTransaction { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // check if the current statement is CREATE INDEX CONCURRENTLY and there is at least one @@ -39,7 +39,7 @@ impl Rule for BanConcurrentIndexCreationInTransaction { // since our analyser assumes we're always in a transaction context, we always flag concurrent indexes if let pgls_query::NodeEnum::IndexStmt(stmt) = ctx.stmt() { if stmt.concurrent && ctx.file_context().stmt_count() > 1 { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs index f499d7afb..238c65ba4 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_column.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,17 +27,17 @@ declare_lint_rule! { } } -impl Rule for BanDropColumn { +impl LinterRule for BanDropColumn { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { for cmd in &stmt.cmds { if let Some(pgls_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { if cmd.subtype() == pgls_query::protobuf::AlterTableType::AtDropColumn { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs index 8448eb26a..833eb4aae 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_database.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -16,15 +16,15 @@ declare_lint_rule! { } } -impl Rule for BanDropDatabase { +impl LinterRule for BanDropDatabase { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = vec![]; if let pgls_query::NodeEnum::DropdbStmt(_) = &ctx.stmt() { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs index a9ed90ea8..fd1e3a89a 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_not_null.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,17 +27,17 @@ declare_lint_rule! { } } -impl Rule for BanDropNotNull { +impl LinterRule for BanDropNotNull { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { for cmd in &stmt.cmds { if let Some(pgls_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { if cmd.subtype() == pgls_query::protobuf::AlterTableType::AtDropNotNull { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs b/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs index 77265d24e..4ad99b854 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_drop_table.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -26,16 +26,16 @@ declare_lint_rule! { } } -impl Rule for BanDropTable { +impl LinterRule for BanDropTable { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = vec![]; if let pgls_query::NodeEnum::DropStmt(stmt) = &ctx.stmt() { if stmt.remove_type() == pgls_query::protobuf::ObjectType::ObjectTable { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs b/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs index e2bd9009b..3423dcb45 100644 --- a/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs +++ b/crates/pgls_analyser/src/lint/safety/ban_truncate_cascade.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -29,15 +29,15 @@ declare_lint_rule! { } } -impl Rule for BanTruncateCascade { +impl LinterRule for BanTruncateCascade { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::TruncateStmt(stmt) = &ctx.stmt() { if stmt.behavior() == DropBehavior::DropCascade { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/changing_column_type.rs b/crates/pgls_analyser/src/lint/safety/changing_column_type.rs index f093b4242..d7e7e9159 100644 --- a/crates/pgls_analyser/src/lint/safety/changing_column_type.rs +++ b/crates/pgls_analyser/src/lint/safety/changing_column_type.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -29,17 +29,17 @@ declare_lint_rule! { } } -impl Rule for ChangingColumnType { +impl LinterRule for ChangingColumnType { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() { for cmd in &stmt.cmds { if let Some(pgls_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { if cmd.subtype() == pgls_query::protobuf::AlterTableType::AtAlterColumnType { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs b/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs index 1e27fc06e..248765101 100644 --- a/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs +++ b/crates/pgls_analyser/src/lint/safety/constraint_missing_not_valid.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -35,10 +35,10 @@ declare_lint_rule! { } } -impl Rule for ConstraintMissingNotValid { +impl LinterRule for ConstraintMissingNotValid { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); let pgls_query::NodeEnum::AlterTableStmt(stmt) = ctx.stmt() else { @@ -67,7 +67,7 @@ impl Rule for ConstraintMissingNotValid { fn check_constraint_needs_not_valid( constraint: &pgls_query::protobuf::Constraint, -) -> Option { +) -> Option { // Skip if the constraint has NOT VALID if !constraint.initially_valid { return None; @@ -77,7 +77,7 @@ fn check_constraint_needs_not_valid( match constraint.contype() { pgls_query::protobuf::ConstrType::ConstrCheck | pgls_query::protobuf::ConstrType::ConstrForeign => Some( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/creating_enum.rs b/crates/pgls_analyser/src/lint/safety/creating_enum.rs index e6b6ccf8a..1791361f8 100644 --- a/crates/pgls_analyser/src/lint/safety/creating_enum.rs +++ b/crates/pgls_analyser/src/lint/safety/creating_enum.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -42,17 +42,17 @@ declare_lint_rule! { } } -impl Rule for CreatingEnum { +impl LinterRule for CreatingEnum { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::CreateEnumStmt(stmt) = &ctx.stmt() { let type_name = get_type_name(&stmt.type_name); diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs b/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs index c7a081133..5009c9640 100644 --- a/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs +++ b/crates/pgls_analyser/src/lint/safety/disallow_unique_constraint.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -39,10 +39,10 @@ declare_lint_rule! { } } -impl Rule for DisallowUniqueConstraint { +impl LinterRule for DisallowUniqueConstraint { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { @@ -79,7 +79,7 @@ impl Rule for DisallowUniqueConstraint { && constraint.indexname.is_empty() { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -104,7 +104,7 @@ impl Rule for DisallowUniqueConstraint { == pgls_query::protobuf::ConstrType::ConstrUnique { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs b/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs index 7660b9db4..df597cb2c 100644 --- a/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs +++ b/crates/pgls_analyser/src/lint/safety/lock_timeout_warning.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -44,10 +44,10 @@ declare_lint_rule! { } } -impl Rule for LockTimeoutWarning { +impl LinterRule for LockTimeoutWarning { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Check if lock timeout has been set in the transaction @@ -72,7 +72,7 @@ impl Rule for LockTimeoutWarning { if !tx_state.has_created_object(schema, table) { let full_name = format!("{schema}.{table}"); diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -102,7 +102,7 @@ impl Rule for LockTimeoutWarning { let full_name = format!("{schema}.{table}"); let index_name = &stmt.idxname; diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs b/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs index b7b7178b1..274db62ab 100644 --- a/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs +++ b/crates/pgls_analyser/src/lint/safety/multiple_alter_table.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -40,10 +40,10 @@ declare_lint_rule! { } } -impl Rule for MultipleAlterTable { +impl LinterRule for MultipleAlterTable { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Check if current statement is ALTER TABLE @@ -94,7 +94,7 @@ impl Rule for MultipleAlterTable { }; diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs b/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs index 5aae8df47..f49c6e214 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_big_int.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -52,10 +52,10 @@ declare_lint_rule! { } } -impl Rule for PreferBigInt { +impl LinterRule for PreferBigInt { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -90,7 +90,7 @@ impl Rule for PreferBigInt { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { if let Some(type_name) = &col_def.type_name { @@ -111,7 +111,7 @@ fn check_column_def( if is_small_int { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs index 58d0deff3..3940d2efb 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_int.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -58,10 +58,10 @@ declare_lint_rule! { } } -impl Rule for PreferBigintOverInt { +impl LinterRule for PreferBigintOverInt { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -96,7 +96,7 @@ impl Rule for PreferBigintOverInt { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { let Some(type_name) = &col_def.type_name else { @@ -118,7 +118,7 @@ fn check_column_def( } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs index 066923b02..612943311 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_bigint_over_smallint.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -52,10 +52,10 @@ declare_lint_rule! { } } -impl Rule for PreferBigintOverSmallint { +impl LinterRule for PreferBigintOverSmallint { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -90,7 +90,7 @@ impl Rule for PreferBigintOverSmallint { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { let Some(type_name) = &col_def.type_name else { @@ -111,7 +111,7 @@ fn check_column_def( } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_identity.rs b/crates/pgls_analyser/src/lint/safety/prefer_identity.rs index bc82b1967..224f315f6 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_identity.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_identity.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -53,10 +53,10 @@ declare_lint_rule! { } } -impl Rule for PreferIdentity { +impl LinterRule for PreferIdentity { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match ctx.stmt() { @@ -92,7 +92,7 @@ impl Rule for PreferIdentity { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { let Some(type_name) = &col_def.type_name else { @@ -112,7 +112,7 @@ fn check_column_def( } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs b/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs index 16f51f9ff..a002f2065 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_jsonb.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -61,10 +61,10 @@ declare_lint_rule! { } } -impl Rule for PreferJsonb { +impl LinterRule for PreferJsonb { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -99,7 +99,7 @@ impl Rule for PreferJsonb { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { let Some(type_name) = &col_def.type_name else { @@ -117,7 +117,7 @@ fn check_column_def( } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs b/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs index 0451db031..ac9a89ceb 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_robust_stmts.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -41,10 +41,10 @@ declare_lint_rule! { } } -impl Rule for PreferRobustStmts { +impl LinterRule for PreferRobustStmts { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Since we assume we're always in a transaction, we only check for @@ -55,7 +55,7 @@ impl Rule for PreferRobustStmts { if stmt.concurrent { // Check for unnamed index if stmt.idxname.is_empty() { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { @@ -66,7 +66,7 @@ impl Rule for PreferRobustStmts { // Check for IF NOT EXISTS if !stmt.if_not_exists { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { @@ -85,7 +85,7 @@ impl Rule for PreferRobustStmts { // Concurrent drop runs outside transaction if stmt.concurrent && !stmt.missing_ok { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs b/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs index 847ad43d5..858b2194f 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_text_field.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -45,10 +45,10 @@ declare_lint_rule! { } } -impl Rule for PreferTextField { +impl LinterRule for PreferTextField { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -84,7 +84,7 @@ impl Rule for PreferTextField { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { if let Some(type_name) = &col_def.type_name { @@ -93,7 +93,7 @@ fn check_column_def( // Check if it's varchar with a size limit if name.sval.to_lowercase() == "varchar" && !type_name.typmods.is_empty() { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs b/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs index 8fd048e01..c329c7657 100644 --- a/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs +++ b/crates/pgls_analyser/src/lint/safety/prefer_timestamptz.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -58,10 +58,10 @@ declare_lint_rule! { } } -impl Rule for PreferTimestamptz { +impl LinterRule for PreferTimestamptz { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); match &ctx.stmt() { @@ -97,7 +97,7 @@ impl Rule for PreferTimestamptz { } fn check_column_def( - diagnostics: &mut Vec, + diagnostics: &mut Vec, col_def: &pgls_query::protobuf::ColumnDef, ) { if let Some(type_name) = &col_def.type_name { @@ -106,7 +106,7 @@ fn check_column_def( // Check for "timestamp" (without timezone) if name.sval.to_lowercase() == "timestamp" { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/renaming_column.rs b/crates/pgls_analyser/src/lint/safety/renaming_column.rs index a54fbb51a..a4e345be9 100644 --- a/crates/pgls_analyser/src/lint/safety/renaming_column.rs +++ b/crates/pgls_analyser/src/lint/safety/renaming_column.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,15 +27,15 @@ declare_lint_rule! { } } -impl Rule for RenamingColumn { +impl LinterRule for RenamingColumn { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::RenameStmt(stmt) = &ctx.stmt() { if stmt.rename_type() == pgls_query::protobuf::ObjectType::ObjectColumn { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/renaming_table.rs b/crates/pgls_analyser/src/lint/safety/renaming_table.rs index 2cc1046fc..796a9137e 100644 --- a/crates/pgls_analyser/src/lint/safety/renaming_table.rs +++ b/crates/pgls_analyser/src/lint/safety/renaming_table.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -27,15 +27,15 @@ declare_lint_rule! { } } -impl Rule for RenamingTable { +impl LinterRule for RenamingTable { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::RenameStmt(stmt) = &ctx.stmt() { if stmt.rename_type() == pgls_query::protobuf::ObjectType::ObjectTable { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs index 257536d3a..15709bfc0 100644 --- a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs +++ b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_creation.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -33,10 +33,10 @@ declare_lint_rule! { } } -impl Rule for RequireConcurrentIndexCreation { +impl LinterRule for RequireConcurrentIndexCreation { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); let pgls_query::NodeEnum::IndexStmt(stmt) = &ctx.stmt() else { @@ -61,7 +61,7 @@ impl Rule for RequireConcurrentIndexCreation { } diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs index 7117908c4..23460dffd 100644 --- a/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs +++ b/crates/pgls_analyser/src/lint/safety/require_concurrent_index_deletion.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -33,17 +33,17 @@ declare_lint_rule! { } } -impl Rule for RequireConcurrentIndexDeletion { +impl LinterRule for RequireConcurrentIndexDeletion { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::DropStmt(stmt) = &ctx.stmt() { if !stmt.concurrent && stmt.remove_type() == pgls_query::protobuf::ObjectType::ObjectIndex { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs b/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs index f481f76a9..86f14bd78 100644 --- a/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs +++ b/crates/pgls_analyser/src/lint/safety/running_statement_while_holding_access_exclusive.rs @@ -1,4 +1,4 @@ -use crate::{Rule, RuleContext, RuleDiagnostic}; +use crate::{LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -43,17 +43,17 @@ declare_lint_rule! { } } -impl Rule for RunningStatementWhileHoldingAccessExclusive { +impl LinterRule for RunningStatementWhileHoldingAccessExclusive { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); // Check if we're currently holding an ACCESS EXCLUSIVE lock let tx_state = ctx.file_context().transaction_state(); if tx_state.is_holding_access_exclusive() { diagnostics.push( - RuleDiagnostic::new( + LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs b/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs index d7205c945..6d5b94148 100644 --- a/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs +++ b/crates/pgls_analyser/src/lint/safety/transaction_nesting.rs @@ -1,4 +1,4 @@ -use crate::{AnalysedFileContext, Rule, RuleContext, RuleDiagnostic}; +use crate::{AnalysedFileContext, LinterDiagnostic, LinterRule, LinterRuleContext}; use pgls_analyse::{RuleSource, declare_lint_rule}; use pgls_console::markup; use pgls_diagnostics::Severity; @@ -34,10 +34,10 @@ declare_lint_rule! { } } -impl Rule for TransactionNesting { +impl LinterRule for TransactionNesting { type Options = (); - fn run(ctx: &RuleContext) -> Vec { + fn run(ctx: &LinterRuleContext) -> Vec { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::TransactionStmt(stmt) = &ctx.stmt() { @@ -46,7 +46,7 @@ impl Rule for TransactionNesting { | pgls_query::protobuf::TransactionStmtKind::TransStmtStart => { // Check if there's already a BEGIN in previous statements if has_transaction_start_before(ctx.file_context()) { - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { @@ -55,7 +55,7 @@ impl Rule for TransactionNesting { ).detail(None, "Starting a transaction when already in a transaction can cause issues.")); } // Always warn about BEGIN/START since we assume we're in a transaction - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { @@ -67,7 +67,7 @@ impl Rule for TransactionNesting { pgls_query::protobuf::TransactionStmtKind::TransStmtCommit | pgls_query::protobuf::TransactionStmtKind::TransStmtRollback => { // Always warn about COMMIT/ROLLBACK since we assume we're in a transaction - diagnostics.push(RuleDiagnostic::new( + diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { diff --git a/crates/pgls_splinter/Cargo.toml b/crates/pgls_splinter/Cargo.toml index f6554aaf2..e42d9a51c 100644 --- a/crates/pgls_splinter/Cargo.toml +++ b/crates/pgls_splinter/Cargo.toml @@ -18,9 +18,6 @@ serde.workspace = true serde_json.workspace = true sqlx.workspace = true -[build-dependencies] -ureq = "2.10" - [dev-dependencies] insta.workspace = true pgls_console.workspace = true diff --git a/crates/pgls_splinter/build.rs b/crates/pgls_splinter/build.rs deleted file mode 100644 index d7d55e693..000000000 --- a/crates/pgls_splinter/build.rs +++ /dev/null @@ -1,214 +0,0 @@ -use std::env; -use std::fs; -use std::path::Path; - -// Update this commit SHA to pull in a new version of splinter.sql -const SPLINTER_COMMIT_SHA: &str = "27ea2ece65464213e466cd969cc61b6940d16219"; - -// Rules that work on any PostgreSQL database -const GENERIC_RULES: &[&str] = &[ - "unindexed_foreign_keys", - "no_primary_key", - "unused_index", - "multiple_permissive_policies", - "policy_exists_rls_disabled", - "rls_enabled_no_policy", - "duplicate_index", - "extension_in_public", - "table_bloat", - "extension_versions_outdated", - "function_search_path_mutable", - "unsupported_reg_types", -]; - -// Rules that require Supabase-specific infrastructure (auth schema, anon/authenticated roles, pgrst.db_schemas) -const SUPABASE_ONLY_RULES: &[&str] = &[ - "auth_users_exposed", - "auth_rls_initplan", - "rls_disabled_in_public", - "security_definer_view", - "rls_references_user_metadata", - "materialized_view_in_api", - "foreign_table_in_api", - "insecure_queue_exposed_in_api", - "fkey_to_auth_unique", -]; - -fn main() { - let out_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); - let vendor_dir = Path::new(&out_dir).join("vendor"); - let generic_sql_file = vendor_dir.join("splinter_generic.sql"); - let supabase_sql_file = vendor_dir.join("splinter_supabase.sql"); - let sha_file = vendor_dir.join("COMMIT_SHA.txt"); - - // Create vendor directory if it doesn't exist - if !vendor_dir.exists() { - fs::create_dir_all(&vendor_dir).expect("Failed to create vendor directory"); - } - - // Check if we need to download - let needs_download = - if !generic_sql_file.exists() || !supabase_sql_file.exists() || !sha_file.exists() { - true - } else { - // Check if stored SHA matches current constant - let stored_sha = fs::read_to_string(&sha_file) - .expect("Failed to read COMMIT_SHA.txt") - .trim() - .to_string(); - stored_sha != SPLINTER_COMMIT_SHA - }; - - if needs_download { - println!( - "cargo:warning=Downloading splinter.sql from GitHub (commit: {SPLINTER_COMMIT_SHA})" - ); - download_and_process_sql(&generic_sql_file, &supabase_sql_file); - fs::write(&sha_file, SPLINTER_COMMIT_SHA).expect("Failed to write COMMIT_SHA.txt"); - } - - // Tell cargo to rerun if build.rs or SHA file changes - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=vendor/COMMIT_SHA.txt"); -} - -fn download_and_process_sql(generic_dest: &Path, supabase_dest: &Path) { - let url = format!( - "https://raw.githubusercontent.com/supabase/splinter/{SPLINTER_COMMIT_SHA}/splinter.sql" - ); - - // Download the file - let response = ureq::get(&url) - .call() - .expect("Failed to download splinter.sql"); - - let content = response - .into_string() - .expect("Failed to read response body"); - - // Remove the SET LOCAL search_path section - let mut processed_content = remove_set_search_path(&content); - - // Add "!" suffix to column aliases for sqlx non-null checking - processed_content = add_not_null_markers(&processed_content); - - // Split into generic and Supabase-specific queries (validates categorization) - let (generic_queries, supabase_queries) = split_queries(&processed_content); - - // Write to destination files - fs::write(generic_dest, generic_queries).expect("Failed to write splinter_generic.sql"); - fs::write(supabase_dest, supabase_queries).expect("Failed to write splinter_supabase.sql"); - - println!( - "cargo:warning=Successfully downloaded and processed splinter.sql into generic and Supabase-specific files" - ); -} - -fn remove_set_search_path(content: &str) -> String { - content - .lines() - .filter(|line| { - let trimmed = line.trim(); - !trimmed.to_lowercase().starts_with("set local search_path") - }) - .collect::>() - .join("\n") -} - -fn add_not_null_markers(content: &str) -> String { - // Add "!" suffix to all column aliases to mark them as non-null for sqlx - // This transforms patterns like: 'value' as name - // Into: 'value' as "name!" - - let columns_to_mark = [ - "name", - "title", - "level", - "facing", - "categories", - "description", - "detail", - "remediation", - "metadata", - "cache_key", - ]; - - let mut result = content.to_string(); - - for column in &columns_to_mark { - // Match patterns like: as name, as name) - let pattern_comma = format!(" as {column}"); - let replacement_comma = format!(" as \"{column}!\""); - result = result.replace(&pattern_comma, &replacement_comma); - } - - result -} - -/// Extract rule name from a query fragment -fn extract_rule_name_from_query(query: &str) -> String { - // Look for pattern 'rule_name' as "name!" - for line in query.lines() { - if line.contains(" as \"name!\"") { - if let Some(start) = line.rfind('\'') { - if let Some(prev_quote) = line[..start].rfind('\'') { - return line[prev_quote + 1..start].to_string(); - } - } - } - } - "unknown".to_string() -} - -fn split_queries(content: &str) -> (String, String) { - // Split the union all queries based on rule names - let queries: Vec<&str> = content.split("union all").collect(); - - let mut generic_queries = Vec::new(); - let mut supabase_queries = Vec::new(); - - for query in queries { - // Extract the rule name from the query (it's the first 'name' field) - let is_supabase = SUPABASE_ONLY_RULES - .iter() - .any(|rule| query.contains(&format!("'{rule}' as \"name!\""))); - - let is_generic = GENERIC_RULES - .iter() - .any(|rule| query.contains(&format!("'{rule}' as \"name!\""))); - - if is_supabase { - supabase_queries.push(query); - } else if is_generic { - generic_queries.push(query); - } else { - // Extract rule name for better error message - let rule_name = extract_rule_name_from_query(query); - panic!( - "Found unknown Splinter rule that is not categorized: {rule_name:?}\n\ - Please add this rule to either GENERIC_RULES or SUPABASE_ONLY_RULES in build.rs.\n\ - \n\ - Guidelines:\n\ - - GENERIC_RULES: Rules that work on any PostgreSQL database\n\ - - SUPABASE_ONLY_RULES: Rules that require Supabase infrastructure (auth schema, roles, pgrst.db_schemas)\n\ - \n\ - This prevents new Supabase-specific rules from breaking linting on non-Supabase databases." - ); - } - } - - // Join queries with "union all" and wrap in parentheses - let generic_sql = if generic_queries.is_empty() { - String::new() - } else { - generic_queries.join("union all\n") - }; - - let supabase_sql = if supabase_queries.is_empty() { - String::new() - } else { - supabase_queries.join("union all\n") - }; - - (generic_sql, supabase_sql) -} diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index 21b272855..7552c744e 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -79,8 +79,12 @@ pub async fn run_splinter( for rule_name in &collector.enabled_rules { // Skip Supabase-specific rules if Supabase roles don't exist - if !has_supabase_roles && crate::registry::rule_requires_supabase(rule_name) { - continue; + if !has_supabase_roles { + if let Some(metadata) = crate::registry::get_rule_metadata(rule_name) { + if metadata.requires_supabase { + continue; + } + } } // Get embedded SQL content (compile-time included) @@ -99,7 +103,7 @@ pub async fn run_splinter( // Ensure all queries are wrapped for valid UNION ALL syntax let processed_queries: Vec = sql_queries .iter() - .map(|sql| { + .map(|sql: &&str| { let trimmed = sql.trim(); // Wrap in parentheses if not already wrapped if trimmed.starts_with('(') && trimmed.ends_with(')') { diff --git a/crates/pgls_splinter/src/registry.rs b/crates/pgls_splinter/src/registry.rs index aa84f4e2a..3b2a81350 100644 --- a/crates/pgls_splinter/src/registry.rs +++ b/crates/pgls_splinter/src/registry.rs @@ -2,6 +2,16 @@ #![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"] use pgls_analyse::RegistryVisitor; +#[doc = r" Metadata for a splinter rule"] +#[derive(Debug, Clone, Copy)] +pub struct SplinterRuleMetadata { + #[doc = r" Description of what the rule detects"] + pub description: &'static str, + #[doc = r" URL to documentation/remediation guide"] + pub remediation: &'static str, + #[doc = r" Whether this rule requires Supabase roles (anon, authenticated, service_role)"] + pub requires_supabase: bool, +} #[doc = r" Visit all splinter rules using the visitor pattern"] #[doc = r" This is called during registry building to collect enabled rules"] pub fn visit_registry(registry: &mut V) { @@ -151,6 +161,25 @@ pub fn get_sql_content(rule_name: &str) -> Option<&'static str> { _ => None, } } +#[doc = r" Get metadata fields for a rule (camelCase name)"] +#[doc = r" Returns (description, remediation, requires_supabase) tuple"] +#[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 , } +} +#[doc = r" Get metadata for a rule (camelCase name)"] +#[doc = r" Returns None if rule not found"] +#[doc = r""] +#[doc = r" This provides structured access to rule metadata by calling trait constants"] +pub fn get_rule_metadata(rule_name: &str) -> Option { + let (description, remediation, requires_supabase) = get_rule_metadata_fields(rule_name)?; + Some(SplinterRuleMetadata { + description, + remediation, + requires_supabase, + }) +} #[doc = r" Map rule name from SQL result (snake_case) to diagnostic category"] #[doc = r" Returns None if rule not found"] #[doc = r""] @@ -225,6 +254,7 @@ pub fn get_rule_category(rule_name: &str) -> Option<&'static ::pgls_diagnostics: } #[doc = r" Check if a rule requires Supabase roles (anon, authenticated, service_role)"] #[doc = r" Rules that require Supabase should be filtered out if these roles don't exist"] +#[deprecated(note = "Use get_rule_metadata() instead")] pub fn rule_requires_supabase(rule_name: &str) -> bool { match rule_name { "authRlsInitplan" => true, diff --git a/crates/pgls_splinter/src/rule.rs b/crates/pgls_splinter/src/rule.rs index 0b1ef1046..f00a528f2 100644 --- a/crates/pgls_splinter/src/rule.rs +++ b/crates/pgls_splinter/src/rule.rs @@ -10,5 +10,11 @@ use pgls_analyse::RuleMeta; #[doc = r" - Rule logic is in SQL files, not Rust"] pub trait SplinterRule: RuleMeta { #[doc = r" Path to the SQL file containing the rule query"] - fn sql_file_path() -> &'static str; + const SQL_FILE_PATH: &'static str; + #[doc = r" Description of what the rule detects"] + const DESCRIPTION: &'static str; + #[doc = r" URL to documentation/remediation guide"] + const REMEDIATION: &'static str; + #[doc = r" Whether this rule requires Supabase roles (anon, authenticated, service_role)"] + const REQUIRES_SUPABASE: bool; } diff --git a/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs index 463304b69..bd5c4f34c 100644 --- a/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs +++ b/crates/pgls_splinter/src/rules/performance/auth_rls_initplan.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Auth RLS Initialization Plan\n///\n/// Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// with policies as (\n/// select\n/// nsp.nspname as schema_name,\n/// pb.tablename as table_name,\n/// pc.relrowsecurity as is_rls_active,\n/// polname as policy_name,\n/// polpermissive as is_permissive, -- if not, then restrictive\n/// (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles,\n/// case polcmd\n/// when 'r' then 'SELECT'\n/// when 'a' then 'INSERT'\n/// when 'w' then 'UPDATE'\n/// when 'd' then 'DELETE'\n/// when '*' then 'ALL'\n/// end as command,\n/// qual,\n/// with_check\n/// from\n/// pg_catalog.pg_policy pa\n/// join pg_catalog.pg_class pc\n/// on pa.polrelid = pc.oid\n/// join pg_catalog.pg_namespace nsp\n/// on pc.relnamespace = nsp.oid\n/// join pg_catalog.pg_policies pb\n/// on pc.relname = pb.tablename\n/// and nsp.nspname = pb.schemaname\n/// and pa.polname = pb.policyname\n/// )\n/// select\n/// 'auth_rls_initplan' as \"name!\",\n/// 'Auth RLS Initialization Plan' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has a row level security policy \\`%s\\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \\`auth.()\\` with \\`(select auth.())\\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.',\n/// schema_name,\n/// table_name,\n/// policy_name\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', schema_name,\n/// 'name', table_name,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as \"cache_key!\"\n/// from\n/// policies\n/// where\n/// is_rls_active\n/// -- NOTE: does not include realtime in support of monitoring policies on realtime.messages\n/// and schema_name 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', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and (\n/// -- Example: auth.uid()\n/// (\n/// qual like '%auth.uid()%'\n/// and lower(qual) not like '%select auth.uid()%'\n/// )\n/// or (\n/// qual like '%auth.jwt()%'\n/// and lower(qual) not like '%select auth.jwt()%'\n/// )\n/// or (\n/// qual like '%auth.role()%'\n/// and lower(qual) not like '%select auth.role()%'\n/// )\n/// or (\n/// qual like '%auth.email()%'\n/// and lower(qual) not like '%select auth.email()%'\n/// )\n/// or (\n/// qual like '%current\\_setting(%)%'\n/// and lower(qual) not like '%select current\\_setting(%)%'\n/// )\n/// or (\n/// with_check like '%auth.uid()%'\n/// and lower(with_check) not like '%select auth.uid()%'\n/// )\n/// or (\n/// with_check like '%auth.jwt()%'\n/// and lower(with_check) not like '%select auth.jwt()%'\n/// )\n/// or (\n/// with_check like '%auth.role()%'\n/// and lower(with_check) not like '%select auth.role()%'\n/// )\n/// or (\n/// with_check like '%auth.email()%'\n/// and lower(with_check) not like '%select auth.email()%'\n/// )\n/// or (\n/// with_check like '%current\\_setting(%)%'\n/// and lower(with_check) not like '%select current\\_setting(%)%'\n/// )\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"authRlsInitplan\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub AuthRlsInitplan { version : "1.0.0" , name : "authRlsInitplan" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for AuthRlsInitplan { - fn sql_file_path() -> &'static str { - "performance/auth_rls_initplan.sql" - } + const SQL_FILE_PATH: &'static str = "performance/auth_rls_initplan.sql"; + const DESCRIPTION: &'static str = "Detects if calls to \\`current_setting()\\` and \\`auth.()\\` in RLS policies are being unnecessarily re-evaluated for each row"; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/performance/duplicate_index.rs b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs index b89661dc4..841b6a5cb 100644 --- a/crates/pgls_splinter/src/rules/performance/duplicate_index.rs +++ b/crates/pgls_splinter/src/rules/performance/duplicate_index.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Duplicate Index\n///\n/// Detects cases where two ore more identical indexes exist.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'duplicate_index' as \"name!\",\n/// 'Duplicate Index' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects cases where two ore more identical indexes exist.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has identical indexes %s. Drop all except one of them',\n/// n.nspname,\n/// c.relname,\n/// array_agg(pi.indexname order by pi.indexname)\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', case\n/// when c.relkind = 'r' then 'table'\n/// when c.relkind = 'm' then 'materialized view'\n/// else 'ERROR'\n/// end,\n/// 'indexes', array_agg(pi.indexname order by pi.indexname)\n/// ) as \"metadata!\",\n/// format(\n/// 'duplicate_index_%s_%s_%s',\n/// n.nspname,\n/// c.relname,\n/// array_agg(pi.indexname order by pi.indexname)\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_indexes pi\n/// join pg_catalog.pg_namespace n\n/// on n.nspname = pi.schemaname\n/// join pg_catalog.pg_class c\n/// on pi.tablename = c.relname\n/// and n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind in ('r', 'm') -- tables and materialized views\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relkind,\n/// c.relname,\n/// replace(pi.indexdef, pi.indexname, '')\n/// having\n/// count(*) > 1)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"duplicateIndex\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub DuplicateIndex { version : "1.0.0" , name : "duplicateIndex" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for DuplicateIndex { - fn sql_file_path() -> &'static str { - "performance/duplicate_index.sql" - } + const SQL_FILE_PATH: &'static str = "performance/duplicate_index.sql"; + const DESCRIPTION: &'static str = "Detects cases where two ore more identical indexes exist."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs index 15344e31d..227551a04 100644 --- a/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs +++ b/crates/pgls_splinter/src/rules/performance/multiple_permissive_policies.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Multiple Permissive Policies\n///\n/// Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'multiple_permissive_policies' as \"name!\",\n/// 'Multiple Permissive Policies' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has multiple permissive policies for role \\`%s\\` for action \\`%s\\`. Policies include \\`%s\\`',\n/// n.nspname,\n/// c.relname,\n/// r.rolname,\n/// act.cmd,\n/// array_agg(p.polname order by p.polname)\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'multiple_permissive_policies_%s_%s_%s_%s',\n/// n.nspname,\n/// c.relname,\n/// r.rolname,\n/// act.cmd\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_policy p\n/// join pg_catalog.pg_class c\n/// on p.polrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// join pg_catalog.pg_roles r\n/// on p.polroles @> array[r.oid]\n/// or p.polroles = array[0::oid]\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e',\n/// lateral (\n/// select x.cmd\n/// from unnest((\n/// select\n/// case p.polcmd\n/// when 'r' then array['SELECT']\n/// when 'a' then array['INSERT']\n/// when 'w' then array['UPDATE']\n/// when 'd' then array['DELETE']\n/// when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE']\n/// else array['ERROR']\n/// end as actions\n/// )) x(cmd)\n/// ) act(cmd)\n/// where\n/// c.relkind = 'r' -- regular tables\n/// and p.polpermissive -- policy is permissive\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/// and r.rolname not like 'pg_%'\n/// and r.rolname not like 'supabase%admin'\n/// and not r.rolbypassrls\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relname,\n/// r.rolname,\n/// act.cmd\n/// having\n/// count(1) > 1)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"multiplePermissivePolicies\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub MultiplePermissivePolicies { version : "1.0.0" , name : "multiplePermissivePolicies" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for MultiplePermissivePolicies { - fn sql_file_path() -> &'static str { - "performance/multiple_permissive_policies.sql" - } + const SQL_FILE_PATH: &'static str = "performance/multiple_permissive_policies.sql"; + const DESCRIPTION: &'static str = "Detects if multiple permissive row level security policies are present on a table for the same \\`role\\` and \\`action\\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/no_primary_key.rs b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs index 97c6e30d8..d65f9fc80 100644 --- a/crates/pgls_splinter/src/rules/performance/no_primary_key.rs +++ b/crates/pgls_splinter/src/rules/performance/no_primary_key.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # No Primary Key\n///\n/// Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'no_primary_key' as \"name!\",\n/// 'No Primary Key' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` does not have a primary key',\n/// pgns.nspname,\n/// pgc.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', pgns.nspname,\n/// 'name', pgc.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'no_primary_key_%s_%s',\n/// pgns.nspname,\n/// pgc.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class pgc\n/// join pg_catalog.pg_namespace pgns\n/// on pgns.oid = pgc.relnamespace\n/// left join pg_catalog.pg_index pgi\n/// on pgi.indrelid = pgc.oid\n/// left join pg_catalog.pg_depend dep\n/// on pgc.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// pgc.relkind = 'r' -- regular tables\n/// and pgns.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// pgc.oid,\n/// pgns.nspname,\n/// pgc.relname\n/// having\n/// max(coalesce(pgi.indisprimary, false)::int) = 0)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"noPrimaryKey\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub NoPrimaryKey { version : "1.0.0" , name : "noPrimaryKey" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for NoPrimaryKey { - fn sql_file_path() -> &'static str { - "performance/no_primary_key.sql" - } + const SQL_FILE_PATH: &'static str = "performance/no_primary_key.sql"; + const DESCRIPTION: &'static str = "Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/table_bloat.rs b/crates/pgls_splinter/src/rules/performance/table_bloat.rs index 6c30e0483..58e34d3b2 100644 --- a/crates/pgls_splinter/src/rules/performance/table_bloat.rs +++ b/crates/pgls_splinter/src/rules/performance/table_bloat.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Table Bloat\n///\n/// Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// with constants as (\n/// select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma\n/// ),\n/// \n/// bloat_info as (\n/// select\n/// ma,\n/// bs,\n/// schemaname,\n/// tablename,\n/// (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr,\n/// (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2\n/// from (\n/// select\n/// schemaname,\n/// tablename,\n/// hdr,\n/// ma,\n/// bs,\n/// sum((1 - null_frac) * avg_width) as datawidth,\n/// max(null_frac) as maxfracsum,\n/// hdr + (\n/// select 1 + count(*) / 8\n/// from pg_stats s2\n/// where\n/// null_frac <> 0\n/// and s2.schemaname = s.schemaname\n/// and s2.tablename = s.tablename\n/// ) as nullhdr\n/// from pg_stats s, constants\n/// group by 1, 2, 3, 4, 5\n/// ) as foo\n/// ),\n/// \n/// table_bloat as (\n/// select\n/// schemaname,\n/// tablename,\n/// cc.relpages,\n/// bs,\n/// ceil((cc.reltuples * ((datahdr + ma -\n/// (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta\n/// from\n/// bloat_info\n/// join pg_class cc\n/// on cc.relname = bloat_info.tablename\n/// join pg_namespace nn\n/// on cc.relnamespace = nn.oid\n/// and nn.nspname = bloat_info.schemaname\n/// and nn.nspname <> 'information_schema'\n/// where\n/// cc.relkind = 'r'\n/// and cc.relam = (select oid from pg_am where amname = 'heap')\n/// ),\n/// \n/// bloat_data as (\n/// select\n/// 'table' as type,\n/// schemaname,\n/// tablename as object_name,\n/// round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat,\n/// case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste\n/// from\n/// table_bloat\n/// )\n/// \n/// select\n/// 'table_bloat' as \"name!\",\n/// 'Table Bloat' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as \"description!\",\n/// format(\n/// 'Table `%s`.`%s` has excessive bloat',\n/// bloat_data.schemaname,\n/// bloat_data.object_name\n/// ) as \"detail!\",\n/// 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', bloat_data.schemaname,\n/// 'name', bloat_data.object_name,\n/// 'type', bloat_data.type\n/// ) as \"metadata!\",\n/// format(\n/// 'table_bloat_%s_%s',\n/// bloat_data.schemaname,\n/// bloat_data.object_name\n/// ) as \"cache_key!\"\n/// from\n/// bloat_data\n/// where\n/// bloat > 70.0\n/// and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB\n/// order by\n/// schemaname,\n/// object_name)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"tableBloat\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub TableBloat { version : "1.0.0" , name : "tableBloat" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for TableBloat { - fn sql_file_path() -> &'static str { - "performance/table_bloat.sql" - } + const SQL_FILE_PATH: &'static str = "performance/table_bloat.sql"; + const DESCRIPTION: &'static str = "Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster."; + const REMEDIATION: &'static str = "Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat."; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs index 0afe3636e..c4542cb0f 100644 --- a/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs +++ b/crates/pgls_splinter/src/rules/performance/unindexed_foreign_keys.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Unindexed foreign keys\n///\n/// Identifies foreign key constraints without a covering index, which can impact database performance.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// with foreign_keys as (\n/// select\n/// cl.relnamespace::regnamespace::text as schema_name,\n/// cl.relname as table_name,\n/// cl.oid as table_oid,\n/// ct.conname as fkey_name,\n/// ct.conkey as col_attnums\n/// from\n/// pg_catalog.pg_constraint ct\n/// join pg_catalog.pg_class cl -- fkey owning table\n/// on ct.conrelid = cl.oid\n/// left join pg_catalog.pg_depend d\n/// on d.objid = cl.oid\n/// and d.deptype = 'e'\n/// where\n/// ct.contype = 'f' -- foreign key constraints\n/// and d.objid is null -- exclude tables that are dependencies of extensions\n/// and cl.relnamespace::regnamespace::text not in (\n/// 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions'\n/// )\n/// ),\n/// index_ as (\n/// select\n/// pi.indrelid as table_oid,\n/// indexrelid::regclass as index_,\n/// string_to_array(indkey::text, ' ')::smallint[] as col_attnums\n/// from\n/// pg_catalog.pg_index pi\n/// where\n/// indisvalid\n/// )\n/// select\n/// 'unindexed_foreign_keys' as \"name!\",\n/// 'Unindexed foreign keys' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Identifies foreign key constraints without a covering index, which can impact database performance.' as \"description!\",\n/// format(\n/// 'Table `%s.%s` has a foreign key `%s` without a covering index. This can lead to suboptimal query performance.',\n/// fk.schema_name,\n/// fk.table_name,\n/// fk.fkey_name\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', fk.schema_name,\n/// 'name', fk.table_name,\n/// 'type', 'table',\n/// 'fkey_name', fk.fkey_name,\n/// 'fkey_columns', fk.col_attnums\n/// ) as \"metadata!\",\n/// format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as \"cache_key!\"\n/// from\n/// foreign_keys fk\n/// left join index_ idx\n/// on fk.table_oid = idx.table_oid\n/// and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)]\n/// left join pg_catalog.pg_depend dep\n/// on idx.table_oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// idx.index_ is null\n/// and fk.schema_name not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null -- exclude tables owned by extensions\n/// order by\n/// fk.schema_name,\n/// fk.table_name,\n/// fk.fkey_name\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"unindexedForeignKeys\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub UnindexedForeignKeys { version : "1.0.0" , name : "unindexedForeignKeys" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for UnindexedForeignKeys { - fn sql_file_path() -> &'static str { - "performance/unindexed_foreign_keys.sql" - } + const SQL_FILE_PATH: &'static str = "performance/unindexed_foreign_keys.sql"; + const DESCRIPTION: &'static str = "Identifies foreign key constraints without a covering index, which can impact database performance."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/performance/unused_index.rs b/crates/pgls_splinter/src/rules/performance/unused_index.rs index 813bce6d4..3f0c4db67 100644 --- a/crates/pgls_splinter/src/rules/performance/unused_index.rs +++ b/crates/pgls_splinter/src/rules/performance/unused_index.rs @@ -4,7 +4,10 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Unused Index\n///\n/// Detects if an index has never been used and may be a candidate for removal.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'unused_index' as \"name!\",\n/// 'Unused Index' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['PERFORMANCE'] as \"categories!\",\n/// 'Detects if an index has never been used and may be a candidate for removal.' as \"description!\",\n/// format(\n/// 'Index \\`%s\\` on table \\`%s.%s\\` has not been used',\n/// psui.indexrelname,\n/// psui.schemaname,\n/// psui.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', psui.schemaname,\n/// 'name', psui.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'unused_index_%s_%s_%s',\n/// psui.schemaname,\n/// psui.relname,\n/// psui.indexrelname\n/// ) as \"cache_key!\"\n/// \n/// from\n/// pg_catalog.pg_stat_user_indexes psui\n/// join pg_catalog.pg_index pi\n/// on psui.indexrelid = pi.indexrelid\n/// left join pg_catalog.pg_depend dep\n/// on psui.relid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// psui.idx_scan = 0\n/// and not pi.indisunique\n/// and not pi.indisprimary\n/// and dep.objid is null -- exclude tables owned by extensions\n/// and psui.schemaname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"performance\": {\n/// \"unusedIndex\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub UnusedIndex { version : "1.0.0" , name : "unusedIndex" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for UnusedIndex { - fn sql_file_path() -> &'static str { - "performance/unused_index.sql" - } + const SQL_FILE_PATH: &'static str = "performance/unused_index.sql"; + const DESCRIPTION: &'static str = + "Detects if an index has never been used and may be a candidate for removal."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs index d8cee82d7..9f0372fdf 100644 --- a/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs +++ b/crates/pgls_splinter/src/rules/security/auth_users_exposed.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Exposed Auth Users\n///\n/// Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'auth_users_exposed' as \"name!\",\n/// 'Exposed Auth Users' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as \"description!\",\n/// format(\n/// 'View/Materialized View \"%s\" in the public schema may expose \\`auth.users\\` data to anon or authenticated roles.',\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'view',\n/// 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null)\n/// ) as \"metadata!\",\n/// format('auth_users_exposed_%s_%s', n.nspname, c.relname) as \"cache_key!\"\n/// from\n/// -- Identify the oid for auth.users\n/// pg_catalog.pg_class auth_users_pg_class\n/// join pg_catalog.pg_namespace auth_users_pg_namespace\n/// on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid\n/// and auth_users_pg_class.relname = 'users'\n/// and auth_users_pg_namespace.nspname = 'auth'\n/// -- Depends on auth.users\n/// join pg_catalog.pg_depend d\n/// on d.refobjid = auth_users_pg_class.oid\n/// join pg_catalog.pg_rewrite r\n/// on r.oid = d.objid\n/// join pg_catalog.pg_class c\n/// on c.oid = r.ev_class\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// join pg_catalog.pg_class pg_class_auth_users\n/// on d.refobjid = pg_class_auth_users.oid\n/// where\n/// d.deptype = 'n'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// -- Exclude self\n/// and c.relname <> '0002_auth_users_exposed'\n/// -- There are 3 insecure configurations\n/// and\n/// (\n/// -- Materialized views don't support RLS so this is insecure by default\n/// (c.relkind in ('m')) -- m for materialized view\n/// or\n/// -- Standard View, accessible to anon or authenticated that is security_definer\n/// (\n/// c.relkind = 'v' -- v for view\n/// -- Exclude security invoker views\n/// and not (\n/// lower(coalesce(c.reloptions::text,'{}'))::text[]\n/// && array[\n/// 'security_invoker=1',\n/// 'security_invoker=true',\n/// 'security_invoker=yes',\n/// 'security_invoker=on'\n/// ]\n/// )\n/// )\n/// or\n/// -- Standard View, security invoker, but no RLS enabled on auth.users\n/// (\n/// c.relkind in ('v') -- v for view\n/// -- is security invoker\n/// and (\n/// lower(coalesce(c.reloptions::text,'{}'))::text[]\n/// && array[\n/// 'security_invoker=1',\n/// 'security_invoker=true',\n/// 'security_invoker=yes',\n/// 'security_invoker=on'\n/// ]\n/// )\n/// and not pg_class_auth_users.relrowsecurity\n/// )\n/// )\n/// group by\n/// n.nspname,\n/// c.relname,\n/// c.oid)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"authUsersExposed\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub AuthUsersExposed { version : "1.0.0" , name : "authUsersExposed" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for AuthUsersExposed { - fn sql_file_path() -> &'static str { - "security/auth_users_exposed.sql" - } + const SQL_FILE_PATH: &'static str = "security/auth_users_exposed.sql"; + const DESCRIPTION: &'static str = "Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/extension_in_public.rs b/crates/pgls_splinter/src/rules/security/extension_in_public.rs index e69a01a44..02aa1adec 100644 --- a/crates/pgls_splinter/src/rules/security/extension_in_public.rs +++ b/crates/pgls_splinter/src/rules/security/extension_in_public.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Extension in Public\n///\n/// Detects extensions installed in the \\`public\\` schema.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'extension_in_public' as \"name!\",\n/// 'Extension in Public' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects extensions installed in the \\`public\\` schema.' as \"description!\",\n/// format(\n/// 'Extension \\`%s\\` is installed in the public schema. Move it to another schema.',\n/// pe.extname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', pe.extnamespace::regnamespace,\n/// 'name', pe.extname,\n/// 'type', 'extension'\n/// ) as \"metadata!\",\n/// format(\n/// 'extension_in_public_%s',\n/// pe.extname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_extension pe\n/// where\n/// -- plpgsql is installed by default in public and outside user control\n/// -- confirmed safe\n/// pe.extname not in ('plpgsql')\n/// -- Scoping this to public is not optimal. Ideally we would use the postgres\n/// -- search path. That currently isn't available via SQL. In other lints\n/// -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that\n/// -- is not appropriate here as it would evaluate true for the extensions schema\n/// and pe.extnamespace::regnamespace::text = 'public')\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"extensionInPublic\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub ExtensionInPublic { version : "1.0.0" , name : "extensionInPublic" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ExtensionInPublic { - fn sql_file_path() -> &'static str { - "security/extension_in_public.sql" - } + const SQL_FILE_PATH: &'static str = "security/extension_in_public.sql"; + const DESCRIPTION: &'static str = "Detects extensions installed in the \\`public\\` schema."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs index 3cf65de2e..066ba0b9c 100644 --- a/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs +++ b/crates/pgls_splinter/src/rules/security/extension_versions_outdated.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Extension Versions Outdated\n///\n/// Detects extensions that are not using the default (recommended) version.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'extension_versions_outdated' as \"name!\",\n/// 'Extension Versions Outdated' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects extensions that are not using the default (recommended) version.' as \"description!\",\n/// format(\n/// 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.',\n/// ext.name,\n/// ext.installed_version,\n/// ext.default_version\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as \"remediation!\",\n/// jsonb_build_object(\n/// 'extension_name', ext.name,\n/// 'installed_version', ext.installed_version,\n/// 'default_version', ext.default_version\n/// ) as \"metadata!\",\n/// format(\n/// 'extension_versions_outdated_%s_%s',\n/// ext.name,\n/// ext.installed_version\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_available_extensions ext\n/// join\n/// -- ignore versions not in pg_available_extension_versions\n/// -- e.g. residue of pg_upgrade\n/// pg_catalog.pg_available_extension_versions extv\n/// on extv.name = ext.name and extv.installed\n/// where\n/// ext.installed_version is not null\n/// and ext.default_version is not null\n/// and ext.installed_version != ext.default_version\n/// order by\n/// ext.name)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"extensionVersionsOutdated\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub ExtensionVersionsOutdated { version : "1.0.0" , name : "extensionVersionsOutdated" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ExtensionVersionsOutdated { - fn sql_file_path() -> &'static str { - "security/extension_versions_outdated.sql" - } + const SQL_FILE_PATH: &'static str = "security/extension_versions_outdated.sql"; + const DESCRIPTION: &'static str = + "Detects extensions that are not using the default (recommended) version."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs index e84917504..53dd84031 100644 --- a/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs +++ b/crates/pgls_splinter/src/rules/security/fkey_to_auth_unique.rs @@ -4,7 +4,10 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Foreign Key to Auth Unique Constraint\n///\n/// Detects user defined foreign keys to unique constraints in the auth schema.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'fkey_to_auth_unique' as \"name!\",\n/// 'Foreign Key to Auth Unique Constraint' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects user defined foreign keys to unique constraints in the auth schema.' as \"description!\",\n/// format(\n/// 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint',\n/// n.nspname, -- referencing schema\n/// c_rel.relname, -- referencing table\n/// c.conname -- fkey name\n/// ) as \"detail!\",\n/// 'Drop the foreign key constraint that references the auth schema.' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c_rel.relname,\n/// 'foreign_key', c.conname\n/// ) as \"metadata!\",\n/// format(\n/// 'fkey_to_auth_unique_%s_%s_%s',\n/// n.nspname, -- referencing schema\n/// c_rel.relname, -- referencing table\n/// c.conname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_constraint c\n/// join pg_catalog.pg_class c_rel\n/// on c.conrelid = c_rel.oid\n/// join pg_catalog.pg_namespace n\n/// on c_rel.relnamespace = n.oid\n/// join pg_catalog.pg_class ref_rel\n/// on c.confrelid = ref_rel.oid\n/// join pg_catalog.pg_namespace cn\n/// on ref_rel.relnamespace = cn.oid\n/// join pg_catalog.pg_index i\n/// on c.conindid = i.indexrelid\n/// where c.contype = 'f'\n/// and cn.nspname = 'auth'\n/// and i.indisunique\n/// and not i.indisprimary)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"fkeyToAuthUnique\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub FkeyToAuthUnique { version : "1.0.0" , name : "fkeyToAuthUnique" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for FkeyToAuthUnique { - fn sql_file_path() -> &'static str { - "security/fkey_to_auth_unique.sql" - } + const SQL_FILE_PATH: &'static str = "security/fkey_to_auth_unique.sql"; + const DESCRIPTION: &'static str = + "Detects user defined foreign keys to unique constraints in the auth schema."; + const REMEDIATION: &'static str = + "Drop the foreign key constraint that references the auth schema."; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs index 962db4a02..808b07742 100644 --- a/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/foreign_table_in_api.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Foreign Table in API\n///\n/// Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'foreign_table_in_api' as \"name!\",\n/// 'Foreign Table in API' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as \"description!\",\n/// format(\n/// 'Foreign table \\`%s.%s\\` is accessible over APIs',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'foreign table'\n/// ) as \"metadata!\",\n/// format(\n/// 'foreign_table_in_api_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'f'\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/// and dep.objid is null)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"foreignTableInApi\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub ForeignTableInApi { version : "1.0.0" , name : "foreignTableInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for ForeignTableInApi { - fn sql_file_path() -> &'static str { - "security/foreign_table_in_api.sql" - } + const SQL_FILE_PATH: &'static str = "security/foreign_table_in_api.sql"; + const DESCRIPTION: &'static str = "Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs index a33deaf61..6ea976151 100644 --- a/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs +++ b/crates/pgls_splinter/src/rules/security/function_search_path_mutable.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Function Search Path Mutable\n///\n/// Detects functions where the search_path parameter is not set.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'function_search_path_mutable' as \"name!\",\n/// 'Function Search Path Mutable' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects functions where the search_path parameter is not set.' as \"description!\",\n/// format(\n/// 'Function \\`%s.%s\\` has a role mutable search_path',\n/// n.nspname,\n/// p.proname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', p.proname,\n/// 'type', 'function'\n/// ) as \"metadata!\",\n/// format(\n/// 'function_search_path_mutable_%s_%s_%s',\n/// n.nspname,\n/// p.proname,\n/// md5(p.prosrc) -- required when function is polymorphic\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_proc p\n/// join pg_catalog.pg_namespace n\n/// on p.pronamespace = n.oid\n/// left join pg_catalog.pg_depend dep\n/// on p.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// 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/// and dep.objid is null -- exclude functions owned by extensions\n/// -- Search path not set\n/// and not exists (\n/// select 1\n/// from unnest(coalesce(p.proconfig, '{}')) as config\n/// where config like 'search_path=%'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"functionSearchPathMutable\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub FunctionSearchPathMutable { version : "1.0.0" , name : "functionSearchPathMutable" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for FunctionSearchPathMutable { - fn sql_file_path() -> &'static str { - "security/function_search_path_mutable.sql" - } + const SQL_FILE_PATH: &'static str = "security/function_search_path_mutable.sql"; + const DESCRIPTION: &'static str = + "Detects functions where the search_path parameter is not set."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs index c44223426..a739cb138 100644 --- a/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/insecure_queue_exposed_in_api.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Insecure Queue Exposed in API\n///\n/// Detects cases where an insecure Queue is exposed over Data APIs\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'insecure_queue_exposed_in_api' as \"name!\",\n/// 'Insecure Queue Exposed in API' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where an insecure Queue is exposed over Data APIs' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` is public, but RLS has not been enabled.',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'rls_disabled_in_public_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\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 in ('r', 'I') -- regular or partitioned tables\n/// and not c.relrowsecurity -- RLS is disabled\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 = 'pgmq' -- tables in the pgmq schema\n/// and c.relname like 'q_%' -- only queue tables\n/// -- Constant requirements\n/// and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"insecureQueueExposedInApi\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub InsecureQueueExposedInApi { version : "1.0.0" , name : "insecureQueueExposedInApi" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for InsecureQueueExposedInApi { - fn sql_file_path() -> &'static str { - "security/insecure_queue_exposed_in_api.sql" - } + const SQL_FILE_PATH: &'static str = "security/insecure_queue_exposed_in_api.sql"; + const DESCRIPTION: &'static str = + "Detects cases where an insecure Queue is exposed over Data APIs"; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs index c3dd23e9e..b659eafd5 100644 --- a/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs +++ b/crates/pgls_splinter/src/rules/security/materialized_view_in_api.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Materialized View in API\n///\n/// Detects materialized views that are accessible over the Data APIs.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'materialized_view_in_api' as \"name!\",\n/// 'Materialized View in API' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects materialized views that are accessible over the Data APIs.' as \"description!\",\n/// format(\n/// 'Materialized view \\`%s.%s\\` is selectable by anon or authenticated roles',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'materialized view'\n/// ) as \"metadata!\",\n/// format(\n/// 'materialized_view_in_api_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'm'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// and dep.objid is null)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"materializedViewInApi\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub MaterializedViewInApi { version : "1.0.0" , name : "materializedViewInApi" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for MaterializedViewInApi { - fn sql_file_path() -> &'static str { - "security/materialized_view_in_api.sql" - } + const SQL_FILE_PATH: &'static str = "security/materialized_view_in_api.sql"; + const DESCRIPTION: &'static str = + "Detects materialized views that are accessible over the Data APIs."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs index ca5047a71..60cfc98a2 100644 --- a/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs +++ b/crates/pgls_splinter/src/rules/security/policy_exists_rls_disabled.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Policy Exists RLS Disabled\n///\n/// Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'policy_exists_rls_disabled' as \"name!\",\n/// 'Policy Exists RLS Disabled' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has RLS policies but RLS is not enabled on the table. Policies include %s.',\n/// n.nspname,\n/// c.relname,\n/// array_agg(p.polname order by p.polname)\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'policy_exists_rls_disabled_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_policy p\n/// join pg_catalog.pg_class c\n/// on p.polrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'r' -- regular tables\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// -- RLS is disabled\n/// and not c.relrowsecurity\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relname)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"policyExistsRlsDisabled\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub PolicyExistsRlsDisabled { version : "1.0.0" , name : "policyExistsRlsDisabled" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for PolicyExistsRlsDisabled { - fn sql_file_path() -> &'static str { - "security/policy_exists_rls_disabled.sql" - } + const SQL_FILE_PATH: &'static str = "security/policy_exists_rls_disabled.sql"; + const DESCRIPTION: &'static str = "Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs index ba4fca06d..b441f4619 100644 --- a/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs +++ b/crates/pgls_splinter/src/rules/security/rls_disabled_in_public.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # RLS Disabled in Public\n///\n/// Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'rls_disabled_in_public' as \"name!\",\n/// 'RLS Disabled in Public' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` is public, but RLS has not been enabled.',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'rls_disabled_in_public_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\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/// -- RLS is disabled\n/// and not c.relrowsecurity\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"rlsDisabledInPublic\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub RlsDisabledInPublic { version : "1.0.0" , name : "rlsDisabledInPublic" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for RlsDisabledInPublic { - fn sql_file_path() -> &'static str { - "security/rls_disabled_in_public.sql" - } + const SQL_FILE_PATH: &'static str = "security/rls_disabled_in_public.sql"; + const DESCRIPTION: &'static str = "Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST"; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs index 06deface8..1f58b7789 100644 --- a/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs +++ b/crates/pgls_splinter/src/rules/security/rls_enabled_no_policy.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # RLS Enabled No Policy\n///\n/// Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'rls_enabled_no_policy' as \"name!\",\n/// 'RLS Enabled No Policy' as \"title!\",\n/// 'INFO' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has RLS enabled, but no policies exist',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'rls_enabled_no_policy_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// left join pg_catalog.pg_policy p\n/// on p.polrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'r' -- regular tables\n/// and n.nspname not in (\n/// '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n/// )\n/// -- RLS is enabled\n/// and c.relrowsecurity\n/// and p.polname is null\n/// and dep.objid is null -- exclude tables owned by extensions\n/// group by\n/// n.nspname,\n/// c.relname)\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"rlsEnabledNoPolicy\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub RlsEnabledNoPolicy { version : "1.0.0" , name : "rlsEnabledNoPolicy" , severity : pgls_diagnostics :: Severity :: Information , } } impl SplinterRule for RlsEnabledNoPolicy { - fn sql_file_path() -> &'static str { - "security/rls_enabled_no_policy.sql" - } + const SQL_FILE_PATH: &'static str = "security/rls_enabled_no_policy.sql"; + const DESCRIPTION: &'static str = "Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs index 1c9b5d33d..a43c1dcf8 100644 --- a/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs +++ b/crates/pgls_splinter/src/rules/security/rls_references_user_metadata.rs @@ -4,7 +4,8 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # RLS references user metadata\n///\n/// Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// with policies as (\n/// select\n/// nsp.nspname as schema_name,\n/// pb.tablename as table_name,\n/// polname as policy_name,\n/// qual,\n/// with_check\n/// from\n/// pg_catalog.pg_policy pa\n/// join pg_catalog.pg_class pc\n/// on pa.polrelid = pc.oid\n/// join pg_catalog.pg_namespace nsp\n/// on pc.relnamespace = nsp.oid\n/// join pg_catalog.pg_policies pb\n/// on pc.relname = pb.tablename\n/// and nsp.nspname = pb.schemaname\n/// and pa.polname = pb.policyname\n/// )\n/// select\n/// 'rls_references_user_metadata' as \"name!\",\n/// 'RLS references user metadata' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has a row level security policy \\`%s\\` that references Supabase Auth \\`user_metadata\\`. \\`user_metadata\\` is editable by end users and should never be used in a security context.',\n/// schema_name,\n/// table_name,\n/// policy_name\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', schema_name,\n/// 'name', table_name,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as \"cache_key!\"\n/// from\n/// policies\n/// where\n/// schema_name 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/// and (\n/// -- Example: auth.jwt() -> 'user_metadata'\n/// -- False positives are possible, but it isn't practical to string match\n/// -- If false positive rate is too high, this expression can iterate\n/// qual like '%auth.jwt()%user_metadata%'\n/// or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'\n/// or with_check like '%auth.jwt()%user_metadata%'\n/// or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"rlsReferencesUserMetadata\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub RlsReferencesUserMetadata { version : "1.0.0" , name : "rlsReferencesUserMetadata" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for RlsReferencesUserMetadata { - fn sql_file_path() -> &'static str { - "security/rls_references_user_metadata.sql" - } + const SQL_FILE_PATH: &'static str = "security/rls_references_user_metadata.sql"; + const DESCRIPTION: &'static str = "Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy."; + const REMEDIATION: &'static str = "https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/security_definer_view.rs b/crates/pgls_splinter/src/rules/security/security_definer_view.rs index a6752587b..f36b73670 100644 --- a/crates/pgls_splinter/src/rules/security/security_definer_view.rs +++ b/crates/pgls_splinter/src/rules/security/security_definer_view.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Security Definer View\n///\n/// Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user\n/// \n/// **Note:** This rule requires Supabase roles (`anon`, `authenticated`, `service_role`). \n/// It will be automatically skipped if these roles don't exist in your database.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'security_definer_view' as \"name!\",\n/// 'Security Definer View' as \"title!\",\n/// 'ERROR' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as \"description!\",\n/// format(\n/// 'View \\`%s.%s\\` is defined with the SECURITY DEFINER property',\n/// n.nspname,\n/// c.relname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'type', 'view'\n/// ) as \"metadata!\",\n/// format(\n/// 'security_definer_view_%s_%s',\n/// n.nspname,\n/// c.relname\n/// ) as \"cache_key!\"\n/// from\n/// pg_catalog.pg_class c\n/// join pg_catalog.pg_namespace n\n/// on n.oid = c.relnamespace\n/// left join pg_catalog.pg_depend dep\n/// on c.oid = dep.objid\n/// and dep.deptype = 'e'\n/// where\n/// c.relkind = 'v'\n/// and (\n/// pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n/// or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n/// )\n/// and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15\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/// and dep.objid is null -- exclude views owned by extensions\n/// and not (\n/// lower(coalesce(c.reloptions::text,'{}'))::text[]\n/// && array[\n/// 'security_invoker=1',\n/// 'security_invoker=true',\n/// 'security_invoker=yes',\n/// 'security_invoker=on'\n/// ]\n/// ))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"securityDefinerView\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub SecurityDefinerView { version : "1.0.0" , name : "securityDefinerView" , severity : pgls_diagnostics :: Severity :: Error , } } impl SplinterRule for SecurityDefinerView { - fn sql_file_path() -> &'static str { - "security/security_definer_view.sql" - } + const SQL_FILE_PATH: &'static str = "security/security_definer_view.sql"; + const DESCRIPTION: &'static str = "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"; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view"; + const REQUIRES_SUPABASE: bool = true; } diff --git a/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs index 9e6c2ea36..0d1008df1 100644 --- a/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs +++ b/crates/pgls_splinter/src/rules/security/unsupported_reg_types.rs @@ -4,7 +4,9 @@ use crate::rule::SplinterRule; ::pgls_analyse::declare_rule! { # [doc = "/// # Unsupported reg types\n///\n/// Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.\n///\n/// ## SQL Query\n///\n/// ```sql\n/// (\n/// select\n/// 'unsupported_reg_types' as \"name!\",\n/// 'Unsupported reg types' as \"title!\",\n/// 'WARN' as \"level!\",\n/// 'EXTERNAL' as \"facing!\",\n/// array['SECURITY'] as \"categories!\",\n/// 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as \"description!\",\n/// format(\n/// 'Table \\`%s.%s\\` has a column \\`%s\\` with unsupported reg* type \\`%s\\`.',\n/// n.nspname,\n/// c.relname,\n/// a.attname,\n/// t.typname\n/// ) as \"detail!\",\n/// 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as \"remediation!\",\n/// jsonb_build_object(\n/// 'schema', n.nspname,\n/// 'name', c.relname,\n/// 'column', a.attname,\n/// 'type', 'table'\n/// ) as \"metadata!\",\n/// format(\n/// 'unsupported_reg_types_%s_%s_%s',\n/// n.nspname,\n/// c.relname,\n/// a.attname\n/// ) AS cache_key\n/// from\n/// pg_catalog.pg_attribute a\n/// join pg_catalog.pg_class c\n/// on a.attrelid = c.oid\n/// join pg_catalog.pg_namespace n\n/// on c.relnamespace = n.oid\n/// join pg_catalog.pg_type t\n/// on a.atttypid = t.oid\n/// join pg_catalog.pg_namespace tn\n/// on t.typnamespace = tn.oid\n/// where\n/// tn.nspname = 'pg_catalog'\n/// and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure')\n/// and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium'))\n/// ```\n///\n/// ## Configuration\n///\n/// Enable or disable this rule in your configuration:\n///\n/// ```json\n/// {\n/// \"splinter\": {\n/// \"rules\": {\n/// \"security\": {\n/// \"unsupportedRegTypes\": \"warn\"\n/// }\n/// }\n/// }\n/// }\n/// ```\n///\n/// ## Remediation\n///\n/// See: "] pub UnsupportedRegTypes { version : "1.0.0" , name : "unsupportedRegTypes" , severity : pgls_diagnostics :: Severity :: Warning , } } impl SplinterRule for UnsupportedRegTypes { - fn sql_file_path() -> &'static str { - "security/unsupported_reg_types.sql" - } + const SQL_FILE_PATH: &'static str = "security/unsupported_reg_types.sql"; + const DESCRIPTION: &'static str = "Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade."; + const REMEDIATION: &'static str = + "https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types"; + const REQUIRES_SUPABASE: bool = false; } diff --git a/crates/pgls_splinter/vendor/splinter.sql b/crates/pgls_splinter/vendor/splinter.sql deleted file mode 100644 index 7f479898e..000000000 --- a/crates/pgls_splinter/vendor/splinter.sql +++ /dev/null @@ -1,1149 +0,0 @@ - -( -with foreign_keys as ( - select - cl.relnamespace::regnamespace::text as schema_name, - cl.relname as table_name, - cl.oid as table_oid, - ct.conname as fkey_name, - ct.conkey as col_attnums - from - pg_catalog.pg_constraint ct - join pg_catalog.pg_class cl -- fkey owning table - on ct.conrelid = cl.oid - left join pg_catalog.pg_depend d - on d.objid = cl.oid - and d.deptype = 'e' - where - ct.contype = 'f' -- foreign key constraints - and d.objid is null -- exclude tables that are dependencies of extensions - and cl.relnamespace::regnamespace::text not in ( - 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions' - ) -), -index_ as ( - select - pi.indrelid as table_oid, - indexrelid::regclass as index_, - string_to_array(indkey::text, ' ')::smallint[] as col_attnums - from - pg_catalog.pg_index pi - where - indisvalid -) -select - 'unindexed_foreign_keys' as "name!", - 'Unindexed foreign keys' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Identifies foreign key constraints without a covering index, which can impact database performance.' as "description!", - format( - 'Table \`%s.%s\` has a foreign key \`%s\` without a covering index. This can lead to suboptimal query performance.', - fk.schema_name, - fk.table_name, - fk.fkey_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as "remediation!", - jsonb_build_object( - 'schema', fk.schema_name, - 'name', fk.table_name, - 'type', 'table', - 'fkey_name', fk.fkey_name, - 'fkey_columns', fk.col_attnums - ) as "metadata!", - format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as "cache_key!" -from - foreign_keys fk - left join index_ idx - on fk.table_oid = idx.table_oid - and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)] - left join pg_catalog.pg_depend dep - on idx.table_oid = dep.objid - and dep.deptype = 'e' -where - idx.index_ is null - and fk.schema_name 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' - ) - and dep.objid is null -- exclude tables owned by extensions -order by - fk.schema_name, - fk.table_name, - fk.fkey_name) -union all -( -select - 'auth_users_exposed' as "name!", - 'Exposed Auth Users' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as "description!", - format( - 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.', - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'view', - 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null) - ) as "metadata!", - format('auth_users_exposed_%s_%s', n.nspname, c.relname) as "cache_key!" -from - -- Identify the oid for auth.users - pg_catalog.pg_class auth_users_pg_class - join pg_catalog.pg_namespace auth_users_pg_namespace - on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid - and auth_users_pg_class.relname = 'users' - and auth_users_pg_namespace.nspname = 'auth' - -- Depends on auth.users - join pg_catalog.pg_depend d - on d.refobjid = auth_users_pg_class.oid - join pg_catalog.pg_rewrite r - on r.oid = d.objid - join pg_catalog.pg_class c - on c.oid = r.ev_class - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - join pg_catalog.pg_class pg_class_auth_users - on d.refobjid = pg_class_auth_users.oid -where - d.deptype = 'n' - 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'), ','))))) - -- Exclude self - and c.relname <> '0002_auth_users_exposed' - -- There are 3 insecure configurations - and - ( - -- Materialized views don't support RLS so this is insecure by default - (c.relkind in ('m')) -- m for materialized view - or - -- Standard View, accessible to anon or authenticated that is security_definer - ( - c.relkind = 'v' -- v for view - -- Exclude security invoker views - and not ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - ) - ) - or - -- Standard View, security invoker, but no RLS enabled on auth.users - ( - c.relkind in ('v') -- v for view - -- is security invoker - and ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - ) - and not pg_class_auth_users.relrowsecurity - ) - ) -group by - n.nspname, - c.relname, - c.oid) -union all -( -with policies as ( - select - nsp.nspname as schema_name, - pb.tablename as table_name, - pc.relrowsecurity as is_rls_active, - polname as policy_name, - polpermissive as is_permissive, -- if not, then restrictive - (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles, - case polcmd - when 'r' then 'SELECT' - when 'a' then 'INSERT' - when 'w' then 'UPDATE' - when 'd' then 'DELETE' - when '*' then 'ALL' - end as command, - qual, - 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 -) -select - 'auth_rls_initplan' as "name!", - 'Auth RLS Initialization Plan' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row' as "description!", - format( - 'Table \`%s.%s\` has a row level security policy \`%s\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.()\` with \`(select auth.())\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.', - schema_name, - table_name, - policy_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as "remediation!", - jsonb_build_object( - 'schema', schema_name, - 'name', table_name, - 'type', 'table' - ) as "metadata!", - format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" -from - policies -where - is_rls_active - -- NOTE: does not include realtime in support of monitoring policies on realtime.messages - and schema_name 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', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and ( - -- Example: auth.uid() - ( - qual like '%auth.uid()%' - and lower(qual) not like '%select auth.uid()%' - ) - or ( - qual like '%auth.jwt()%' - and lower(qual) not like '%select auth.jwt()%' - ) - or ( - qual like '%auth.role()%' - and lower(qual) not like '%select auth.role()%' - ) - or ( - qual like '%auth.email()%' - and lower(qual) not like '%select auth.email()%' - ) - or ( - qual like '%current\_setting(%)%' - and lower(qual) not like '%select current\_setting(%)%' - ) - or ( - with_check like '%auth.uid()%' - and lower(with_check) not like '%select auth.uid()%' - ) - or ( - with_check like '%auth.jwt()%' - and lower(with_check) not like '%select auth.jwt()%' - ) - or ( - with_check like '%auth.role()%' - and lower(with_check) not like '%select auth.role()%' - ) - or ( - with_check like '%auth.email()%' - and lower(with_check) not like '%select auth.email()%' - ) - or ( - with_check like '%current\_setting(%)%' - and lower(with_check) not like '%select current\_setting(%)%' - ) - )) -union all -( -select - 'no_primary_key' as "name!", - 'No Primary Key' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as "description!", - format( - 'Table \`%s.%s\` does not have a primary key', - pgns.nspname, - pgc.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as "remediation!", - jsonb_build_object( - 'schema', pgns.nspname, - 'name', pgc.relname, - 'type', 'table' - ) as "metadata!", - format( - 'no_primary_key_%s_%s', - pgns.nspname, - pgc.relname - ) as "cache_key!" -from - pg_catalog.pg_class pgc - join pg_catalog.pg_namespace pgns - on pgns.oid = pgc.relnamespace - left join pg_catalog.pg_index pgi - on pgi.indrelid = pgc.oid - left join pg_catalog.pg_depend dep - on pgc.oid = dep.objid - and dep.deptype = 'e' -where - pgc.relkind = 'r' -- regular tables - and pgns.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' - ) - and dep.objid is null -- exclude tables owned by extensions -group by - pgc.oid, - pgns.nspname, - pgc.relname -having - max(coalesce(pgi.indisprimary, false)::int) = 0) -union all -( -select - 'unused_index' as "name!", - 'Unused Index' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if an index has never been used and may be a candidate for removal.' as "description!", - format( - 'Index \`%s\` on table \`%s.%s\` has not been used', - psui.indexrelname, - psui.schemaname, - psui.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as "remediation!", - jsonb_build_object( - 'schema', psui.schemaname, - 'name', psui.relname, - 'type', 'table' - ) as "metadata!", - format( - 'unused_index_%s_%s_%s', - psui.schemaname, - psui.relname, - psui.indexrelname - ) as "cache_key!" - -from - pg_catalog.pg_stat_user_indexes psui - join pg_catalog.pg_index pi - on psui.indexrelid = pi.indexrelid - left join pg_catalog.pg_depend dep - on psui.relid = dep.objid - and dep.deptype = 'e' -where - psui.idx_scan = 0 - and not pi.indisunique - and not pi.indisprimary - and dep.objid is null -- exclude tables owned by extensions - and psui.schemaname 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' - )) -union all -( -select - 'multiple_permissive_policies' as "name!", - 'Multiple Permissive Policies' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as "description!", - format( - 'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`', - n.nspname, - c.relname, - r.rolname, - act.cmd, - array_agg(p.polname order by p.polname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'multiple_permissive_policies_%s_%s_%s_%s', - n.nspname, - c.relname, - r.rolname, - act.cmd - ) as "cache_key!" -from - pg_catalog.pg_policy p - join pg_catalog.pg_class c - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - join pg_catalog.pg_roles r - on p.polroles @> array[r.oid] - or p.polroles = array[0::oid] - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e', - lateral ( - select x.cmd - from unnest(( - select - case p.polcmd - when 'r' then array['SELECT'] - when 'a' then array['INSERT'] - when 'w' then array['UPDATE'] - when 'd' then array['DELETE'] - when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE'] - else array['ERROR'] - end as actions - )) x(cmd) - ) act(cmd) -where - c.relkind = 'r' -- regular tables - and p.polpermissive -- policy is permissive - 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' - ) - and r.rolname not like 'pg_%' - and r.rolname not like 'supabase%admin' - and not r.rolbypassrls - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname, - r.rolname, - act.cmd -having - count(1) > 1) -union all -( -select - 'policy_exists_rls_disabled' as "name!", - 'Policy Exists RLS Disabled' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as "description!", - format( - 'Table \`%s.%s\` has RLS policies but RLS is not enabled on the table. Policies include %s.', - n.nspname, - c.relname, - array_agg(p.polname order by p.polname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'policy_exists_rls_disabled_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_policy p - join pg_catalog.pg_class c - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'r' -- regular tables - 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' - ) - -- RLS is disabled - and not c.relrowsecurity - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname) -union all -( -select - 'rls_enabled_no_policy' as "name!", - 'RLS Enabled No Policy' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as "description!", - format( - 'Table \`%s.%s\` has RLS enabled, but no policies exist', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_enabled_no_policy_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - left join pg_catalog.pg_policy p - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'r' -- regular tables - 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' - ) - -- RLS is enabled - and c.relrowsecurity - and p.polname is null - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname) -union all -( -select - 'duplicate_index' as "name!", - 'Duplicate Index' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects cases where two ore more identical indexes exist.' as "description!", - format( - 'Table \`%s.%s\` has identical indexes %s. Drop all except one of them', - n.nspname, - c.relname, - array_agg(pi.indexname order by pi.indexname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', case - when c.relkind = 'r' then 'table' - when c.relkind = 'm' then 'materialized view' - else 'ERROR' - end, - 'indexes', array_agg(pi.indexname order by pi.indexname) - ) as "metadata!", - format( - 'duplicate_index_%s_%s_%s', - n.nspname, - c.relname, - array_agg(pi.indexname order by pi.indexname) - ) as "cache_key!" -from - pg_catalog.pg_indexes pi - join pg_catalog.pg_namespace n - on n.nspname = pi.schemaname - join pg_catalog.pg_class c - on pi.tablename = c.relname - and n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind in ('r', 'm') -- tables and materialized views - 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' - ) - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relkind, - c.relname, - replace(pi.indexdef, pi.indexname, '') -having - count(*) > 1) -union all -( -select - 'security_definer_view' as "name!", - 'Security Definer View' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as "description!", - format( - 'View \`%s.%s\` is defined with the SECURITY DEFINER property', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'view' - ) as "metadata!", - format( - 'security_definer_view_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'v' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname 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' - ) - and dep.objid is null -- exclude views owned by extensions - and not ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - )) -union all -( -select - 'function_search_path_mutable' as "name!", - 'Function Search Path Mutable' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects functions where the search_path parameter is not set.' as "description!", - format( - 'Function \`%s.%s\` has a role mutable search_path', - n.nspname, - p.proname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', p.proname, - 'type', 'function' - ) as "metadata!", - format( - 'function_search_path_mutable_%s_%s_%s', - n.nspname, - p.proname, - md5(p.prosrc) -- required when function is polymorphic - ) as "cache_key!" -from - pg_catalog.pg_proc p - join pg_catalog.pg_namespace n - on p.pronamespace = n.oid - left join pg_catalog.pg_depend dep - on p.oid = dep.objid - and dep.deptype = 'e' -where - 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' - ) - and dep.objid is null -- exclude functions owned by extensions - -- Search path not set - and not exists ( - select 1 - from unnest(coalesce(p.proconfig, '{}')) as config - where config like 'search_path=%' - )) -union all -( -select - 'rls_disabled_in_public' as "name!", - 'RLS Disabled in Public' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as "description!", - format( - 'Table \`%s.%s\` is public, but RLS has not been enabled.', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_disabled_in_public_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid -where - c.relkind = 'r' -- regular tables - -- RLS is disabled - and not c.relrowsecurity - 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' - )) -union all -( -select - 'extension_in_public' as "name!", - 'Extension in Public' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects extensions installed in the \`public\` schema.' as "description!", - format( - 'Extension \`%s\` is installed in the public schema. Move it to another schema.', - pe.extname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as "remediation!", - jsonb_build_object( - 'schema', pe.extnamespace::regnamespace, - 'name', pe.extname, - 'type', 'extension' - ) as "metadata!", - format( - 'extension_in_public_%s', - pe.extname - ) as "cache_key!" -from - pg_catalog.pg_extension pe -where - -- plpgsql is installed by default in public and outside user control - -- confirmed safe - pe.extname not in ('plpgsql') - -- Scoping this to public is not optimal. Ideally we would use the postgres - -- search path. That currently isn't available via SQL. In other lints - -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that - -- is not appropriate here as it would evaluate true for the extensions schema - and pe.extnamespace::regnamespace::text = 'public') -union all -( -with policies as ( - select - nsp.nspname as schema_name, - pb.tablename as table_name, - polname as policy_name, - qual, - 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 -) -select - 'rls_references_user_metadata' as "name!", - 'RLS references user metadata' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as "description!", - format( - 'Table \`%s.%s\` has a row level security policy \`%s\` that references Supabase Auth \`user_metadata\`. \`user_metadata\` is editable by end users and should never be used in a security context.', - schema_name, - table_name, - policy_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as "remediation!", - jsonb_build_object( - 'schema', schema_name, - 'name', table_name, - 'type', 'table' - ) as "metadata!", - format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" -from - policies -where - schema_name 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' - ) - and ( - -- Example: auth.jwt() -> 'user_metadata' - -- False positives are possible, but it isn't practical to string match - -- If false positive rate is too high, this expression can iterate - qual like '%auth.jwt()%user_metadata%' - or qual like '%current_setting(%request.jwt.claims%)%user_metadata%' - or with_check like '%auth.jwt()%user_metadata%' - or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%' - )) -union all -( -select - 'materialized_view_in_api' as "name!", - 'Materialized View in API' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects materialized views that are accessible over the Data APIs.' as "description!", - format( - 'Materialized view \`%s.%s\` is selectable by anon or authenticated roles', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'materialized view' - ) as "metadata!", - format( - 'materialized_view_in_api_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'm' - 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' - ) - and dep.objid is null) -union all -( -select - 'foreign_table_in_api' as "name!", - 'Foreign Table in API' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as "description!", - format( - 'Foreign table \`%s.%s\` is accessible over APIs', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'foreign table' - ) as "metadata!", - format( - 'foreign_table_in_api_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'f' - 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' - ) - and dep.objid is null) -union all -( -select - 'unsupported_reg_types' as "name!", - 'Unsupported reg types' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as "description!", - format( - 'Table \`%s.%s\` has a column \`%s\` with unsupported reg* type \`%s\`.', - n.nspname, - c.relname, - a.attname, - t.typname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'column', a.attname, - 'type', 'table' - ) as "metadata!", - format( - 'unsupported_reg_types_%s_%s_%s', - n.nspname, - c.relname, - a.attname - ) AS cache_key -from - pg_catalog.pg_attribute a - join pg_catalog.pg_class c - on a.attrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - join pg_catalog.pg_type t - on a.atttypid = t.oid - join pg_catalog.pg_namespace tn - on t.typnamespace = tn.oid -where - tn.nspname = 'pg_catalog' - and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure') - and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium')) -union all -( -select - 'insecure_queue_exposed_in_api' as "name!", - 'Insecure Queue Exposed in API' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where an insecure Queue is exposed over Data APIs' as "description!", - format( - 'Table \`%s.%s\` is public, but RLS has not been enabled.', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_disabled_in_public_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid -where - c.relkind in ('r', 'I') -- regular or partitioned tables - and not c.relrowsecurity -- RLS is disabled - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = 'pgmq' -- tables in the pgmq schema - and c.relname like 'q_%' -- only queue tables - -- Constant requirements - and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))) -union all -( -with constants as ( - select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma -), - -bloat_info as ( - select - ma, - bs, - schemaname, - tablename, - (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr, - (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2 - from ( - select - schemaname, - tablename, - hdr, - ma, - bs, - sum((1 - null_frac) * avg_width) as datawidth, - max(null_frac) as maxfracsum, - hdr + ( - select 1 + count(*) / 8 - from pg_stats s2 - where - null_frac <> 0 - and s2.schemaname = s.schemaname - and s2.tablename = s.tablename - ) as nullhdr - from pg_stats s, constants - group by 1, 2, 3, 4, 5 - ) as foo -), - -table_bloat as ( - select - schemaname, - tablename, - cc.relpages, - bs, - ceil((cc.reltuples * ((datahdr + ma - - (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta - from - bloat_info - join pg_class cc - on cc.relname = bloat_info.tablename - join pg_namespace nn - on cc.relnamespace = nn.oid - and nn.nspname = bloat_info.schemaname - and nn.nspname <> 'information_schema' - where - cc.relkind = 'r' - and cc.relam = (select oid from pg_am where amname = 'heap') -), - -bloat_data as ( - select - 'table' as type, - schemaname, - tablename as object_name, - round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat, - case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste - from - table_bloat -) - -select - 'table_bloat' as "name!", - 'Table Bloat' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as "description!", - format( - 'Table `%s`.`%s` has excessive bloat', - bloat_data.schemaname, - bloat_data.object_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0020_table_bloat' as "remediation!", - jsonb_build_object( - 'schema', bloat_data.schemaname, - 'name', bloat_data.object_name, - 'type', bloat_data.type - ) as "metadata!", - format( - 'table_bloat_%s_%s', - bloat_data.schemaname, - bloat_data.object_name - ) as "cache_key!" -from - bloat_data -where - bloat > 70.0 - and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB -order by - schemaname, - object_name) -union all -( -select - 'fkey_to_auth_unique' as "name!", - 'Foreign Key to Auth Unique Constraint' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects user defined foreign keys to unique constraints in the auth schema.' as "description!", - format( - 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint', - n.nspname, -- referencing schema - c_rel.relname, -- referencing table - c.conname -- fkey name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0021_fkey_to_auth_unique' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c_rel.relname, - 'foreign_key', c.conname - ) as "metadata!", - format( - 'fkey_to_auth_unique_%s_%s_%s', - n.nspname, -- referencing schema - c_rel.relname, -- referencing table - c.conname - ) as "cache_key!" -from - pg_catalog.pg_constraint c - join pg_catalog.pg_class c_rel - on c.conrelid = c_rel.oid - join pg_catalog.pg_namespace n - on c_rel.relnamespace = n.oid - join pg_catalog.pg_class ref_rel - on c.confrelid = ref_rel.oid - join pg_catalog.pg_namespace cn - on ref_rel.relnamespace = cn.oid - join pg_catalog.pg_index i - on c.conindid = i.indexrelid -where c.contype = 'f' - and cn.nspname = 'auth' - and i.indisunique - and not i.indisprimary) -union all -( -select - 'extension_versions_outdated' as "name!", - 'Extension Versions Outdated' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects extensions that are not using the default (recommended) version.' as "description!", - format( - 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.', - ext.name, - ext.installed_version, - ext.default_version - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as "remediation!", - jsonb_build_object( - 'extension_name', ext.name, - 'installed_version', ext.installed_version, - 'default_version', ext.default_version - ) as "metadata!", - format( - 'extension_versions_outdated_%s_%s', - ext.name, - ext.installed_version - ) as "cache_key!" -from - pg_catalog.pg_available_extensions ext -join - -- ignore versions not in pg_available_extension_versions - -- e.g. residue of pg_upgrade - pg_catalog.pg_available_extension_versions extv - on extv.name = ext.name and extv.installed -where - ext.installed_version is not null - and ext.default_version is not null - and ext.installed_version != ext.default_version -order by - ext.name) \ No newline at end of file diff --git a/crates/pgls_splinter/vendor/splinter_generic.sql b/crates/pgls_splinter/vendor/splinter_generic.sql deleted file mode 100644 index 8421701f9..000000000 --- a/crates/pgls_splinter/vendor/splinter_generic.sql +++ /dev/null @@ -1,652 +0,0 @@ - -( -with foreign_keys as ( - select - cl.relnamespace::regnamespace::text as schema_name, - cl.relname as table_name, - cl.oid as table_oid, - ct.conname as fkey_name, - ct.conkey as col_attnums - from - pg_catalog.pg_constraint ct - join pg_catalog.pg_class cl -- fkey owning table - on ct.conrelid = cl.oid - left join pg_catalog.pg_depend d - on d.objid = cl.oid - and d.deptype = 'e' - where - ct.contype = 'f' -- foreign key constraints - and d.objid is null -- exclude tables that are dependencies of extensions - and cl.relnamespace::regnamespace::text not in ( - 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions' - ) -), -index_ as ( - select - pi.indrelid as table_oid, - indexrelid::regclass as index_, - string_to_array(indkey::text, ' ')::smallint[] as col_attnums - from - pg_catalog.pg_index pi - where - indisvalid -) -select - 'unindexed_foreign_keys' as "name!", - 'Unindexed foreign keys' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Identifies foreign key constraints without a covering index, which can impact database performance.' as "description!", - format( - 'Table \`%s.%s\` has a foreign key \`%s\` without a covering index. This can lead to suboptimal query performance.', - fk.schema_name, - fk.table_name, - fk.fkey_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as "remediation!", - jsonb_build_object( - 'schema', fk.schema_name, - 'name', fk.table_name, - 'type', 'table', - 'fkey_name', fk.fkey_name, - 'fkey_columns', fk.col_attnums - ) as "metadata!", - format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as "cache_key!" -from - foreign_keys fk - left join index_ idx - on fk.table_oid = idx.table_oid - and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)] - left join pg_catalog.pg_depend dep - on idx.table_oid = dep.objid - and dep.deptype = 'e' -where - idx.index_ is null - and fk.schema_name 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' - ) - and dep.objid is null -- exclude tables owned by extensions -order by - fk.schema_name, - fk.table_name, - fk.fkey_name) -union all - -( -select - 'no_primary_key' as "name!", - 'No Primary Key' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as "description!", - format( - 'Table \`%s.%s\` does not have a primary key', - pgns.nspname, - pgc.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as "remediation!", - jsonb_build_object( - 'schema', pgns.nspname, - 'name', pgc.relname, - 'type', 'table' - ) as "metadata!", - format( - 'no_primary_key_%s_%s', - pgns.nspname, - pgc.relname - ) as "cache_key!" -from - pg_catalog.pg_class pgc - join pg_catalog.pg_namespace pgns - on pgns.oid = pgc.relnamespace - left join pg_catalog.pg_index pgi - on pgi.indrelid = pgc.oid - left join pg_catalog.pg_depend dep - on pgc.oid = dep.objid - and dep.deptype = 'e' -where - pgc.relkind = 'r' -- regular tables - and pgns.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' - ) - and dep.objid is null -- exclude tables owned by extensions -group by - pgc.oid, - pgns.nspname, - pgc.relname -having - max(coalesce(pgi.indisprimary, false)::int) = 0) -union all - -( -select - 'unused_index' as "name!", - 'Unused Index' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if an index has never been used and may be a candidate for removal.' as "description!", - format( - 'Index \`%s\` on table \`%s.%s\` has not been used', - psui.indexrelname, - psui.schemaname, - psui.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as "remediation!", - jsonb_build_object( - 'schema', psui.schemaname, - 'name', psui.relname, - 'type', 'table' - ) as "metadata!", - format( - 'unused_index_%s_%s_%s', - psui.schemaname, - psui.relname, - psui.indexrelname - ) as "cache_key!" - -from - pg_catalog.pg_stat_user_indexes psui - join pg_catalog.pg_index pi - on psui.indexrelid = pi.indexrelid - left join pg_catalog.pg_depend dep - on psui.relid = dep.objid - and dep.deptype = 'e' -where - psui.idx_scan = 0 - and not pi.indisunique - and not pi.indisprimary - and dep.objid is null -- exclude tables owned by extensions - and psui.schemaname 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' - )) -union all - -( -select - 'multiple_permissive_policies' as "name!", - 'Multiple Permissive Policies' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as "description!", - format( - 'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`', - n.nspname, - c.relname, - r.rolname, - act.cmd, - array_agg(p.polname order by p.polname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'multiple_permissive_policies_%s_%s_%s_%s', - n.nspname, - c.relname, - r.rolname, - act.cmd - ) as "cache_key!" -from - pg_catalog.pg_policy p - join pg_catalog.pg_class c - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - join pg_catalog.pg_roles r - on p.polroles @> array[r.oid] - or p.polroles = array[0::oid] - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e', - lateral ( - select x.cmd - from unnest(( - select - case p.polcmd - when 'r' then array['SELECT'] - when 'a' then array['INSERT'] - when 'w' then array['UPDATE'] - when 'd' then array['DELETE'] - when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE'] - else array['ERROR'] - end as actions - )) x(cmd) - ) act(cmd) -where - c.relkind = 'r' -- regular tables - and p.polpermissive -- policy is permissive - 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' - ) - and r.rolname not like 'pg_%' - and r.rolname not like 'supabase%admin' - and not r.rolbypassrls - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname, - r.rolname, - act.cmd -having - count(1) > 1) -union all - -( -select - 'policy_exists_rls_disabled' as "name!", - 'Policy Exists RLS Disabled' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as "description!", - format( - 'Table \`%s.%s\` has RLS policies but RLS is not enabled on the table. Policies include %s.', - n.nspname, - c.relname, - array_agg(p.polname order by p.polname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'policy_exists_rls_disabled_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_policy p - join pg_catalog.pg_class c - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'r' -- regular tables - 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' - ) - -- RLS is disabled - and not c.relrowsecurity - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname) -union all - -( -select - 'rls_enabled_no_policy' as "name!", - 'RLS Enabled No Policy' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as "description!", - format( - 'Table \`%s.%s\` has RLS enabled, but no policies exist', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_enabled_no_policy_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - left join pg_catalog.pg_policy p - on p.polrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'r' -- regular tables - 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' - ) - -- RLS is enabled - and c.relrowsecurity - and p.polname is null - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relname) -union all - -( -select - 'duplicate_index' as "name!", - 'Duplicate Index' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects cases where two ore more identical indexes exist.' as "description!", - format( - 'Table \`%s.%s\` has identical indexes %s. Drop all except one of them', - n.nspname, - c.relname, - array_agg(pi.indexname order by pi.indexname) - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', case - when c.relkind = 'r' then 'table' - when c.relkind = 'm' then 'materialized view' - else 'ERROR' - end, - 'indexes', array_agg(pi.indexname order by pi.indexname) - ) as "metadata!", - format( - 'duplicate_index_%s_%s_%s', - n.nspname, - c.relname, - array_agg(pi.indexname order by pi.indexname) - ) as "cache_key!" -from - pg_catalog.pg_indexes pi - join pg_catalog.pg_namespace n - on n.nspname = pi.schemaname - join pg_catalog.pg_class c - on pi.tablename = c.relname - and n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind in ('r', 'm') -- tables and materialized views - 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' - ) - and dep.objid is null -- exclude tables owned by extensions -group by - n.nspname, - c.relkind, - c.relname, - replace(pi.indexdef, pi.indexname, '') -having - count(*) > 1) -union all - -( -select - 'function_search_path_mutable' as "name!", - 'Function Search Path Mutable' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects functions where the search_path parameter is not set.' as "description!", - format( - 'Function \`%s.%s\` has a role mutable search_path', - n.nspname, - p.proname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', p.proname, - 'type', 'function' - ) as "metadata!", - format( - 'function_search_path_mutable_%s_%s_%s', - n.nspname, - p.proname, - md5(p.prosrc) -- required when function is polymorphic - ) as "cache_key!" -from - pg_catalog.pg_proc p - join pg_catalog.pg_namespace n - on p.pronamespace = n.oid - left join pg_catalog.pg_depend dep - on p.oid = dep.objid - and dep.deptype = 'e' -where - 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' - ) - and dep.objid is null -- exclude functions owned by extensions - -- Search path not set - and not exists ( - select 1 - from unnest(coalesce(p.proconfig, '{}')) as config - where config like 'search_path=%' - )) -union all - -( -select - 'extension_in_public' as "name!", - 'Extension in Public' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects extensions installed in the \`public\` schema.' as "description!", - format( - 'Extension \`%s\` is installed in the public schema. Move it to another schema.', - pe.extname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as "remediation!", - jsonb_build_object( - 'schema', pe.extnamespace::regnamespace, - 'name', pe.extname, - 'type', 'extension' - ) as "metadata!", - format( - 'extension_in_public_%s', - pe.extname - ) as "cache_key!" -from - pg_catalog.pg_extension pe -where - -- plpgsql is installed by default in public and outside user control - -- confirmed safe - pe.extname not in ('plpgsql') - -- Scoping this to public is not optimal. Ideally we would use the postgres - -- search path. That currently isn't available via SQL. In other lints - -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that - -- is not appropriate here as it would evaluate true for the extensions schema - and pe.extnamespace::regnamespace::text = 'public') -union all - -( -select - 'unsupported_reg_types' as "name!", - 'Unsupported reg types' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as "description!", - format( - 'Table \`%s.%s\` has a column \`%s\` with unsupported reg* type \`%s\`.', - n.nspname, - c.relname, - a.attname, - t.typname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'column', a.attname, - 'type', 'table' - ) as "metadata!", - format( - 'unsupported_reg_types_%s_%s_%s', - n.nspname, - c.relname, - a.attname - ) AS cache_key -from - pg_catalog.pg_attribute a - join pg_catalog.pg_class c - on a.attrelid = c.oid - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - join pg_catalog.pg_type t - on a.atttypid = t.oid - join pg_catalog.pg_namespace tn - on t.typnamespace = tn.oid -where - tn.nspname = 'pg_catalog' - and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure') - and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium')) -union all - -( -with constants as ( - select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma -), - -bloat_info as ( - select - ma, - bs, - schemaname, - tablename, - (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr, - (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2 - from ( - select - schemaname, - tablename, - hdr, - ma, - bs, - sum((1 - null_frac) * avg_width) as datawidth, - max(null_frac) as maxfracsum, - hdr + ( - select 1 + count(*) / 8 - from pg_stats s2 - where - null_frac <> 0 - and s2.schemaname = s.schemaname - and s2.tablename = s.tablename - ) as nullhdr - from pg_stats s, constants - group by 1, 2, 3, 4, 5 - ) as foo -), - -table_bloat as ( - select - schemaname, - tablename, - cc.relpages, - bs, - ceil((cc.reltuples * ((datahdr + ma - - (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta - from - bloat_info - join pg_class cc - on cc.relname = bloat_info.tablename - join pg_namespace nn - on cc.relnamespace = nn.oid - and nn.nspname = bloat_info.schemaname - and nn.nspname <> 'information_schema' - where - cc.relkind = 'r' - and cc.relam = (select oid from pg_am where amname = 'heap') -), - -bloat_data as ( - select - 'table' as type, - schemaname, - tablename as object_name, - round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat, - case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste - from - table_bloat -) - -select - 'table_bloat' as "name!", - 'Table Bloat' as "title!", - 'INFO' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as "description!", - format( - 'Table `%s`.`%s` has excessive bloat', - bloat_data.schemaname, - bloat_data.object_name - ) as "detail!", - 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as "remediation!", - jsonb_build_object( - 'schema', bloat_data.schemaname, - 'name', bloat_data.object_name, - 'type', bloat_data.type - ) as "metadata!", - format( - 'table_bloat_%s_%s', - bloat_data.schemaname, - bloat_data.object_name - ) as "cache_key!" -from - bloat_data -where - bloat > 70.0 - and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB -order by - schemaname, - object_name) -union all - -( -select - 'extension_versions_outdated' as "name!", - 'Extension Versions Outdated' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects extensions that are not using the default (recommended) version.' as "description!", - format( - 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.', - ext.name, - ext.installed_version, - ext.default_version - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as "remediation!", - jsonb_build_object( - 'extension_name', ext.name, - 'installed_version', ext.installed_version, - 'default_version', ext.default_version - ) as "metadata!", - format( - 'extension_versions_outdated_%s_%s', - ext.name, - ext.installed_version - ) as "cache_key!" -from - pg_catalog.pg_available_extensions ext -join - -- ignore versions not in pg_available_extension_versions - -- e.g. residue of pg_upgrade - pg_catalog.pg_available_extension_versions extv - on extv.name = ext.name and extv.installed -where - ext.installed_version is not null - and ext.default_version is not null - and ext.installed_version != ext.default_version -order by - ext.name) \ No newline at end of file diff --git a/crates/pgls_splinter/vendor/splinter_supabase.sql b/crates/pgls_splinter/vendor/splinter_supabase.sql deleted file mode 100644 index 79387c083..000000000 --- a/crates/pgls_splinter/vendor/splinter_supabase.sql +++ /dev/null @@ -1,516 +0,0 @@ - -( -select - 'auth_users_exposed' as "name!", - 'Exposed Auth Users' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as "description!", - format( - 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.', - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'view', - 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null) - ) as "metadata!", - format('auth_users_exposed_%s_%s', n.nspname, c.relname) as "cache_key!" -from - -- Identify the oid for auth.users - pg_catalog.pg_class auth_users_pg_class - join pg_catalog.pg_namespace auth_users_pg_namespace - on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid - and auth_users_pg_class.relname = 'users' - and auth_users_pg_namespace.nspname = 'auth' - -- Depends on auth.users - join pg_catalog.pg_depend d - on d.refobjid = auth_users_pg_class.oid - join pg_catalog.pg_rewrite r - on r.oid = d.objid - join pg_catalog.pg_class c - on c.oid = r.ev_class - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - join pg_catalog.pg_class pg_class_auth_users - on d.refobjid = pg_class_auth_users.oid -where - d.deptype = 'n' - 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'), ','))))) - -- Exclude self - and c.relname <> '0002_auth_users_exposed' - -- There are 3 insecure configurations - and - ( - -- Materialized views don't support RLS so this is insecure by default - (c.relkind in ('m')) -- m for materialized view - or - -- Standard View, accessible to anon or authenticated that is security_definer - ( - c.relkind = 'v' -- v for view - -- Exclude security invoker views - and not ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - ) - ) - or - -- Standard View, security invoker, but no RLS enabled on auth.users - ( - c.relkind in ('v') -- v for view - -- is security invoker - and ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - ) - and not pg_class_auth_users.relrowsecurity - ) - ) -group by - n.nspname, - c.relname, - c.oid) -union all - -( -with policies as ( - select - nsp.nspname as schema_name, - pb.tablename as table_name, - pc.relrowsecurity as is_rls_active, - polname as policy_name, - polpermissive as is_permissive, -- if not, then restrictive - (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles, - case polcmd - when 'r' then 'SELECT' - when 'a' then 'INSERT' - when 'w' then 'UPDATE' - when 'd' then 'DELETE' - when '*' then 'ALL' - end as command, - qual, - 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 -) -select - 'auth_rls_initplan' as "name!", - 'Auth RLS Initialization Plan' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['PERFORMANCE'] as "categories!", - 'Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row' as "description!", - format( - 'Table \`%s.%s\` has a row level security policy \`%s\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.()\` with \`(select auth.())\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.', - schema_name, - table_name, - policy_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as "remediation!", - jsonb_build_object( - 'schema', schema_name, - 'name', table_name, - 'type', 'table' - ) as "metadata!", - format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" -from - policies -where - is_rls_active - -- NOTE: does not include realtime in support of monitoring policies on realtime.messages - and schema_name 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', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' - ) - and ( - -- Example: auth.uid() - ( - qual like '%auth.uid()%' - and lower(qual) not like '%select auth.uid()%' - ) - or ( - qual like '%auth.jwt()%' - and lower(qual) not like '%select auth.jwt()%' - ) - or ( - qual like '%auth.role()%' - and lower(qual) not like '%select auth.role()%' - ) - or ( - qual like '%auth.email()%' - and lower(qual) not like '%select auth.email()%' - ) - or ( - qual like '%current\_setting(%)%' - and lower(qual) not like '%select current\_setting(%)%' - ) - or ( - with_check like '%auth.uid()%' - and lower(with_check) not like '%select auth.uid()%' - ) - or ( - with_check like '%auth.jwt()%' - and lower(with_check) not like '%select auth.jwt()%' - ) - or ( - with_check like '%auth.role()%' - and lower(with_check) not like '%select auth.role()%' - ) - or ( - with_check like '%auth.email()%' - and lower(with_check) not like '%select auth.email()%' - ) - or ( - with_check like '%current\_setting(%)%' - and lower(with_check) not like '%select current\_setting(%)%' - ) - )) -union all - -( -select - 'security_definer_view' as "name!", - 'Security Definer View' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as "description!", - format( - 'View \`%s.%s\` is defined with the SECURITY DEFINER property', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'view' - ) as "metadata!", - format( - 'security_definer_view_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'v' - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) - and n.nspname 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' - ) - and dep.objid is null -- exclude views owned by extensions - and not ( - lower(coalesce(c.reloptions::text,'{}'))::text[] - && array[ - 'security_invoker=1', - 'security_invoker=true', - 'security_invoker=yes', - 'security_invoker=on' - ] - )) -union all - -( -select - 'rls_disabled_in_public' as "name!", - 'RLS Disabled in Public' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as "description!", - format( - 'Table \`%s.%s\` is public, but RLS has not been enabled.', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_disabled_in_public_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid -where - c.relkind = 'r' -- regular tables - -- RLS is disabled - and not c.relrowsecurity - 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' - )) -union all - -( -with policies as ( - select - nsp.nspname as schema_name, - pb.tablename as table_name, - polname as policy_name, - qual, - 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 -) -select - 'rls_references_user_metadata' as "name!", - 'RLS references user metadata' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as "description!", - format( - 'Table \`%s.%s\` has a row level security policy \`%s\` that references Supabase Auth \`user_metadata\`. \`user_metadata\` is editable by end users and should never be used in a security context.', - schema_name, - table_name, - policy_name - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as "remediation!", - jsonb_build_object( - 'schema', schema_name, - 'name', table_name, - 'type', 'table' - ) as "metadata!", - format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" -from - policies -where - schema_name 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' - ) - and ( - -- Example: auth.jwt() -> 'user_metadata' - -- False positives are possible, but it isn't practical to string match - -- If false positive rate is too high, this expression can iterate - qual like '%auth.jwt()%user_metadata%' - or qual like '%current_setting(%request.jwt.claims%)%user_metadata%' - or with_check like '%auth.jwt()%user_metadata%' - or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%' - )) -union all - -( -select - 'materialized_view_in_api' as "name!", - 'Materialized View in API' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects materialized views that are accessible over the Data APIs.' as "description!", - format( - 'Materialized view \`%s.%s\` is selectable by anon or authenticated roles', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'materialized view' - ) as "metadata!", - format( - 'materialized_view_in_api_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'm' - 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' - ) - and dep.objid is null) -union all - -( -select - 'foreign_table_in_api' as "name!", - 'Foreign Table in API' as "title!", - 'WARN' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as "description!", - format( - 'Foreign table \`%s.%s\` is accessible over APIs', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'foreign table' - ) as "metadata!", - format( - 'foreign_table_in_api_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on n.oid = c.relnamespace - left join pg_catalog.pg_depend dep - on c.oid = dep.objid - and dep.deptype = 'e' -where - c.relkind = 'f' - 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' - ) - and dep.objid is null) -union all - -( -select - 'insecure_queue_exposed_in_api' as "name!", - 'Insecure Queue Exposed in API' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects cases where an insecure Queue is exposed over Data APIs' as "description!", - format( - 'Table \`%s.%s\` is public, but RLS has not been enabled.', - n.nspname, - c.relname - ) as "detail!", - 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c.relname, - 'type', 'table' - ) as "metadata!", - format( - 'rls_disabled_in_public_%s_%s', - n.nspname, - c.relname - ) as "cache_key!" -from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid -where - c.relkind in ('r', 'I') -- regular or partitioned tables - and not c.relrowsecurity -- RLS is disabled - and ( - pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') - or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') - ) - and n.nspname = 'pgmq' -- tables in the pgmq schema - and c.relname like 'q_%' -- only queue tables - -- Constant requirements - and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))) -union all - -( -select - 'fkey_to_auth_unique' as "name!", - 'Foreign Key to Auth Unique Constraint' as "title!", - 'ERROR' as "level!", - 'EXTERNAL' as "facing!", - array['SECURITY'] as "categories!", - 'Detects user defined foreign keys to unique constraints in the auth schema.' as "description!", - format( - 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint', - n.nspname, -- referencing schema - c_rel.relname, -- referencing table - c.conname -- fkey name - ) as "detail!", - 'Drop the foreign key constraint that references the auth schema.' as "remediation!", - jsonb_build_object( - 'schema', n.nspname, - 'name', c_rel.relname, - 'foreign_key', c.conname - ) as "metadata!", - format( - 'fkey_to_auth_unique_%s_%s_%s', - n.nspname, -- referencing schema - c_rel.relname, -- referencing table - c.conname - ) as "cache_key!" -from - pg_catalog.pg_constraint c - join pg_catalog.pg_class c_rel - on c.conrelid = c_rel.oid - join pg_catalog.pg_namespace n - on c_rel.relnamespace = n.oid - join pg_catalog.pg_class ref_rel - on c.confrelid = ref_rel.oid - join pg_catalog.pg_namespace cn - on ref_rel.relnamespace = cn.oid - join pg_catalog.pg_index i - on c.conindid = i.indexrelid -where c.contype = 'f' - and cn.nspname = 'auth' - and i.indisunique - and not i.indisprimary) diff --git a/docs/codegen/Cargo.toml b/docs/codegen/Cargo.toml index 0b4def22b..97619dda0 100644 --- a/docs/codegen/Cargo.toml +++ b/docs/codegen/Cargo.toml @@ -25,6 +25,7 @@ pgls_env = { workspace = true } pgls_cli = { workspace = true } pgls_analyse = { workspace = true } pgls_analyser = { workspace = true } +pgls_splinter = { workspace = true } pgls_diagnostics = { workspace = true } pgls_query = { workspace = true } pgls_query_ext = { workspace = true } diff --git a/docs/codegen/src/lib.rs b/docs/codegen/src/lib.rs index 1be6d0df0..8e8773521 100644 --- a/docs/codegen/src/lib.rs +++ b/docs/codegen/src/lib.rs @@ -5,6 +5,7 @@ pub mod rules_docs; pub mod rules_index; pub mod rules_sources; pub mod schema; +pub mod splinter_docs; pub mod version; -mod utils; +pub(crate) mod utils; diff --git a/docs/codegen/src/main.rs b/docs/codegen/src/main.rs index 98a63b310..45dfc8397 100644 --- a/docs/codegen/src/main.rs +++ b/docs/codegen/src/main.rs @@ -8,6 +8,7 @@ use docs_codegen::rules_docs::generate_rules_docs; use docs_codegen::rules_index::generate_rules_index; use docs_codegen::rules_sources::generate_rule_sources; use docs_codegen::schema::generate_schema; +use docs_codegen::splinter_docs::generate_splinter_docs; use docs_codegen::version::replace_version; fn docs_root() -> PathBuf { @@ -23,6 +24,7 @@ fn main() -> anyhow::Result<()> { generate_env_variables(&docs_root)?; generate_cli_doc(&docs_root)?; generate_rules_docs(&docs_root)?; + generate_splinter_docs(&docs_root)?; generate_rules_index(&docs_root)?; generate_rule_sources(&docs_root)?; generate_schema(&docs_root)?; diff --git a/docs/codegen/src/splinter_docs.rs b/docs/codegen/src/splinter_docs.rs new file mode 100644 index 000000000..0e8fea9cd --- /dev/null +++ b/docs/codegen/src/splinter_docs.rs @@ -0,0 +1,134 @@ +use anyhow::Result; +use biome_string_case::Case; +use std::{fs, io::Write as _, path::Path}; + +use crate::utils::SplinterRuleMetadata; + +/// Strip metadata comments from SQL content +/// Removes all lines starting with "-- meta:" +fn strip_metadata_from_sql(sql: &str) -> String { + sql.lines() + .filter(|line| !line.trim().starts_with("-- meta:")) + .collect::>() + .join("\n") + .trim() + .to_string() +} + +/// Generates the documentation page for each splinter rule. +/// +/// * `docs_dir`: Path to the docs directory. +pub fn generate_splinter_docs(docs_dir: &Path) -> anyhow::Result<()> { + let rules_dir = docs_dir.join("reference/rules"); + + // Ensure rules directory exists (created by linter docs generation) + if !rules_dir.exists() { + fs::create_dir_all(&rules_dir)?; + } + + let mut visitor = crate::utils::SplinterRulesVisitor::default(); + pgls_splinter::registry::visit_registry(&mut visitor); + + let crate::utils::SplinterRulesVisitor { groups } = visitor; + + for (group, rules) in groups { + for (rule, metadata) in rules { + let content = generate_splinter_rule_doc(group, rule, metadata)?; + let dashed_rule = Case::Kebab.convert(rule); + fs::write(rules_dir.join(format!("{dashed_rule}.md")), content)?; + } + } + + Ok(()) +} + +fn generate_splinter_rule_doc( + group: &'static str, + rule: &'static str, + splinter_meta: SplinterRuleMetadata, +) -> Result { + let meta = splinter_meta.metadata; + let mut content = Vec::new(); + + writeln!(content, "# {rule}")?; + writeln!(content)?; + + writeln!( + content, + "**Diagnostic Category: `splinter/{group}/{rule}`**" + )?; + writeln!(content)?; + + // Add severity + let severity_str = match meta.severity { + pgls_diagnostics::Severity::Information => "Info", + pgls_diagnostics::Severity::Warning => "Warning", + pgls_diagnostics::Severity::Error => "Error", + _ => "Info", + }; + writeln!(content, "**Severity**: {severity_str}")?; + writeln!(content)?; + + // Add Supabase requirement notice + if splinter_meta.requires_supabase { + writeln!(content, "> [!NOTE]")?; + writeln!( + content, + "> This rule requires a Supabase database/project and will be automatically skipped if not detected." + )?; + writeln!(content)?; + } + + writeln!(content, "## Description")?; + writeln!(content)?; + + // Use description from trait + writeln!(content, "{}", splinter_meta.description)?; + writeln!(content)?; + + // Add "Learn More" link with remediation URL from trait + let remediation = splinter_meta.remediation; + writeln!(content, "[Learn More]({remediation})")?; + writeln!(content)?; + + // Add SQL query section (with metadata stripped) + writeln!(content, "## SQL Query")?; + writeln!(content)?; + writeln!(content, "```sql")?; + let sql_without_metadata = strip_metadata_from_sql(splinter_meta.sql_content); + writeln!(content, "{sql_without_metadata}")?; + writeln!(content, "```")?; + writeln!(content)?; + + // Add configuration section + write_how_to_configure(group, rule, &mut content)?; + + Ok(String::from_utf8(content)?) +} + +fn write_how_to_configure( + group: &'static str, + rule: &'static str, + content: &mut Vec, +) -> std::io::Result<()> { + writeln!(content, "## How to configure")?; + writeln!(content)?; + + let json = format!( + r#"{{ + "splinter": {{ + "rules": {{ + "{group}": {{ + "{rule}": "error" + }} + }} + }} +}}"# + ); + + writeln!(content, "```json")?; + writeln!(content, "{json}")?; + writeln!(content, "```")?; + + Ok(()) +} diff --git a/docs/codegen/src/utils.rs b/docs/codegen/src/utils.rs index 8d53cf909..695969a9e 100644 --- a/docs/codegen/src/utils.rs +++ b/docs/codegen/src/utils.rs @@ -4,6 +4,16 @@ use pgls_analyse::{ use regex::Regex; use std::collections::BTreeMap; +/// Metadata for a splinter rule with SQL content and metadata from trait +#[derive(Clone)] +pub(crate) struct SplinterRuleMetadata { + pub(crate) metadata: RuleMetadata, + pub(crate) sql_content: &'static str, + pub(crate) description: &'static str, + pub(crate) remediation: &'static str, + pub(crate) requires_supabase: bool, +} + pub(crate) fn replace_section( content: &str, section_identifier: &str, @@ -59,3 +69,52 @@ impl RegistryVisitor for LintRulesVisitor { self.push_rule::() } } + +#[derive(Default)] +pub(crate) struct SplinterRulesVisitor { + /// This is mapped to: + /// - group (performance, security) -> list of rules + /// - list or rules is mapped to + /// - rule name -> splinter metadata + pub(crate) groups: BTreeMap<&'static str, BTreeMap<&'static str, SplinterRuleMetadata>>, +} + +impl RegistryVisitor for SplinterRulesVisitor { + fn record_category(&mut self) { + // Splinter uses Lint category (like linter), so we need to accept it + if matches!(C::CATEGORY, RuleCategory::Lint) { + C::record_groups(self); + } + } + + fn record_rule(&mut self) + where + R: RuleMeta + 'static, + { + let group = self + .groups + .entry(::NAME) + .or_default(); + + // Get SQL content and metadata from registry + let sql_content = pgls_splinter::registry::get_sql_content(R::METADATA.name) + .unwrap_or("-- SQL content not found"); + let (description, remediation, requires_supabase) = + pgls_splinter::registry::get_rule_metadata_fields(R::METADATA.name).unwrap_or(( + "Detects potential issues in your database schema.", + "https://supabase.com/docs/guides/database/database-advisors", + false, + )); + + group.insert( + R::METADATA.name, + SplinterRuleMetadata { + metadata: R::METADATA, + sql_content, + description, + remediation, + requires_supabase, + }, + ); + } +} diff --git a/docs/reference/rules/auth-rls-initplan.md b/docs/reference/rules/auth-rls-initplan.md new file mode 100644 index 000000000..17163a8f4 --- /dev/null +++ b/docs/reference/rules/auth-rls-initplan.md @@ -0,0 +1,133 @@ +# authRlsInitplan + +**Diagnostic Category: `splinter/performance/authRlsInitplan`** + +**Severity**: Warning + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan) + +## SQL Query + +```sql +( +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + pc.relrowsecurity as is_rls_active, + polname as policy_name, + polpermissive as is_permissive, -- if not, then restrictive + (select array_agg(r::regrole) from unnest(polroles) as x(r)) as roles, + case polcmd + when 'r' then 'SELECT' + when 'a' then 'INSERT' + when 'w' then 'UPDATE' + when 'd' then 'DELETE' + when '*' then 'ALL' + end as command, + qual, + 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 +) +select + 'auth_rls_initplan' as "name!", + 'Auth RLS Initialization Plan' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row' as "description!", + format( + 'Table \`%s.%s\` has a row level security policy \`%s\` that re-evaluates current_setting() or auth.() for each row. This produces suboptimal query performance at scale. Resolve the issue by replacing \`auth.()\` with \`(select auth.())\`. See [docs](https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select) for more info.', + schema_name, + table_name, + policy_name + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0003_auth_rls_initplan' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table' + ) as "metadata!", + format('auth_rls_init_plan_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" +from + policies +where + is_rls_active + -- NOTE: does not include realtime in support of monitoring policies on realtime.messages + and schema_name 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', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and ( + -- Example: auth.uid() + ( + qual like '%auth.uid()%' + and lower(qual) not like '%select auth.uid()%' + ) + or ( + qual like '%auth.jwt()%' + and lower(qual) not like '%select auth.jwt()%' + ) + or ( + qual like '%auth.role()%' + and lower(qual) not like '%select auth.role()%' + ) + or ( + qual like '%auth.email()%' + and lower(qual) not like '%select auth.email()%' + ) + or ( + qual like '%current\_setting(%)%' + and lower(qual) not like '%select current\_setting(%)%' + ) + or ( + with_check like '%auth.uid()%' + and lower(with_check) not like '%select auth.uid()%' + ) + or ( + with_check like '%auth.jwt()%' + and lower(with_check) not like '%select auth.jwt()%' + ) + or ( + with_check like '%auth.role()%' + and lower(with_check) not like '%select auth.role()%' + ) + or ( + with_check like '%auth.email()%' + and lower(with_check) not like '%select auth.email()%' + ) + or ( + with_check like '%current\_setting(%)%' + and lower(with_check) not like '%select current\_setting(%)%' + ) + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "authRlsInitplan": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/auth-users-exposed.md b/docs/reference/rules/auth-users-exposed.md new file mode 100644 index 000000000..70d8c7160 --- /dev/null +++ b/docs/reference/rules/auth-users-exposed.md @@ -0,0 +1,121 @@ +# authUsersExposed + +**Diagnostic Category: `splinter/security/authUsersExposed`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed) + +## SQL Query + +```sql +( +select + 'auth_users_exposed' as "name!", + 'Exposed Auth Users' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security.' as "description!", + format( + 'View/Materialized View "%s" in the public schema may expose \`auth.users\` data to anon or authenticated roles.', + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0002_auth_users_exposed' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'view', + 'exposed_to', array_remove(array_agg(DISTINCT case when pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') then 'anon' when pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') then 'authenticated' end), null) + ) as "metadata!", + format('auth_users_exposed_%s_%s', n.nspname, c.relname) as "cache_key!" +from + -- Identify the oid for auth.users + pg_catalog.pg_class auth_users_pg_class + join pg_catalog.pg_namespace auth_users_pg_namespace + on auth_users_pg_class.relnamespace = auth_users_pg_namespace.oid + and auth_users_pg_class.relname = 'users' + and auth_users_pg_namespace.nspname = 'auth' + -- Depends on auth.users + join pg_catalog.pg_depend d + on d.refobjid = auth_users_pg_class.oid + join pg_catalog.pg_rewrite r + on r.oid = d.objid + join pg_catalog.pg_class c + on c.oid = r.ev_class + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + join pg_catalog.pg_class pg_class_auth_users + on d.refobjid = pg_class_auth_users.oid +where + d.deptype = 'n' + 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'), ','))))) + -- Exclude self + and c.relname <> '0002_auth_users_exposed' + -- There are 3 insecure configurations + and + ( + -- Materialized views don't support RLS so this is insecure by default + (c.relkind in ('m')) -- m for materialized view + or + -- Standard View, accessible to anon or authenticated that is security_definer + ( + c.relkind = 'v' -- v for view + -- Exclude security invoker views + and not ( + lower(coalesce(c.reloptions::text,'{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + ) + ) + or + -- Standard View, security invoker, but no RLS enabled on auth.users + ( + c.relkind in ('v') -- v for view + -- is security invoker + and ( + lower(coalesce(c.reloptions::text,'{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + ) + and not pg_class_auth_users.relrowsecurity + ) + ) +group by + n.nspname, + c.relname, + c.oid) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "authUsersExposed": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/duplicate-index.md b/docs/reference/rules/duplicate-index.md new file mode 100644 index 000000000..7a89fcdb7 --- /dev/null +++ b/docs/reference/rules/duplicate-index.md @@ -0,0 +1,84 @@ +# duplicateIndex + +**Diagnostic Category: `splinter/performance/duplicateIndex`** + +**Severity**: Warning + +## Description + +Detects cases where two ore more identical indexes exist. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index) + +## SQL Query + +```sql +( +select + 'duplicate_index' as "name!", + 'Duplicate Index' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects cases where two ore more identical indexes exist.' as "description!", + format( + 'Table \`%s.%s\` has identical indexes %s. Drop all except one of them', + n.nspname, + c.relname, + array_agg(pi.indexname order by pi.indexname) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0009_duplicate_index' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', case + when c.relkind = 'r' then 'table' + when c.relkind = 'm' then 'materialized view' + else 'ERROR' + end, + 'indexes', array_agg(pi.indexname order by pi.indexname) + ) as "metadata!", + format( + 'duplicate_index_%s_%s_%s', + n.nspname, + c.relname, + array_agg(pi.indexname order by pi.indexname) + ) as "cache_key!" +from + pg_catalog.pg_indexes pi + join pg_catalog.pg_namespace n + on n.nspname = pi.schemaname + join pg_catalog.pg_class c + on pi.tablename = c.relname + and n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind in ('r', 'm') -- tables and materialized views + 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' + ) + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relkind, + c.relname, + replace(pi.indexdef, pi.indexname, '') +having + count(*) > 1) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "duplicateIndex": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/extension-in-public.md b/docs/reference/rules/extension-in-public.md new file mode 100644 index 000000000..9bd981959 --- /dev/null +++ b/docs/reference/rules/extension-in-public.md @@ -0,0 +1,63 @@ +# extensionInPublic + +**Diagnostic Category: `splinter/security/extensionInPublic`** + +**Severity**: Warning + +## Description + +Detects extensions installed in the \`public\` schema. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public) + +## SQL Query + +```sql +( +select + 'extension_in_public' as "name!", + 'Extension in Public' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects extensions installed in the \`public\` schema.' as "description!", + format( + 'Extension \`%s\` is installed in the public schema. Move it to another schema.', + pe.extname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0014_extension_in_public' as "remediation!", + jsonb_build_object( + 'schema', pe.extnamespace::regnamespace, + 'name', pe.extname, + 'type', 'extension' + ) as "metadata!", + format( + 'extension_in_public_%s', + pe.extname + ) as "cache_key!" +from + pg_catalog.pg_extension pe +where + -- plpgsql is installed by default in public and outside user control + -- confirmed safe + pe.extname not in ('plpgsql') + -- Scoping this to public is not optimal. Ideally we would use the postgres + -- search path. That currently isn't available via SQL. In other lints + -- we have used has_schema_privilege('anon', 'extensions', 'USAGE') but that + -- is not appropriate here as it would evaluate true for the extensions schema + and pe.extnamespace::regnamespace::text = 'public') +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "extensionInPublic": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/extension-versions-outdated.md b/docs/reference/rules/extension-versions-outdated.md new file mode 100644 index 000000000..e6cf12a9b --- /dev/null +++ b/docs/reference/rules/extension-versions-outdated.md @@ -0,0 +1,68 @@ +# extensionVersionsOutdated + +**Diagnostic Category: `splinter/security/extensionVersionsOutdated`** + +**Severity**: Warning + +## Description + +Detects extensions that are not using the default (recommended) version. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated) + +## SQL Query + +```sql +( +select + 'extension_versions_outdated' as "name!", + 'Extension Versions Outdated' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects extensions that are not using the default (recommended) version.' as "description!", + format( + 'Extension `%s` is using version `%s` but version `%s` is available. Using outdated extension versions may expose the database to security vulnerabilities.', + ext.name, + ext.installed_version, + ext.default_version + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0022_extension_versions_outdated' as "remediation!", + jsonb_build_object( + 'extension_name', ext.name, + 'installed_version', ext.installed_version, + 'default_version', ext.default_version + ) as "metadata!", + format( + 'extension_versions_outdated_%s_%s', + ext.name, + ext.installed_version + ) as "cache_key!" +from + pg_catalog.pg_available_extensions ext +join + -- ignore versions not in pg_available_extension_versions + -- e.g. residue of pg_upgrade + pg_catalog.pg_available_extension_versions extv + on extv.name = ext.name and extv.installed +where + ext.installed_version is not null + and ext.default_version is not null + and ext.installed_version != ext.default_version +order by + ext.name) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "extensionVersionsOutdated": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/fkey-to-auth-unique.md b/docs/reference/rules/fkey-to-auth-unique.md new file mode 100644 index 000000000..5151d07c2 --- /dev/null +++ b/docs/reference/rules/fkey-to-auth-unique.md @@ -0,0 +1,75 @@ +# fkeyToAuthUnique + +**Diagnostic Category: `splinter/security/fkeyToAuthUnique`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects user defined foreign keys to unique constraints in the auth schema. + +[Learn More](Drop the foreign key constraint that references the auth schema.) + +## SQL Query + +```sql +( +select + 'fkey_to_auth_unique' as "name!", + 'Foreign Key to Auth Unique Constraint' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects user defined foreign keys to unique constraints in the auth schema.' as "description!", + format( + 'Table `%s`.`%s` has a foreign key `%s` referencing an auth unique constraint', + n.nspname, -- referencing schema + c_rel.relname, -- referencing table + c.conname -- fkey name + ) as "detail!", + 'Drop the foreign key constraint that references the auth schema.' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c_rel.relname, + 'foreign_key', c.conname + ) as "metadata!", + format( + 'fkey_to_auth_unique_%s_%s_%s', + n.nspname, -- referencing schema + c_rel.relname, -- referencing table + c.conname + ) as "cache_key!" +from + pg_catalog.pg_constraint c + join pg_catalog.pg_class c_rel + on c.conrelid = c_rel.oid + join pg_catalog.pg_namespace n + on c_rel.relnamespace = n.oid + join pg_catalog.pg_class ref_rel + on c.confrelid = ref_rel.oid + join pg_catalog.pg_namespace cn + on ref_rel.relnamespace = cn.oid + join pg_catalog.pg_index i + on c.conindid = i.indexrelid +where c.contype = 'f' + and cn.nspname = 'auth' + and i.indisunique + and not i.indisprimary) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "fkeyToAuthUnique": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/foreign-table-in-api.md b/docs/reference/rules/foreign-table-in-api.md new file mode 100644 index 000000000..35a77cb03 --- /dev/null +++ b/docs/reference/rules/foreign-table-in-api.md @@ -0,0 +1,75 @@ +# foreignTableInApi + +**Diagnostic Category: `splinter/security/foreignTableInApi`** + +**Severity**: Warning + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api) + +## SQL Query + +```sql +( +select + 'foreign_table_in_api' as "name!", + 'Foreign Table in API' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as "description!", + format( + 'Foreign table \`%s.%s\` is accessible over APIs', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'foreign table' + ) as "metadata!", + format( + 'foreign_table_in_api_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'f' + 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' + ) + and dep.objid is null) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "foreignTableInApi": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/function-search-path-mutable.md b/docs/reference/rules/function-search-path-mutable.md new file mode 100644 index 000000000..c9e1f72a1 --- /dev/null +++ b/docs/reference/rules/function-search-path-mutable.md @@ -0,0 +1,73 @@ +# functionSearchPathMutable + +**Diagnostic Category: `splinter/security/functionSearchPathMutable`** + +**Severity**: Warning + +## Description + +Detects functions where the search_path parameter is not set. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable) + +## SQL Query + +```sql +( +select + 'function_search_path_mutable' as "name!", + 'Function Search Path Mutable' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects functions where the search_path parameter is not set.' as "description!", + format( + 'Function \`%s.%s\` has a role mutable search_path', + n.nspname, + p.proname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0011_function_search_path_mutable' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', p.proname, + 'type', 'function' + ) as "metadata!", + format( + 'function_search_path_mutable_%s_%s_%s', + n.nspname, + p.proname, + md5(p.prosrc) -- required when function is polymorphic + ) as "cache_key!" +from + pg_catalog.pg_proc p + join pg_catalog.pg_namespace n + on p.pronamespace = n.oid + left join pg_catalog.pg_depend dep + on p.oid = dep.objid + and dep.deptype = 'e' +where + 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' + ) + and dep.objid is null -- exclude functions owned by extensions + -- Search path not set + and not exists ( + select 1 + from unnest(coalesce(p.proconfig, '{}')) as config + where config like 'search_path=%' + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "functionSearchPathMutable": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/insecure-queue-exposed-in-api.md b/docs/reference/rules/insecure-queue-exposed-in-api.md new file mode 100644 index 000000000..a8fe64534 --- /dev/null +++ b/docs/reference/rules/insecure-queue-exposed-in-api.md @@ -0,0 +1,72 @@ +# insecureQueueExposedInApi + +**Diagnostic Category: `splinter/security/insecureQueueExposedInApi`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects cases where an insecure Queue is exposed over Data APIs + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api) + +## SQL Query + +```sql +( +select + 'insecure_queue_exposed_in_api' as "name!", + 'Insecure Queue Exposed in API' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where an insecure Queue is exposed over Data APIs' as "description!", + format( + 'Table \`%s.%s\` is public, but RLS has not been enabled.', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'rls_disabled_in_public_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid +where + c.relkind in ('r', 'I') -- regular or partitioned tables + and not c.relrowsecurity -- RLS is disabled + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = 'pgmq' -- tables in the pgmq schema + and c.relname like 'q_%' -- only queue tables + -- Constant requirements + and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "insecureQueueExposedInApi": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/materialized-view-in-api.md b/docs/reference/rules/materialized-view-in-api.md new file mode 100644 index 000000000..e118218aa --- /dev/null +++ b/docs/reference/rules/materialized-view-in-api.md @@ -0,0 +1,75 @@ +# materializedViewInApi + +**Diagnostic Category: `splinter/security/materializedViewInApi`** + +**Severity**: Warning + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects materialized views that are accessible over the Data APIs. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api) + +## SQL Query + +```sql +( +select + 'materialized_view_in_api' as "name!", + 'Materialized View in API' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects materialized views that are accessible over the Data APIs.' as "description!", + format( + 'Materialized view \`%s.%s\` is selectable by anon or authenticated roles', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'materialized view' + ) as "metadata!", + format( + 'materialized_view_in_api_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'm' + 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' + ) + and dep.objid is null) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "materializedViewInApi": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/multiple-permissive-policies.md b/docs/reference/rules/multiple-permissive-policies.md new file mode 100644 index 000000000..cbcf37650 --- /dev/null +++ b/docs/reference/rules/multiple-permissive-policies.md @@ -0,0 +1,102 @@ +# multiplePermissivePolicies + +**Diagnostic Category: `splinter/performance/multiplePermissivePolicies`** + +**Severity**: Warning + +## Description + +Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies) + +## SQL Query + +```sql +( +select + 'multiple_permissive_policies' as "name!", + 'Multiple Permissive Policies' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query.' as "description!", + format( + 'Table \`%s.%s\` has multiple permissive policies for role \`%s\` for action \`%s\`. Policies include \`%s\`', + n.nspname, + c.relname, + r.rolname, + act.cmd, + array_agg(p.polname order by p.polname) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0006_multiple_permissive_policies' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'multiple_permissive_policies_%s_%s_%s_%s', + n.nspname, + c.relname, + r.rolname, + act.cmd + ) as "cache_key!" +from + pg_catalog.pg_policy p + join pg_catalog.pg_class c + on p.polrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + join pg_catalog.pg_roles r + on p.polroles @> array[r.oid] + or p.polroles = array[0::oid] + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e', + lateral ( + select x.cmd + from unnest(( + select + case p.polcmd + when 'r' then array['SELECT'] + when 'a' then array['INSERT'] + when 'w' then array['UPDATE'] + when 'd' then array['DELETE'] + when '*' then array['SELECT', 'INSERT', 'UPDATE', 'DELETE'] + else array['ERROR'] + end as actions + )) x(cmd) + ) act(cmd) +where + c.relkind = 'r' -- regular tables + and p.polpermissive -- policy is permissive + 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' + ) + and r.rolname not like 'pg_%' + and r.rolname not like 'supabase%admin' + and not r.rolbypassrls + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relname, + r.rolname, + act.cmd +having + count(1) > 1) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "multiplePermissivePolicies": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/no-primary-key.md b/docs/reference/rules/no-primary-key.md new file mode 100644 index 000000000..70042250b --- /dev/null +++ b/docs/reference/rules/no-primary-key.md @@ -0,0 +1,75 @@ +# noPrimaryKey + +**Diagnostic Category: `splinter/performance/noPrimaryKey`** + +**Severity**: Info + +## Description + +Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key) + +## SQL Query + +```sql +( +select + 'no_primary_key' as "name!", + 'No Primary Key' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale.' as "description!", + format( + 'Table \`%s.%s\` does not have a primary key', + pgns.nspname, + pgc.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as "remediation!", + jsonb_build_object( + 'schema', pgns.nspname, + 'name', pgc.relname, + 'type', 'table' + ) as "metadata!", + format( + 'no_primary_key_%s_%s', + pgns.nspname, + pgc.relname + ) as "cache_key!" +from + pg_catalog.pg_class pgc + join pg_catalog.pg_namespace pgns + on pgns.oid = pgc.relnamespace + left join pg_catalog.pg_index pgi + on pgi.indrelid = pgc.oid + left join pg_catalog.pg_depend dep + on pgc.oid = dep.objid + and dep.deptype = 'e' +where + pgc.relkind = 'r' -- regular tables + and pgns.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' + ) + and dep.objid is null -- exclude tables owned by extensions +group by + pgc.oid, + pgns.nspname, + pgc.relname +having + max(coalesce(pgi.indisprimary, false)::int) = 0) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "noPrimaryKey": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/policy-exists-rls-disabled.md b/docs/reference/rules/policy-exists-rls-disabled.md new file mode 100644 index 000000000..e4f46c702 --- /dev/null +++ b/docs/reference/rules/policy-exists-rls-disabled.md @@ -0,0 +1,75 @@ +# policyExistsRlsDisabled + +**Diagnostic Category: `splinter/security/policyExistsRlsDisabled`** + +**Severity**: Error + +## Description + +Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled) + +## SQL Query + +```sql +( +select + 'policy_exists_rls_disabled' as "name!", + 'Policy Exists RLS Disabled' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table.' as "description!", + format( + 'Table \`%s.%s\` has RLS policies but RLS is not enabled on the table. Policies include %s.', + n.nspname, + c.relname, + array_agg(p.polname order by p.polname) + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0007_policy_exists_rls_disabled' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'policy_exists_rls_disabled_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_policy p + join pg_catalog.pg_class c + on p.polrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'r' -- regular tables + 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' + ) + -- RLS is disabled + and not c.relrowsecurity + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relname) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "policyExistsRlsDisabled": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/rls-disabled-in-public.md b/docs/reference/rules/rls-disabled-in-public.md new file mode 100644 index 000000000..339bace74 --- /dev/null +++ b/docs/reference/rules/rls-disabled-in-public.md @@ -0,0 +1,73 @@ +# rlsDisabledInPublic + +**Diagnostic Category: `splinter/security/rlsDisabledInPublic`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public) + +## SQL Query + +```sql +( +select + 'rls_disabled_in_public' as "name!", + 'RLS Disabled in Public' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST' as "description!", + format( + 'Table \`%s.%s\` is public, but RLS has not been enabled.', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'rls_disabled_in_public_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid +where + c.relkind = 'r' -- regular tables + -- RLS is disabled + and not c.relrowsecurity + 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' + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "rlsDisabledInPublic": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/rls-enabled-no-policy.md b/docs/reference/rules/rls-enabled-no-policy.md new file mode 100644 index 000000000..644f874b3 --- /dev/null +++ b/docs/reference/rules/rls-enabled-no-policy.md @@ -0,0 +1,75 @@ +# rlsEnabledNoPolicy + +**Diagnostic Category: `splinter/security/rlsEnabledNoPolicy`** + +**Severity**: Info + +## Description + +Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy) + +## SQL Query + +```sql +( +select + 'rls_enabled_no_policy' as "name!", + 'RLS Enabled No Policy' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created.' as "description!", + format( + 'Table \`%s.%s\` has RLS enabled, but no policies exist', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0008_rls_enabled_no_policy' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as "metadata!", + format( + 'rls_enabled_no_policy_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + left join pg_catalog.pg_policy p + on p.polrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'r' -- regular tables + 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' + ) + -- RLS is enabled + and c.relrowsecurity + and p.polname is null + and dep.objid is null -- exclude tables owned by extensions +group by + n.nspname, + c.relname) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "rlsEnabledNoPolicy": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/rls-references-user-metadata.md b/docs/reference/rules/rls-references-user-metadata.md new file mode 100644 index 000000000..955c68512 --- /dev/null +++ b/docs/reference/rules/rls-references-user-metadata.md @@ -0,0 +1,87 @@ +# rlsReferencesUserMetadata + +**Diagnostic Category: `splinter/security/rlsReferencesUserMetadata`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata) + +## SQL Query + +```sql +( +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + polname as policy_name, + qual, + 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 +) +select + 'rls_references_user_metadata' as "name!", + 'RLS references user metadata' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as "description!", + format( + 'Table \`%s.%s\` has a row level security policy \`%s\` that references Supabase Auth \`user_metadata\`. \`user_metadata\` is editable by end users and should never be used in a security context.', + schema_name, + table_name, + policy_name + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as "remediation!", + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table' + ) as "metadata!", + format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as "cache_key!" +from + policies +where + schema_name 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' + ) + and ( + -- Example: auth.jwt() -> 'user_metadata' + -- False positives are possible, but it isn't practical to string match + -- If false positive rate is too high, this expression can iterate + qual like '%auth.jwt()%user_metadata%' + or qual like '%current_setting(%request.jwt.claims%)%user_metadata%' + or with_check like '%auth.jwt()%user_metadata%' + or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%' + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "rlsReferencesUserMetadata": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/security-definer-view.md b/docs/reference/rules/security-definer-view.md new file mode 100644 index 000000000..8dde33a3e --- /dev/null +++ b/docs/reference/rules/security-definer-view.md @@ -0,0 +1,85 @@ +# securityDefinerView + +**Diagnostic Category: `splinter/security/securityDefinerView`** + +**Severity**: Error + +> [!NOTE] +> This rule requires a Supabase database/project and will be automatically skipped if not detected. + +## Description + +Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view) + +## SQL Query + +```sql +( +select + 'security_definer_view' as "name!", + 'Security Definer View' as "title!", + 'ERROR' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user' as "description!", + format( + 'View \`%s.%s\` is defined with the SECURITY DEFINER property', + n.nspname, + c.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'view' + ) as "metadata!", + format( + 'security_definer_view_%s_%s', + n.nspname, + c.relname + ) as "cache_key!" +from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on n.oid = c.relnamespace + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' +where + c.relkind = 'v' + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname 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' + ) + and dep.objid is null -- exclude views owned by extensions + and not ( + lower(coalesce(c.reloptions::text,'{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "securityDefinerView": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/table-bloat.md b/docs/reference/rules/table-bloat.md new file mode 100644 index 000000000..808410b7a --- /dev/null +++ b/docs/reference/rules/table-bloat.md @@ -0,0 +1,128 @@ +# tableBloat + +**Diagnostic Category: `splinter/performance/tableBloat`** + +**Severity**: Info + +## Description + +Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster. + +[Learn More](Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.) + +## SQL Query + +```sql +( +with constants as ( + select current_setting('block_size')::numeric as bs, 23 as hdr, 4 as ma +), + +bloat_info as ( + select + ma, + bs, + schemaname, + tablename, + (datawidth + (hdr + ma - (case when hdr % ma = 0 then ma else hdr % ma end)))::numeric as datahdr, + (maxfracsum * (nullhdr + ma - (case when nullhdr % ma = 0 then ma else nullhdr % ma end))) as nullhdr2 + from ( + select + schemaname, + tablename, + hdr, + ma, + bs, + sum((1 - null_frac) * avg_width) as datawidth, + max(null_frac) as maxfracsum, + hdr + ( + select 1 + count(*) / 8 + from pg_stats s2 + where + null_frac <> 0 + and s2.schemaname = s.schemaname + and s2.tablename = s.tablename + ) as nullhdr + from pg_stats s, constants + group by 1, 2, 3, 4, 5 + ) as foo +), + +table_bloat as ( + select + schemaname, + tablename, + cc.relpages, + bs, + ceil((cc.reltuples * ((datahdr + ma - + (case when datahdr % ma = 0 then ma else datahdr % ma end)) + nullhdr2 + 4)) / (bs - 20::float)) as otta + from + bloat_info + join pg_class cc + on cc.relname = bloat_info.tablename + join pg_namespace nn + on cc.relnamespace = nn.oid + and nn.nspname = bloat_info.schemaname + and nn.nspname <> 'information_schema' + where + cc.relkind = 'r' + and cc.relam = (select oid from pg_am where amname = 'heap') +), + +bloat_data as ( + select + 'table' as type, + schemaname, + tablename as object_name, + round(case when otta = 0 then 0.0 else table_bloat.relpages / otta::numeric end, 1) as bloat, + case when relpages < otta then 0 else (bs * (table_bloat.relpages - otta)::bigint)::bigint end as raw_waste + from + table_bloat +) + +select + 'table_bloat' as "name!", + 'Table Bloat' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster.' as "description!", + format( + 'Table `%s`.`%s` has excessive bloat', + bloat_data.schemaname, + bloat_data.object_name + ) as "detail!", + 'Consider running vacuum full (WARNING: incurs downtime) and tweaking autovacuum settings to reduce bloat.' as "remediation!", + jsonb_build_object( + 'schema', bloat_data.schemaname, + 'name', bloat_data.object_name, + 'type', bloat_data.type + ) as "metadata!", + format( + 'table_bloat_%s_%s', + bloat_data.schemaname, + bloat_data.object_name + ) as "cache_key!" +from + bloat_data +where + bloat > 70.0 + and raw_waste > (20 * 1024 * 1024) -- filter for waste > 200 MB +order by + schemaname, + object_name) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "tableBloat": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/unindexed-foreign-keys.md b/docs/reference/rules/unindexed-foreign-keys.md new file mode 100644 index 000000000..2a978ec65 --- /dev/null +++ b/docs/reference/rules/unindexed-foreign-keys.md @@ -0,0 +1,101 @@ +# unindexedForeignKeys + +**Diagnostic Category: `splinter/performance/unindexedForeignKeys`** + +**Severity**: Info + +## Description + +Identifies foreign key constraints without a covering index, which can impact database performance. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys) + +## SQL Query + +```sql +with foreign_keys as ( + select + cl.relnamespace::regnamespace::text as schema_name, + cl.relname as table_name, + cl.oid as table_oid, + ct.conname as fkey_name, + ct.conkey as col_attnums + from + pg_catalog.pg_constraint ct + join pg_catalog.pg_class cl -- fkey owning table + on ct.conrelid = cl.oid + left join pg_catalog.pg_depend d + on d.objid = cl.oid + and d.deptype = 'e' + where + ct.contype = 'f' -- foreign key constraints + and d.objid is null -- exclude tables that are dependencies of extensions + and cl.relnamespace::regnamespace::text not in ( + 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'extensions' + ) +), +index_ as ( + select + pi.indrelid as table_oid, + indexrelid::regclass as index_, + string_to_array(indkey::text, ' ')::smallint[] as col_attnums + from + pg_catalog.pg_index pi + where + indisvalid +) +select + 'unindexed_foreign_keys' as "name!", + 'Unindexed foreign keys' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Identifies foreign key constraints without a covering index, which can impact database performance.' as "description!", + format( + 'Table `%s.%s` has a foreign key `%s` without a covering index. This can lead to suboptimal query performance.', + fk.schema_name, + fk.table_name, + fk.fkey_name + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as "remediation!", + jsonb_build_object( + 'schema', fk.schema_name, + 'name', fk.table_name, + 'type', 'table', + 'fkey_name', fk.fkey_name, + 'fkey_columns', fk.col_attnums + ) as "metadata!", + format('unindexed_foreign_keys_%s_%s_%s', fk.schema_name, fk.table_name, fk.fkey_name) as "cache_key!" +from + foreign_keys fk + left join index_ idx + on fk.table_oid = idx.table_oid + and fk.col_attnums = idx.col_attnums[1:array_length(fk.col_attnums, 1)] + left join pg_catalog.pg_depend dep + on idx.table_oid = dep.objid + and dep.deptype = 'e' +where + idx.index_ is null + and fk.schema_name 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' + ) + and dep.objid is null -- exclude tables owned by extensions +order by + fk.schema_name, + fk.table_name, + fk.fkey_name +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "unindexedForeignKeys": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/unsupported-reg-types.md b/docs/reference/rules/unsupported-reg-types.md new file mode 100644 index 000000000..6922bd008 --- /dev/null +++ b/docs/reference/rules/unsupported-reg-types.md @@ -0,0 +1,72 @@ +# unsupportedRegTypes + +**Diagnostic Category: `splinter/security/unsupportedRegTypes`** + +**Severity**: Warning + +## Description + +Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types) + +## SQL Query + +```sql +( +select + 'unsupported_reg_types' as "name!", + 'Unsupported reg types' as "title!", + 'WARN' as "level!", + 'EXTERNAL' as "facing!", + array['SECURITY'] as "categories!", + 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as "description!", + format( + 'Table \`%s.%s\` has a column \`%s\` with unsupported reg* type \`%s\`.', + n.nspname, + c.relname, + a.attname, + t.typname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as "remediation!", + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'column', a.attname, + 'type', 'table' + ) as "metadata!", + format( + 'unsupported_reg_types_%s_%s_%s', + n.nspname, + c.relname, + a.attname + ) AS cache_key +from + pg_catalog.pg_attribute a + join pg_catalog.pg_class c + on a.attrelid = c.oid + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + join pg_catalog.pg_type t + on a.atttypid = t.oid + join pg_catalog.pg_namespace tn + on t.typnamespace = tn.oid +where + tn.nspname = 'pg_catalog' + and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure') + and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium')) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "security": { + "unsupportedRegTypes": "error" + } + } + } +} +``` diff --git a/docs/reference/rules/unused-index.md b/docs/reference/rules/unused-index.md new file mode 100644 index 000000000..e0f5d1d83 --- /dev/null +++ b/docs/reference/rules/unused-index.md @@ -0,0 +1,72 @@ +# unusedIndex + +**Diagnostic Category: `splinter/performance/unusedIndex`** + +**Severity**: Info + +## Description + +Detects if an index has never been used and may be a candidate for removal. + +[Learn More](https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index) + +## SQL Query + +```sql +( +select + 'unused_index' as "name!", + 'Unused Index' as "title!", + 'INFO' as "level!", + 'EXTERNAL' as "facing!", + array['PERFORMANCE'] as "categories!", + 'Detects if an index has never been used and may be a candidate for removal.' as "description!", + format( + 'Index \`%s\` on table \`%s.%s\` has not been used', + psui.indexrelname, + psui.schemaname, + psui.relname + ) as "detail!", + 'https://supabase.com/docs/guides/database/database-linter?lint=0005_unused_index' as "remediation!", + jsonb_build_object( + 'schema', psui.schemaname, + 'name', psui.relname, + 'type', 'table' + ) as "metadata!", + format( + 'unused_index_%s_%s_%s', + psui.schemaname, + psui.relname, + psui.indexrelname + ) as "cache_key!" + +from + pg_catalog.pg_stat_user_indexes psui + join pg_catalog.pg_index pi + on psui.indexrelid = pi.indexrelid + left join pg_catalog.pg_depend dep + on psui.relid = dep.objid + and dep.deptype = 'e' +where + psui.idx_scan = 0 + and not pi.indisunique + and not pi.indisprimary + and dep.objid is null -- exclude tables owned by extensions + and psui.schemaname 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' + )) +``` + +## How to configure + +```json +{ + "splinter": { + "rules": { + "performance": { + "unusedIndex": "error" + } + } + } +} +``` diff --git a/xtask/codegen/src/generate_new_analyser_rule.rs b/xtask/codegen/src/generate_new_analyser_rule.rs index d65a1cbf8..7e7d7b04e 100644 --- a/xtask/codegen/src/generate_new_analyser_rule.rs +++ b/xtask/codegen/src/generate_new_analyser_rule.rs @@ -41,12 +41,10 @@ fn generate_rule_template( }; format!( - r#"use pgls_analyse::{{ - AnalysedFileContext, context::RuleContext, {macro_name}, Rule, RuleDiagnostic, -}}; + r#"use crate::{{LinterRule, LinterRuleContext, LinterDiagnostic}}; +use pgls_analyse::{{RuleSource, {macro_name}}}; use pgls_console::markup; use pgls_diagnostics::Severity; -use pgls_schema_cache::SchemaCache; {macro_name}! {{ /// Succinct description of the rule. @@ -77,10 +75,10 @@ use pgls_schema_cache::SchemaCache; }} }} -impl Rule for {rule_name_upper_camel} {{ +impl LinterRule for {rule_name_upper_camel} {{ type Options = (); - fn run(ctx: &RuleContext) -> Vec {{ + fn run(ctx: &LinterRuleContext) -> Vec {{ Vec::new() }} }} diff --git a/xtask/codegen/src/generate_splinter.rs b/xtask/codegen/src/generate_splinter.rs index a00277be4..6dc94bd0e 100644 --- a/xtask/codegen/src/generate_splinter.rs +++ b/xtask/codegen/src/generate_splinter.rs @@ -188,7 +188,16 @@ fn generate_rule_trait() -> Result<()> { /// - Rule logic is in SQL files, not Rust pub trait SplinterRule: RuleMeta { /// Path to the SQL file containing the rule query - fn sql_file_path() -> &'static str; + const SQL_FILE_PATH: &'static str; + + /// Description of what the rule detects + const DESCRIPTION: &'static str; + + /// URL to documentation/remediation guide + const REMEDIATION: &'static str; + + /// Whether this rule requires Supabase roles (anon, authenticated, service_role) + const REQUIRES_SUPABASE: bool; } }; @@ -321,9 +330,10 @@ fn generate_rule_file(category_dir: &Path, metadata: &SqlRuleMetadata) -> Result } impl SplinterRule for #struct_name { - fn sql_file_path() -> &'static str { - #sql_path - } + const SQL_FILE_PATH: &'static str = #sql_path; + const DESCRIPTION: &'static str = #description; + const REMEDIATION: &'static str = #remediation; + const REQUIRES_SUPABASE: bool = #requires_supabase; } }; @@ -502,11 +512,42 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { }) .collect(); + // Generate match arms for metadata fields lookup (camelCase → tuple) + // These call the trait constants from the generated rule types + let metadata_fields_arms: Vec<_> = rules + .values() + .map(|rule| { + let camel_name = &rule.name; + let category_ident = format_ident!("{}", rule.category.to_lowercase()); + let module_name = format_ident!("{}", &rule.snake_name); + let struct_name = format_ident!("{}", Case::Pascal.convert(&rule.snake_name)); + + quote! { + #camel_name => Some(( + ::DESCRIPTION, + ::REMEDIATION, + ::REQUIRES_SUPABASE, + )) + } + }) + .collect(); + let content = quote! { //! Generated file, do not edit by hand, see `xtask/codegen` use pgls_analyse::RegistryVisitor; + /// Metadata for a splinter rule + #[derive(Debug, Clone, Copy)] + pub struct SplinterRuleMetadata { + /// Description of what the rule detects + pub description: &'static str, + /// URL to documentation/remediation guide + pub remediation: &'static str, + /// Whether this rule requires Supabase roles (anon, authenticated, service_role) + pub requires_supabase: bool, + } + /// Visit all splinter rules using the visitor pattern /// This is called during registry building to collect enabled rules pub fn visit_registry(registry: &mut V) { @@ -535,6 +576,32 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { } } + /// Get metadata fields for a rule (camelCase name) + /// Returns (description, remediation, requires_supabase) tuple + /// + /// 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 { + #( #metadata_fields_arms, )* + _ => None, + } + } + + /// Get metadata for a rule (camelCase name) + /// Returns None if rule not found + /// + /// This provides structured access to rule metadata by calling trait constants + pub fn get_rule_metadata(rule_name: &str) -> Option { + let (description, remediation, requires_supabase) = get_rule_metadata_fields(rule_name)?; + Some(SplinterRuleMetadata { + description, + remediation, + requires_supabase, + }) + } + /// Map rule name from SQL result (snake_case) to diagnostic category /// Returns None if rule not found /// @@ -548,6 +615,7 @@ fn generate_registry(rules: &BTreeMap) -> Result<()> { /// Check if a rule requires Supabase roles (anon, authenticated, service_role) /// Rules that require Supabase should be filtered out if these roles don't exist + #[deprecated(note = "Use get_rule_metadata() instead")] pub fn rule_requires_supabase(rule_name: &str) -> bool { match rule_name { #( #supabase_arms, )*