Files
supabase/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts
samrose 4afbe9c2b2 feat: lint integration for pg_graphql introspection + SECURITY DEFINER functions (#45260)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature — wires up three new advisor lints landed in splinter, and
updates the self-hosted SQL bundle for the existing
`pg_graphql_anon_table_exposed` lint to track splinter's correctness
fixes. Companion to `supabase/splinter` #160 (already merged) and #162
(test fix in flight).

## What is the current behavior?

Splinter's `main` now exposes four lints in the pg_graphql / SECURITY
DEFINER family:

- `pg_graphql_anon_table_exposed` (0026, existing) — wired into Studio
in #45253; SQL in `packages/pg-meta` is the original version that uses
`has_table_privilege` and the relkind set `('r','p','v','m')`.
- `pg_graphql_authenticated_table_exposed` (0027, new) — paired check
against the `authenticated` role. Studio renders any new finding without
a `lintInfoMap` entry as a row with no icon, no title mapping, and no
"Fix" CTA. Self-hosted users do not see the lint at all because
`packages/pg-meta` does not include it.
- `anon_security_definer_function_executable` (0028, new) — `SECURITY
DEFINER` function executable by `anon`. Same Studio + self-hosted gaps
as 0027.
- `authenticated_security_definer_function_executable` (0029, new) —
same against `authenticated`.

Splinter has also updated 0026 itself (PR #160) in two ways that need to
flow into the self-hosted SQL bundle:
1. **`relkind` filter:** `('r','p','v','m')` → `('r','v','m','f')`.
Drops partitioned table roots (pg_graphql does not expose them; their
leaf partitions are still covered as `'r'`) and adds foreign tables,
which pg_graphql does expose.
2. **Privilege predicate:** `has_table_privilege(role, oid, 'SELECT')` →
`EXISTS` over `pg_attribute` calling `has_column_privilege`. Catches
column-level grants such as `GRANT SELECT (col) ON t TO anon`, which
pg_graphql's introspection exposes but `has_table_privilege` missed.

Cloud projects auto-fetch `splinter.sql` via the platform mgmt-api's
`getLintSql` (1-hour cache TTL), so they pick up #160's lint and SQL
changes independently of this PR. This PR is about the Studio display
mapping and the self-hosted SQL bundle.

## What is the new behavior?

Two minimal additions, mirroring the integration shape of #45253.

### `apps/studio/components/interfaces/Linter/Linter.utils.tsx`

Three new entries appended to `lintInfoMap`:

- `pg_graphql_authenticated_table_exposed` — `Eye` icon (paired with the
existing `pg_graphql_anon_table_exposed` entry); link points to the
Table Editor scoped to `metadata.schema` + `metadata.name`; `linkText:
'View object'`; `category: 'security'`.
- `anon_security_definer_function_executable` — `Unlock` icon (signals
"this thing is callable when it shouldn't be"); link points to the
Database Functions browser scoped to `metadata.schema` +
`metadata.name`; `linkText: 'View function'`; `category: 'security'`.
- `authenticated_security_definer_function_executable` — same as 0028
against `authenticated`.

Each entry's `docsLink` points at the splinter-hosted lint doc.

### `packages/pg-meta/src/sql/studio/advisor/lints.ts`

The existing `pg_graphql_anon_table_exposed` SQL block is updated in
place to match the new splinter version: new `relkind` set, `case`
statement for `'f'`, and the `EXISTS` over `pg_attribute` privilege
check. Three new `union all` blocks are appended for 0027/0028/0029. The
function lints (0028/0029) include the `pgrst.db_schemas` filter
(mirroring lint `0023_sensitive_columns_exposed`) so findings are scoped
to schemas PostgREST actually exposes; the self-hosted query wrapper
already sets the GUC when `exposedSchemas` is passed
(`enrichLintsQuery`).

## Coverage of the four exposure paths

| Role | Tables/views/MVs/foreign tables | SECURITY DEFINER functions |
|------|---------|----------|
| `anon` | 0026 (existing, updated) | 0028 (new) |
| `authenticated` | 0027 (new) | 0029 (new) |

The 0026/0027 pair covers `pg_graphql` introspection visibility; the
0028/0029 pair covers RLS bypass via privileged function execution
through `/rest/v1/rpc` (and `/graphql/v1` for compatible return types).
Each lint's doc cross-references its sibling so an operator hitting one
is steered toward the others.

## Verification

- `cd packages/pg-meta && npx tsc --noEmit` — clean.
- `cd apps/studio && npx tsc --noEmit` — clean for the changed file.
(Other unrelated TS errors exist in the working tree but are
pre-existing and not introduced by this PR.)
- `cd apps/studio && npx eslint
components/interfaces/Linter/Linter.utils.tsx` — clean.

## Files

- `apps/studio/components/interfaces/Linter/Linter.utils.tsx` — adds
three `lintInfoMap` entries (0027, 0028, 0029).
- `packages/pg-meta/src/sql/studio/advisor/lints.ts` — updates the 0026
SQL block to match splinter's correctness fixes, appends 0027/0028/0029
SQL blocks.

## Related

- supabase/splinter#160 — adds 0027/0028/0029 and rewrites 0026
(merged).
- supabase/splinter#162 — fixes test setup for 0028/0029 (in flight;
does not affect the SQL shipped here).
- supabase/supabase#45253 — original 0026 Studio integration.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added security linting to detect authenticated-table exposure and
executable SECURITY DEFINER functions.
  * Added signed-in visibility checks alongside anonymous checks.

* **Bug Fixes / Improvements**
* Improved relation type handling for accurate table/foreign/partition
classification.
  * Switched to column-level privilege analysis for visibility.
* Improved entity naming shown in lints (includes function argument
display).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
2026-04-27 10:56:44 +08:00

187 lines
5.1 KiB
TypeScript

import dayjs from 'dayjs'
import { Gauge, Inbox, Shield } from 'lucide-react'
import type { ElementType } from 'react'
import type { AdvisorItem, AdvisorLintItem, AdvisorNotificationItem } from './AdvisorPanel.types'
import { lintInfoMap } from '@/components/interfaces/Linter/Linter.utils'
import type { Lint } from '@/data/lint/lint-query'
import type { Notification, NotificationData } from '@/data/notifications/notifications-v2-query'
import type { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state'
export const MAX_HOMEPAGE_ADVISOR_ITEMS = 4
export const severityOrder: Record<AdvisorSeverity, number> = {
critical: 0,
warning: 1,
info: 2,
}
export const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => {
switch (level) {
case 'ERROR':
return 'critical'
case 'WARN':
return 'warning'
default:
return 'info'
}
}
export const notificationPriorityToSeverity = (
priority: string | null | undefined
): AdvisorSeverity => {
switch (priority) {
case 'Critical':
return 'critical'
case 'Warning':
return 'warning'
default:
return 'info'
}
}
export const createAdvisorLintItems = (lintData?: Lint[]): AdvisorLintItem[] => {
if (!lintData) return []
return lintData
.map((lint): AdvisorLintItem | null => {
const categories = lint.categories || []
const tab = categories.includes('SECURITY')
? ('security' as const)
: categories.includes('PERFORMANCE')
? ('performance' as const)
: undefined
if (!tab) return null
return {
id: lint.cache_key,
title: lint.detail,
severity: lintLevelToSeverity(lint.level),
createdAt: undefined,
tab,
source: 'lint',
original: lint,
}
})
.filter((item): item is AdvisorLintItem => item !== null)
}
export const createAdvisorNotificationItems = (
notifications?: Notification[]
): AdvisorNotificationItem[] => {
if (!notifications) return []
return notifications.map((notification) => {
const data = notification.data as NotificationData
return {
id: notification.id,
title: data.title,
severity: notificationPriorityToSeverity(notification.priority),
createdAt: dayjs(notification.inserted_at).valueOf(),
tab: 'messages' as const,
source: 'notification' as const,
original: notification,
}
})
}
export const sortAdvisorItems = <T extends AdvisorItem>(items: T[]) => {
return [...items].sort((a, b) => {
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]
if (severityDiff !== 0) return severityDiff
const createdDiff = (b.createdAt ?? 0) - (a.createdAt ?? 0)
if (createdDiff !== 0) return createdDiff
return getAdvisorItemDisplayTitle(a).localeCompare(getAdvisorItemDisplayTitle(b))
})
}
export const formatItemDate = (timestamp: number): string => {
const daysFromNow = dayjs().diff(dayjs(timestamp), 'day')
const formattedTimeFromNow = dayjs(timestamp).fromNow()
const formattedInsertedAt = dayjs(timestamp).format('MMM DD, YYYY')
return daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow
}
export const getAdvisorItemDisplayTitle = (item: AdvisorItem): string => {
if (item.source === 'lint') {
return (
lintInfoMap.find((info) => info.name === item.original.name)?.title ||
item.title.replace(/[`\\]/g, '')
)
}
if (item.source === 'signal') {
return `${item.title}`
}
return item.title.replace(/[`\\]/g, '')
}
export const getAdvisorPanelItemDisplayTitle = (item: AdvisorItem): string => {
if (item.source === 'signal') {
return item.title
}
return getAdvisorItemDisplayTitle(item)
}
export const getAdvisorItemSecondaryText = (item: AdvisorItem): string | undefined => {
if (item.source === 'lint') {
return getLintEntityString(item.original)
}
if (item.source === 'signal') {
return `Database · ${item.sourceData.ip}`
}
return undefined
}
export const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, ElementType> = {
security: Shield,
performance: Gauge,
messages: Inbox,
}
export const severityColorClasses: Record<AdvisorSeverity, string> = {
critical: 'text-destructive',
warning: 'text-warning',
info: 'text-foreground-light',
}
export const severityBadgeVariants: Record<AdvisorSeverity, 'destructive' | 'warning' | 'default'> =
{
critical: 'destructive',
warning: 'warning',
info: 'default',
}
export const severityLabels: Record<AdvisorSeverity, string> = {
critical: 'Critical',
warning: 'Warning',
info: 'Info',
}
export const getLintEntityString = (lint: Lint | null): string | undefined => {
if (!lint?.metadata) {
return undefined
}
if (lint.metadata.entity) {
return lint.metadata.entity
}
if (lint.metadata.schema && lint.metadata.name) {
const extendedMetadata = lint.metadata as typeof lint.metadata & { arguments?: string }
const args =
typeof extendedMetadata.arguments === 'string' ? extendedMetadata.arguments : undefined
return `${lint.metadata.schema}.${lint.metadata.name}${args !== undefined ? `(${args})` : ''}`
}
return undefined
}