mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: Add more lint rules (#22594)
* Refactor the functions page to keep its state in the URL. * Derive the filter options from currently active lints. * Update the lint query sql. * Update the urls to the docs. * Fix the paddings on the filterPopover. * Add handling for the other types of lints. * Comment out the ignore lint functionality. * Fix a misspell. * unbest * Bring back the CTA button. * Update the SQL code from the splinter repo. * Add handling for the duplicate index lint rule. --------- Co-authored-by: Terry Sutton <saltcod@gmail.com>
This commit is contained in:
1 parent
9ec2bcce9d
commit
db2fb81ec8
6 files changed
+482
-99
No files matched your search
+32
-5
@@ -2,9 +2,10 @@ import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import type { PostgresFunction } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { noop, partition } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Button, IconSearch, Input } from 'ui'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
@@ -15,6 +16,7 @@ import { useDatabaseFunctionsQuery } from 'data/database-functions/database-func
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useCheckPermissions } from 'hooks'
|
||||
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
|
||||
import { useEffect } from 'react'
|
||||
import ProtectedSchemaWarning from '../../ProtectedSchemaWarning'
|
||||
import FunctionList from './FunctionList'
|
||||
|
||||
@@ -30,8 +32,33 @@ const FunctionsList = ({
|
||||
deleteFunction = noop,
|
||||
}: FunctionsListProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const [selectedSchema, setSelectedSchema] = useState<string>('public')
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const router = useRouter()
|
||||
const { schema, search } = useParams()
|
||||
const selectedSchema = schema ?? 'public'
|
||||
const filterString = search ?? ''
|
||||
|
||||
const setSelectedSchema = (s: string) => {
|
||||
const url = new URL(document.URL)
|
||||
url.searchParams.delete('search')
|
||||
url.searchParams.set('schema', s)
|
||||
router.push(url)
|
||||
}
|
||||
const setFilterString = (str: string) => {
|
||||
const url = new URL(document.URL)
|
||||
if (str === '') {
|
||||
url.searchParams.delete('search')
|
||||
} else {
|
||||
url.searchParams.set('search', str)
|
||||
}
|
||||
router.push(url)
|
||||
}
|
||||
|
||||
// update the url to point to public schema
|
||||
useEffect(() => {
|
||||
if (schema !== selectedSchema) {
|
||||
setSelectedSchema(selectedSchema)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const canCreateFunctions = useCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
@@ -45,8 +72,8 @@ const FunctionsList = ({
|
||||
const [protectedSchemas] = partition(schemas ?? [], (schema) =>
|
||||
EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
|
||||
)
|
||||
const schema = schemas?.find((schema) => schema.name === selectedSchema)
|
||||
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
|
||||
const foundSchema = schemas?.find((schema) => schema.name === selectedSchema)
|
||||
const isLocked = protectedSchemas.some((s) => s.id === foundSchema?.id)
|
||||
|
||||
const {
|
||||
data: functions,
|
||||
|
||||
@@ -20,6 +20,18 @@ export function getHumanReadableTitle(title: LINT_TYPES) {
|
||||
return 'Unused Index'
|
||||
case 'multiple_permissive_policies':
|
||||
return 'Multiple Permissive Policies'
|
||||
case 'function_search_path_mutable':
|
||||
return 'Function Search Path Mutable'
|
||||
case 'rls_enabled_no_policy':
|
||||
return 'RLS Enabled No Policy'
|
||||
case 'policy_exists_rls_disabled':
|
||||
return 'Policy Exists RLS Disabled'
|
||||
case 'rls_disabled_in_public':
|
||||
return 'RLS Disabled in Public'
|
||||
case 'security_definer_view':
|
||||
return 'Security Definer View'
|
||||
case 'duplicate_index':
|
||||
return 'Duplicate Index'
|
||||
default:
|
||||
assertUnreachable(title)
|
||||
throw new Error('This case should never be reached')
|
||||
@@ -85,6 +97,18 @@ export const LintCTA = ({
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'duplicate_index':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/indexes?schema=${metadata?.schema}&table=${metadata?.name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View index
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'multiple_permissive_policies':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
@@ -97,6 +121,58 @@ export const LintCTA = ({
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'function_search_path_mutable':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/functions?schema=${metadata?.schema}&search=${metadata?.name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View functions
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'rls_enabled_no_policy':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View policies
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'policy_exists_rls_disabled':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View policies
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'rls_disabled_in_public':
|
||||
return (
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View policies
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
case 'security_definer_view':
|
||||
// we don't have a good place to send the user to check out a view
|
||||
return <></>
|
||||
|
||||
default:
|
||||
assertUnreachable(title)
|
||||
return <></>
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { Eye, EyeIcon, EyeOff, HelpCircle, Table2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Eye, HelpCircle, Table2 } from 'lucide-react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { LINT_TYPES, Lint } from 'data/lint/lint-query'
|
||||
import { useLocalStorageQuery } from 'hooks'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Modal,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
Tooltip_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Badge, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
|
||||
import { Markdown } from '../Markdown'
|
||||
import { LintCTA, getHumanReadableTitle } from './ReportLints.utils'
|
||||
|
||||
@@ -23,33 +13,35 @@ type ReportLintsTableRowProps = {
|
||||
|
||||
const ReportLintsTableRow = ({ lint }: ReportLintsTableRowProps) => {
|
||||
const { ref } = useParams()
|
||||
const [selectedLint, setSelectedLint] = useState<Lint | null>(null)
|
||||
// TODO: Comment out functionality for ignoring lints because it relied on local storage. Will revisit later.
|
||||
// const [selectedLint, setSelectedLint] = useState<Lint | null>(null)
|
||||
|
||||
const [lintIgnoreList, setLintIgnoreList] = useLocalStorageQuery<string[]>(
|
||||
LOCAL_STORAGE_KEYS.PROJECT_LINT_IGNORE_LIST,
|
||||
[]
|
||||
)
|
||||
const isIgnored = lintIgnoreList.includes(lint.cache_key)
|
||||
// const [lintIgnoreList, setLintIgnoreList] = useLocalStorageQuery<string[]>(
|
||||
// LOCAL_STORAGE_KEYS.PROJECT_LINT_IGNORE_LIST,
|
||||
// []
|
||||
// )
|
||||
// const isIgnored = lintIgnoreList.includes(lint.cache_key)
|
||||
|
||||
// if the lint type can't be handled (there's no CTA text defined), don't render it
|
||||
if (!LINT_TYPES.includes(lint.name)) {
|
||||
console.log('Unhandled lint type:', lint.name)
|
||||
return null
|
||||
}
|
||||
|
||||
const toggleLintIgnore = () => {
|
||||
let currentIgnoreList = []
|
||||
const cacheKey = lint.cache_key
|
||||
// const toggleLintIgnore = () => {
|
||||
// let currentIgnoreList = []
|
||||
// const cacheKey = lint.cache_key
|
||||
|
||||
// Check if the cacheKey exists in the array and ignore or unignore it
|
||||
const index = lintIgnoreList.indexOf(cacheKey)
|
||||
if (index !== -1) {
|
||||
currentIgnoreList = lintIgnoreList.filter((l) => l !== cacheKey)
|
||||
} else {
|
||||
currentIgnoreList = lintIgnoreList.concat(cacheKey)
|
||||
}
|
||||
setLintIgnoreList(currentIgnoreList)
|
||||
setSelectedLint(null)
|
||||
}
|
||||
// // Check if the cacheKey exists in the array and ignore or unignore it
|
||||
// const index = lintIgnoreList.indexOf(cacheKey)
|
||||
// if (index !== -1) {
|
||||
// currentIgnoreList = lintIgnoreList.filter((l) => l !== cacheKey)
|
||||
// } else {
|
||||
// currentIgnoreList = lintIgnoreList.concat(cacheKey)
|
||||
// }
|
||||
// setLintIgnoreList(currentIgnoreList)
|
||||
// setSelectedLint(null)
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -109,7 +101,7 @@ const ReportLintsTableRow = ({ lint }: ReportLintsTableRowProps) => {
|
||||
)}
|
||||
{lint.remediation && (
|
||||
<p className="text-foreground-light max-w-full leading-relaxed">
|
||||
You can read more about this lint rule and the best ways to remedy it{' '}
|
||||
You can read more about this lint rule and ways to remedy it{' '}
|
||||
<a
|
||||
className="underline text-foreground-light transition-all hover:text-foreground hover:decoration-brand"
|
||||
href={lint.remediation}
|
||||
@@ -125,7 +117,7 @@ const ReportLintsTableRow = ({ lint }: ReportLintsTableRowProps) => {
|
||||
<Table.td>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<LintCTA title={lint.name} projectRef={ref!} metadata={lint.metadata} />
|
||||
<Tooltip_Shadcn_>
|
||||
{/* <Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -141,11 +133,11 @@ const ReportLintsTableRow = ({ lint }: ReportLintsTableRowProps) => {
|
||||
<TooltipContent_Shadcn_ side="bottom">
|
||||
{isIgnored ? 'Unignore problem' : 'Ignore problem'}
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</Tooltip_Shadcn_> */}
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
<Modal
|
||||
{/* <Modal
|
||||
size="small"
|
||||
alignFooter="right"
|
||||
visible={selectedLint !== null}
|
||||
@@ -162,7 +154,7 @@ const ReportLintsTableRow = ({ lint }: ReportLintsTableRowProps) => {
|
||||
</p>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,9 +40,10 @@ export const FilterPopover = ({
|
||||
header={<div className="prose text-xs">Select {name.toLowerCase()}</div>}
|
||||
overlay={
|
||||
<>
|
||||
<div className="space-y-4 px-3 py-3 min-w-[170px]">
|
||||
<div className="space-y-4 min-w-[170px]">
|
||||
<ScrollArea className={options.length > 7 ? 'h-[205px]' : ''}>
|
||||
<Checkbox.Group
|
||||
className="px-3 py-3"
|
||||
id="projects"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
|
||||
@@ -10,10 +10,10 @@ with foreign_keys as (
|
||||
ct.conname as fkey_name,
|
||||
ct.conkey col_attnums
|
||||
from
|
||||
pg_constraint ct
|
||||
join pg_class cl -- fkey owning table
|
||||
pg_catalog.pg_constraint ct
|
||||
join pg_catalog.pg_class cl -- fkey owning table
|
||||
on ct.conrelid = cl.oid
|
||||
left join pg_depend d
|
||||
left join pg_catalog.pg_depend d
|
||||
on d.objid = cl.oid
|
||||
and d.deptype = 'e'
|
||||
where
|
||||
@@ -29,7 +29,7 @@ index_ as (
|
||||
indexrelid::regclass as index_,
|
||||
string_to_array(indkey::text, ' ')::smallint[] as col_attnums
|
||||
from
|
||||
pg_index
|
||||
pg_catalog.pg_index
|
||||
where
|
||||
indisvalid
|
||||
)
|
||||
@@ -42,7 +42,6 @@ select
|
||||
'Table \`%s.%s\` has a foreign key \`%s\` without a covering index. This can lead to suboptimal query performance.',
|
||||
fk.schema_,
|
||||
fk.table_,
|
||||
fk.table_,
|
||||
fk.fkey_name
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0001_unindexed_foreign_keys' as remediation,
|
||||
@@ -53,7 +52,7 @@ select
|
||||
'fkey_name', fk.fkey_name,
|
||||
'fkey_columns', fk.col_attnums
|
||||
) as metadata,
|
||||
format('0001_unindexed_foreign_keys_%s_%s_%s', fk.schema_, fk.table_, fk.fkey_name) as cache_key
|
||||
format('unindexed_foreign_keys_%s_%s_%s', fk.schema_, fk.table_, fk.fkey_name) as cache_key
|
||||
from
|
||||
foreign_keys fk
|
||||
left join index_ idx
|
||||
@@ -61,6 +60,9 @@ from
|
||||
and fk.col_attnums = idx.col_attnums
|
||||
where
|
||||
idx.index_ is null
|
||||
and fk.schema_::text not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'extensions', 'graphql', 'graphql_public', 'net', 'pgsodium', 'storage', 'supabase_functions', 'vault'
|
||||
)
|
||||
order by
|
||||
fk.table_,
|
||||
fk.fkey_name)
|
||||
@@ -84,17 +86,25 @@ select
|
||||
) as metadata,
|
||||
format('auth_users_exposed_%s_%s', 'public', c.relname) as cache_key
|
||||
from
|
||||
pg_depend d
|
||||
join pg_rewrite r
|
||||
-- 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_class c
|
||||
join pg_catalog.pg_class c
|
||||
on c.oid = r.ev_class
|
||||
join pg_namespace n
|
||||
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.refobjid = 'auth.users'::regclass
|
||||
and d.deptype = 'n'
|
||||
and c.relkind in ('v', 'm') -- v for view, m for materialized view
|
||||
d.deptype = 'n'
|
||||
and n.nspname = 'public'
|
||||
and (
|
||||
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
|
||||
@@ -102,6 +112,43 @@ where
|
||||
)
|
||||
-- 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
|
||||
c.relname, c.oid)
|
||||
union all
|
||||
@@ -151,12 +198,12 @@ with policies as (
|
||||
qual,
|
||||
with_check
|
||||
from
|
||||
pg_policy pa
|
||||
join pg_class pc
|
||||
pg_catalog.pg_policy pa
|
||||
join pg_catalog.pg_class pc
|
||||
on pa.polrelid = pc.oid
|
||||
join pg_namespace nsp
|
||||
join pg_catalog.pg_namespace nsp
|
||||
on pc.relnamespace = nsp.oid
|
||||
join pg_policies pb
|
||||
join pg_catalog.pg_policies pb
|
||||
on pc.relname = pb.tablename
|
||||
and nsp.nspname = pb.schemaname
|
||||
and pa.polname = pb.policyname
|
||||
@@ -182,6 +229,9 @@ from
|
||||
policies
|
||||
where
|
||||
is_rls_active
|
||||
and schema_::text not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'extensions', 'graphql', 'graphql_public', 'net', 'pgsodium', 'storage', 'supabase_functions', 'vault'
|
||||
)
|
||||
and (
|
||||
(
|
||||
-- Example: auth.uid()
|
||||
@@ -210,7 +260,7 @@ select
|
||||
pgc.relname
|
||||
) as detail,
|
||||
'https://supabase.com/docs/guides/database/database-linter?lint=0004_no_primary_key' as remediation,
|
||||
jsonb_build_object(
|
||||
jsonb_build_object(
|
||||
'schema', pgns.nspname,
|
||||
'name', pgc.relname,
|
||||
'type', 'table'
|
||||
@@ -221,15 +271,15 @@ select
|
||||
pgc.relname
|
||||
) as cache_key
|
||||
from
|
||||
pg_class pgc
|
||||
join pg_namespace pgns
|
||||
pg_catalog.pg_class pgc
|
||||
join pg_catalog.pg_namespace pgns
|
||||
on pgns.oid = pgc.relnamespace
|
||||
left join pg_index pgi
|
||||
left join pg_catalog.pg_index pgi
|
||||
on pgi.indrelid = pgc.oid
|
||||
where
|
||||
pgc.relkind = 'r' -- regular tables
|
||||
and pgns.nspname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'
|
||||
'pg_catalog', 'information_schema', 'auth', 'extensions', 'graphql', 'graphql_public', 'net', 'pgsodium', 'storage', 'supabase_functions', 'vault'
|
||||
)
|
||||
group by
|
||||
pgc.oid,
|
||||
@@ -272,7 +322,7 @@ where
|
||||
and not pi.indisunique
|
||||
and not pi.indisprimary
|
||||
and psui.schemaname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'
|
||||
'pg_catalog', 'information_schema', 'auth', 'net', 'pgsodium', 'storage', 'supabase_functions', 'vault'
|
||||
))
|
||||
union all
|
||||
(
|
||||
@@ -326,7 +376,7 @@ from
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
and n.nspname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'
|
||||
'pg_catalog', 'information_schema', 'auth', 'extensions', 'graphql', 'graphql_public', 'net', 'pgsodium', 'storage', 'supabase_functions', 'vault'
|
||||
)
|
||||
and r.rolname not like 'pg_%'
|
||||
and r.rolname not like 'supabase%admin'
|
||||
@@ -337,16 +387,256 @@ group by
|
||||
r.rolname,
|
||||
act.cmd
|
||||
having
|
||||
count(1) > 1)`.trim()
|
||||
count(1) > 1)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'policy_exists_rls_disabled' as name,
|
||||
'INFO' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'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
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
and n.nspname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'extensions', 'graphql', 'graphql_public', 'net', 'pgsodium', 'storage', 'supabase_functions', 'vault'
|
||||
)
|
||||
-- RLS is disabled
|
||||
and not c.relrowsecurity
|
||||
group by
|
||||
n.nspname,
|
||||
c.relname)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'rls_enabled_no_policy' as name,
|
||||
'INFO' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'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
|
||||
where
|
||||
c.relkind = 'r' -- regular tables
|
||||
and n.nspname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'extensions', 'graphql', 'graphql_public', 'net', 'pgsodium', 'storage', 'supabase_functions', 'vault'
|
||||
)
|
||||
-- RLS is enabled
|
||||
and c.relrowsecurity
|
||||
and p.polname is null
|
||||
group by
|
||||
n.nspname,
|
||||
c.relname)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'duplicate_index' as name,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'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
|
||||
where
|
||||
c.relkind in ('r', 'm') -- tables and materialized views
|
||||
and n.nspname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'extensions', 'graphql', 'graphql_public', 'net', 'pgsodium', 'storage', 'supabase_functions', 'vault'
|
||||
)
|
||||
group by
|
||||
n.nspname,
|
||||
c.relkind,
|
||||
c.relname,
|
||||
replace(pi.indexdef, pi.indexname, '')
|
||||
having
|
||||
count(*) > 1)
|
||||
union all
|
||||
(
|
||||
select
|
||||
'security_definer_view' as name,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'Detects views that are SECURITY DEFINER meaning that they ignore row level security (RLS) policies.' as description,
|
||||
format(
|
||||
'View \`%s.%s\` is SECURITY DEFINER',
|
||||
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
|
||||
where
|
||||
c.relkind = 'v'
|
||||
and n.nspname = 'public'
|
||||
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,
|
||||
'WARN' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'Detects functions with a mutable search_path parameter which could fail to execute successfully for some roles.' 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
|
||||
where
|
||||
n.nspname not in (
|
||||
'pg_catalog', 'information_schema', 'auth', 'extensions', 'graphql', 'graphql_public', 'net', 'pgsodium', 'storage', 'supabase_functions', 'vault'
|
||||
)
|
||||
-- Search path not set to ''
|
||||
and not coalesce(p.proconfig, '{}') && array['search_path=""'])
|
||||
union all
|
||||
(
|
||||
select
|
||||
'rls_disabled_in_public' as name,
|
||||
'ERROR' as level,
|
||||
'EXTERNAL' as facing,
|
||||
'Detects cases where row level security (RLS) has not been enabled on a table in the \`public\` schema.' 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
|
||||
and n.nspname = 'public'
|
||||
-- RLS is disabled
|
||||
and not c.relrowsecurity)`.trim()
|
||||
|
||||
// Array of all lint rules we handle right now.
|
||||
export const LINT_TYPES = [
|
||||
'unindexed_foreign_keys',
|
||||
'auth_users_exposed',
|
||||
'auth_rls_initplan',
|
||||
'no_primary_key',
|
||||
'unused_index',
|
||||
'multiple_permissive_policies',
|
||||
'auth_rls_initplan',
|
||||
'policy_exists_rls_disabled',
|
||||
'rls_enabled_no_policy',
|
||||
'duplicate_index',
|
||||
'security_definer_view',
|
||||
'function_search_path_mutable',
|
||||
'rls_disabled_in_public',
|
||||
] as const
|
||||
export type LINT_TYPES = (typeof LINT_TYPES)[number]
|
||||
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { partition, sortBy } from 'lodash'
|
||||
import { sortBy } from 'lodash'
|
||||
import { Check, ExternalLink, Loader } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { AccordionTrigger } from '@ui/components/shadcn/ui/accordion'
|
||||
import { getHumanReadableTitle } from 'components/interfaces/Reports/ReportLints.utils'
|
||||
import ReportLintsTableRow from 'components/interfaces/Reports/ReportLintsTableRow'
|
||||
import { DatabaseLayout } from 'components/layouts'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { FormHeader } from 'components/ui/Forms'
|
||||
import { useProjectLintsQuery } from 'data/lint/lint-query'
|
||||
import { useLocalStorageQuery, useSelectedProject } from 'hooks'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import {
|
||||
AccordionContent_Shadcn_,
|
||||
AccordionItem_Shadcn_,
|
||||
Accordion_Shadcn_,
|
||||
Button,
|
||||
LoadingLine,
|
||||
} from 'ui'
|
||||
import { FilterPopover } from 'components/ui/FilterPopover'
|
||||
import { FormHeader } from 'components/ui/Forms'
|
||||
import { LINT_TYPES, useProjectLintsQuery } from 'data/lint/lint-query'
|
||||
import { useSelectedProject } from 'hooks'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Button, LoadingLine } from 'ui'
|
||||
|
||||
const ProjectLints: NextPageWithLayout = () => {
|
||||
const project = useSelectedProject()
|
||||
@@ -27,10 +20,10 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
levels: [] as string[],
|
||||
types: [] as string[],
|
||||
})
|
||||
const [lintIgnoreList] = useLocalStorageQuery<string[]>(
|
||||
LOCAL_STORAGE_KEYS.PROJECT_LINT_IGNORE_LIST,
|
||||
[]
|
||||
)
|
||||
// const [lintIgnoreList] = useLocalStorageQuery<string[]>(
|
||||
// LOCAL_STORAGE_KEYS.PROJECT_LINT_IGNORE_LIST,
|
||||
// []
|
||||
// )
|
||||
|
||||
const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({
|
||||
projectRef: project?.ref,
|
||||
@@ -45,9 +38,10 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
return 3
|
||||
})
|
||||
|
||||
const [ignoredLints, activeLints] = partition(lints, (lint) =>
|
||||
lintIgnoreList.includes(lint.cache_key)
|
||||
)
|
||||
const activeLints = lints
|
||||
// const [ignoredLints, activeLints] = partition(lints, (lint) =>
|
||||
// lintIgnoreList.includes(lint.cache_key)
|
||||
// )
|
||||
const filteredLints = useMemo(() => {
|
||||
return activeLints
|
||||
.filter((x) => (filters.levels.length > 0 ? filters.levels.includes(x.level) : x))
|
||||
@@ -57,6 +51,16 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
const warnLintsCount = activeLints.filter((x) => x.level === 'WARN').length
|
||||
const errorLintsCount = activeLints.filter((x) => x.level === 'ERROR').length
|
||||
|
||||
const filterOptions = useMemo(() => {
|
||||
// only show filters for lint types which are present in the results and not ignored
|
||||
return LINT_TYPES.filter((type) => activeLints.some((lint) => lint.name === type)).map(
|
||||
(type) => ({
|
||||
name: getHumanReadableTitle(type),
|
||||
value: type,
|
||||
})
|
||||
)
|
||||
}, [activeLints])
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection>
|
||||
@@ -86,14 +90,7 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
/>
|
||||
<FilterPopover
|
||||
name="Type"
|
||||
options={[
|
||||
{ name: 'Unindexed foreign keys', value: 'unindexed_foreign_keys' },
|
||||
{ name: 'Auth users exposed', value: 'auth_users_exposed' },
|
||||
{ name: 'No primary key', value: 'no_primary_key' },
|
||||
{ name: 'Unused index', value: 'unused_index' },
|
||||
{ name: 'Multiple permissive policies', value: 'multiple_permissive_policies' },
|
||||
{ name: 'Auth RLS Initialization Plan', value: 'auth_rls_initplan' },
|
||||
]}
|
||||
options={filterOptions}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
activeOptions={filters.types}
|
||||
@@ -179,7 +176,7 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ignoredLints.length > 0 && (
|
||||
{/* {ignoredLints.length > 0 && (
|
||||
<div className="col-span-12 flex flex-col text-sm max-w-none gap-8 py-4">
|
||||
<Accordion_Shadcn_ type="single" collapsible>
|
||||
<AccordionItem_Shadcn_ value="1" className="border-none">
|
||||
@@ -219,7 +216,7 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
</AccordionItem_Shadcn_>
|
||||
</Accordion_Shadcn_>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user