Skill: dyagil/dyagil-supabase-security-audit (version 1.0.0)

--- name: dyagil-supabase-security-audit description: Audit a Supabase + Vercel project for RLS coverage, privilege escalation, cross-customer data leaks, anonymous exposure, magic-link flow correctness, and HTTP security headers — and apply hotfix templates when issues are found. Use whenever the user asks about security / RLS / audits, after any migration that touches `profiles`-like tables or auth, or before exposing a new customer-facing surface. version: 1.0.0 license: MIT author: dyagil --- # Supabase Security Audit Run the audit, read the findings, apply hotfixes. The script is tuned for projects that use Supabase + Vercel and a `profiles` table with `role in ('admin','agent','customer')` (or similar), but its individual probes are independent — single-table projects benefit too. ## When to Run - User asks "are we secure?" / "security audit" / "RLS check". - After any migration that touches `profiles`, RLS policies, or auth triggers. - Before sending the first real magic link to a customer. - After rotating credentials or changing Site URL / Redirect URLs. - Before exposing a new surface (portal, CRM, admin tool) to real users. ## Prerequisites Credentials file at `~/.openclaw/credentials/supabase/credentials.env` (override path with `--cred`) containing: ``` SUPABASE_URL=https://<ref>.supabase.co SUPABASE_PROJECT_REF=<ref> SUPABASE_ANON_KEY=... SUPABASE_SERVICE_ROLE_KEY=... SUPABASE_DB_PASSWORD=... ``` The `pg` Node package must be reachable. The script auto-discovers it from `/tmp/sb-tools/node_modules/pg`. If missing: ```bash mkdir -p /tmp/sb-tools && (cd /tmp/sb-tools && npm i pg) ``` ## Workflow ### 1. Run the audit ```bash node <skills-dir>/supabase-security-audit/scripts/audit.js \ --probe-uid <existing-customer-uid> \ --target-uid <another-customer-uid> \ --site https://example.com ``` - `--probe-uid` / `--target-uid` are **optional** but unlock the live privilege-escalation probe and the cross-customer data-leak probe. Both probes run inside a transaction that is always rolled back — they do not mutate the database. - `--site` adds a HEAD request to check security headers (CSP, HSTS, etc.). - `--public-tables` overrides the default list checked for anonymous reads (defaults to `profiles,deals,documents,invoices,inquiries,customer_events,tax_engagements`). Exit code is `0` if no critical findings, `1` if any. ### 2. Read the findings Output is grouped: RLS coverage → anonymous exposure → UPDATE policies → live privilege escalation → cross-customer leaks → HTTP headers → summary. Severity icons: - 🚨 `crit` — exploitable now, fix today. - 🟡 `warn` — best-practice gap or audit step skipped (e.g. no `--probe-uid`). - ✅ `ok` — passed. ### 3. Map findings to fixes For each 🚨 finding, look it up in [references/threat-patterns.md](references/threat-patterns.md) — every common pattern in that file has a detection query, an impact statement, and a fix. The most common critical finding is **"UPDATE policy lacks WITH CHECK on role/email — privilege escalation risk"**. That has a turn-key SQL template: ```bash # Edit the table name in the file first if it isn't `public.profiles` node <project>/deploy/run-migration.cjs \ <skills-dir>/supabase-security-audit/scripts/hotfix-role-lock.sql ``` Then re-run `audit.js` to confirm the finding flipped to ✅. ### 4. Document the audit Append a one-paragraph summary to your project's memory or change-log file under a dated heading, including which findings were fixed and any deferred items. ## What This Skill Does NOT Cover - **DDoS / rate-limit checks.** Supabase + Vercel handle the basics; for finer control add Cloudflare or Supabase's Pro plan rate limiting. - **Application-level CSRF.** Supabase auth uses Bearer tokens, not cookies, so CSRF is not a concern for the auth flow itself — but custom session cookies (e.g. an internal dashboard's `mc_session`) must be audited separately. - **Penetration testing of business logic** (e.g. "can a customer call `/api/send-portal-link` for another customer's id?"). Spot-check those manually by tracing each `api/*.js` endpoint's auth check. ## References - [references/threat-patterns.md](references/threat-patterns.md) — exploitable patterns with detection queries and fixes. - [scripts/audit.js](scripts/audit.js) — the audit runner. - [scripts/hotfix-role-lock.sql](scripts/hotfix-role-lock.sql) — turnkey fix for the privilege-escalation pattern.

path: references/threat-patterns.md
content: # Threat patterns to look for in Supabase projects

This is a checklist of real, exploitable mistakes that have shown up in projects
in this workspace. Each section describes the pattern, the impact, the detection
query, and the fix.

## 1. UPDATE policy without WITH CHECK on `role`

**Pattern**

```sql
create policy profiles_self_update
  on public.profiles
  for update
  using (auth.uid() = id);   -- no WITH CHECK
```

**Impact**

Any authenticated customer can run
`update profiles set role='admin' where id=auth.uid()`,
because `is_staff()` (typically `role in ('admin','agent')`) then returns true
and unlocks every `*_staff_all` policy in the schema.

**Detection**

```sql
select c.table_name, p.policyname, p.with_check
  from information_schema.columns c
  join pg_policies p
    on p.schemaname = c.table_schema and p.tablename = c.table_name
 where c.table_schema = 'public'
   and c.column_name in ('role', 'email')
   and p.cmd = 'UPDATE'
   and (p.with_check is null
        or (p.with_check not like '%role%' and p.with_check not like '%is_staff%'));
```

**Fix**

Use `scripts/hotfix-role-lock.sql` as a starting point.

## 2. RLS disabled on a public-schema table

**Pattern**

A migration creates a new table and forgets `alter table ... enable row level security`.

**Impact**

Anonymous (anon key) clients can read or even write everything in that table.

**Detection**

```sql
select tablename
  from pg_tables
 where schemaname='public' and not rowsecurity;
```

**Fix**

```sql
alter table public.<name> enable row level security;
-- then add at minimum a staff-only or owner-only policy.
```

## 3. Permissive client-facing policy without `auth.uid()` scoping

**Pattern**

```sql
create policy foo_read on public.deals for select using (true);
```

**Impact**

Any logged-in user reads every row.

**Detection**

```sql
select tablename, policyname, qual
  from pg_policies
 where schemaname='public'
   and cmd='SELECT'
   and qual !~ 'auth\\.uid|is_staff';
```

(Allow-listed reference tables such as `pipelines` or `pipeline_products` are
legitimate exceptions — confirm the table is intended to be public.)

## 4. Storage bucket without folder-isolated RLS

**Pattern**

```sql
create policy "any_user_read" on storage.objects
  for select using (auth.role() = 'authenticated');
```

**Impact**

Authenticated user A can fetch user B's files by guessing their `storage_path`.

**Fix**

```sql
create policy "user_owned_read" on storage.objects
  for select using (
    bucket_id = 'documents'
    and (storage.foldername(name))[1] = auth.uid()::text
  );
```

## 5. Service-role key in client bundle

**Pattern**

Vite/Webpack accidentally bundles a server env var into client JS, or a
developer copy-pastes the wrong key into `createClient`.

**Detection**

```bash
# Scan deployed JS for the JWT shape that service_role keys use.
curl -s https://<site>/portal/portal.js | grep -E 'eyJ[A-Za-z0-9_-]+\\.eyJ[A-Za-z0-9_-]+'

# Each match: decode the payload (2nd dot-section, base64url) and look for
#   "role":"service_role"
```

**Fix**

Rotate the key in Supabase Studio → API Settings → Reset service key
**and** replace the value in Vercel env / wherever it lives. Any client that
held a service-role key must be treated as compromised: rotate, then audit the
project for unexpected writes.

## 6. CORS `*` on admin-only endpoints

**Pattern**

A serverless function for `/api/manage-staff` sets
`Access-Control-Allow-Origin: *`.

**Impact**

Combined with a stolen Bearer token this allows attacks from any origin. Not as
severe as a missing auth check, but it weakens defense-in-depth.

**Fix**

Restrict CORS to the project's own origin(s):

```js
const ALLOWED = ['https://oganimy.co.il', 'https://oganim.vercel.app'];
const origin = request.headers.origin || '';
response.setHeader('Access-Control-Allow-Origin',
  ALLOWED.includes(origin) ? origin : ALLOWED[0]);
response.setHeader('Vary', 'Origin');
```

## 7. Magic-link redirect not whitelisted → tokens land in URL hash on the
   wrong page

**Pattern**

Code calls `auth.admin.generate_link({ redirect_to: 'https://site/portal/' })`
but `https://site/portal/` is not in the Supabase Redirect URL allow-list. The
auth server silently rewrites the redirect to the Site URL (e.g.
`https://site/`), and the `access_token=...` hash arrives on the marketing
homepage instead of the portal.

**Impact**

UX bug, not a security bug — but it _looks_ like one (tokens visible in the
URL on the wrong page).

**Fix**

Generate the link manually as
`https://<site>/portal/?token_hash=<hashed_token>&type=magiclink`
and let the portal call `auth.verifyOtp({ token_hash, type })` itself. Bypasses
the redirect-whitelist altogether.

## 8. Profile auto-create trigger creates duplicates instead of merging

**Pattern**

```sql
create function handle_new_user() ...
  insert into profiles(id, email) values (NEW.id, NEW.email);
```

When a staff member created a customer profile **before** the customer signed
up via Supabase auth, the auth-side insert creates a SECOND profile row.
`auth.uid()` now points at the empty new row; all of the customer's data
(deals, events, documents) stays on the orphan profile.

**Impact**

Customer sees an empty portal even though their data exists in the database.

**Fix**

Update the trigger to merge instead of duplicate. See the
`migration-stage15-profile-dedupe.sql` template in the oganim project for
reference (FK repoint + delete orphan).


path: scripts/audit.js
content: #!/usr/bin/env node
/**
 * Supabase security audit — runs a battery of read-only checks against a
 * Supabase project and prints a human-readable report.
 *
 * USAGE:
 *   node audit.js [--cred ~/.openclaw/credentials/supabase/credentials.env]
 *                 [--pooler aws-1-eu-central-1.pooler.supabase.com:6543]
 *                 [--public-tables profiles,deals,documents,...]
 *                 [--probe-uid <uuid>]   # an existing customer-role user id
 *                 [--target-uid <uuid>]  # another customer id to test data leaks
 *                 [--site https://example.com]
 *
 * The credentials file must define:
 *   SUPABASE_URL=...
 *   SUPABASE_PROJECT_REF=...
 *   SUPABASE_ANON_KEY=...
 *   SUPABASE_SERVICE_ROLE_KEY=...
 *   SUPABASE_DB_PASSWORD=...
 *
 * The audit is READ-ONLY for everything that matters. It WILL temporarily
 * elevate `--probe-uid` to test privilege escalation; the change is reverted
 * in a transaction and rolled back regardless of outcome.
 */

'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');

// ---- pg discovery (use whatever pg the host already has) ----
let Client;
const pgCandidates = [
  '/tmp/sb-tools/node_modules/pg',
  process.env.PG_PATH,
  'pg',
].filter(Boolean);
for (const p of pgCandidates) {
  try { ({ Client } = require(p)); break; } catch {}
}
if (!Client) {
  console.error('ERR: the `pg` package is required. Install it once with:');
  console.error('     mkdir -p /tmp/sb-tools && (cd /tmp/sb-tools && npm i pg)');
  process.exit(2);
}

// ---- CLI args ----
const args = Object.fromEntries(process.argv.slice(2).reduce((acc, cur, i, arr) => {
  if (cur.startsWith('--')) acc.push([cur.slice(2), arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[i + 1] : true]);
  return acc;
}, []));

const credPath = args.cred || path.join(process.env.HOME, '.openclaw/credentials/supabase/credentials.env');
const pooler   = args.pooler || 'aws-1-eu-central-1.pooler.supabase.com:6543';
const site     = args.site;
const publicTables = (args['public-tables'] || 'profiles,deals,documents,invoices,inquiries,customer_events,tax_engagements').split(',');
const probeUid  = args['probe-uid'];
const targetUid = args['target-uid'];

if (!fs.existsSync(credPath)) {
  console.error('ERR: credentials file not found:', credPath);
  process.exit(2);
}
const cred = Object.fromEntries(
  fs.readFileSync(credPath, 'utf8').split('\n')
    .filter(l => l.includes('=') && !l.trim().startsWith('#'))
    .map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
);

for (const k of ['SUPABASE_URL', 'SUPABASE_PROJECT_REF', 'SUPABASE_ANON_KEY', 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_DB_PASSWORD']) {
  if (!cred[k]) { console.error('ERR: missing', k, 'in', credPath); process.exit(2); }
}

// ---- Output helpers ----
const findings = []; // {level: ok|warn|crit, area, msg}
const C = { ok: '\x1b[32m', warn: '\x1b[33m', crit: '\x1b[31m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m' };
const TTY = process.stdout.isTTY;
const fmt = (color, s) => TTY ? C[color] + s + C.reset : s;
function record(level, area, msg) {
  findings.push({ level, area, msg });
  const icon = { ok: '✅', warn: '🟡', crit: '🚨' }[level];
  console.log(`${icon} ${fmt('bold', area)} — ${msg}`);
}

// ---- Tiny REST helper ----
function rest(method, url, headers) {
  return new Promise((resolve, reject) => {
    const u = new URL(url);
    const req = https.request({
      method, hostname: u.hostname, port: u.port || 443,
      path: u.pathname + u.search,
      headers: Object.assign({ 'Accept': 'application/json' }, headers || {}),
    }, res => {
      let buf = '';
      res.on('data', c => buf += c);
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: buf }));
    });
    req.on('error', reject);
    req.end();
  });
}

// ---- PG connection ----
const [host, port] = pooler.split(':');
const pg = new Client({
  host, port: Number(port || 6543),
  user: `postgres.${cred.SUPABASE_PROJECT_REF}`,
  [REDACTED_SECRET],
  database: 'postgres',
  ssl: { rejectUnauthorized: false },
  connectionTimeoutMillis: 15000,
});

// ============================================================
async function checkRlsCoverage() {
  console.log(fmt('bold', '\n=== 1. RLS coverage ==='));
  const r = await pg.query(`
    select tablename, rowsecurity
      from pg_tables
     where schemaname='public' and tablename not like 'pg_%'
     order by rowsecurity, tablename
  `);
  const off = r.rows.filter(x => !x.rowsecurity);
  if (off.length === 0) record('ok', 'RLS', `all ${r.rows.length} public tables have RLS enabled`);
  else record('crit', 'RLS', `${off.length} tables without RLS: ${off.map(x => x.tablename).join(', ')}`);
}

// ============================================================
async function checkAnonExposure() {
  console.log(fmt('bold', '\n=== 2. Anonymous reads ==='));
  for (const tbl of publicTables) {
    try {
      const r = await rest('GET', `${cred.SUPABASE_URL}/rest/v1/${tbl}?select=count`, {
        [REDACTED_SECRET],
        Authorization: 'Bearer ' + cred.SUPABASE_ANON_KEY,
        Prefer: 'count=exact',
      });
      const m = (r.headers['content-range'] || '').match(/\/(\d+)/);
      const n = m ? Number(m[1]) : null;
      if (n === null || n === 0) record('ok', 'anon:' + tbl, 'anonymous sees 0 rows');
      else                       record('crit', 'anon:' + tbl, `anonymous sees ${n} rows`);
    } catch (e) {
      record('warn', 'anon:' + tbl, 'check failed: ' + e.message);
    }
  }
}

// ============================================================
async function checkUpdatePolicies() {
  console.log(fmt('bold', '\n=== 3. UPDATE policies on profiles-like tables ==='));
  // Find every public table that has a column literally called `role` and an
  // UPDATE policy whose `with_check` is null/empty (= privilege escalation vector).
  const r = await pg.query(`
    select c.table_name,
           p.policyname,
           p.qual,
           p.with_check
      from information_schema.columns c
      join pg_policies p
        on p.schemaname = c.table_schema and p.tablename = c.table_name
     where c.table_schema = 'public'
       and c.column_name in ('role', 'email')
       and p.cmd = 'UPDATE'
  `);
  let found = false;
  for (const row of r.rows) {
    const wc = row.with_check;
    if (!wc || (!wc.includes('role') && !wc.includes('is_staff'))) {
      record('crit', `policy:${row.table_name}.${row.policyname}`,
        `UPDATE policy lacks WITH CHECK on role/email — privilege escalation risk`);
      found = true;
    }
  }
  if (!found) record('ok', 'update-policies', 'no privilege escalation surface found on UPDATE policies');
}

// ============================================================
async function checkPrivilegeEscalationLive() {
  if (!probeUid) {
    record('warn', 'priv-escalation', 'skipped — no --probe-uid provided');
    return;
  }
  console.log(fmt('bold', '\n=== 4. Live privilege escalation attempt ==='));
  // Each probe gets its own savepoint, so a deliberate failure in one doesn't
  // poison the next probe with "transaction is aborted".
  const probes = [
    {
      name: 'priv-set-admin',
      sql: `update public.profiles set role='admin' where id = $1 returning role`,
      msg: r => `PRIVILEGE ESCALATION: role became '${r.rows[0].role}'`,
    },
    {
      name: 'priv-set-email',
      sql: `update public.profiles set email='[REDACTED_SECRET]' where id = $1 returning email`,
      msg: () => `email mutation allowed`,
    },
  ];
  await pg.query('begin');
  try {
    await pg.query('set local role authenticated');
    await pg.query(`set local request.jwt.claims to '{"sub":"${probeUid}","role":"authenticated"}'`);
    for (const p of probes) {
      await pg.query('savepoint sp');
      const r = await pg.query(p.sql, [probeUid]).catch(e => ({ error: e.message }));
      if (r.error) {
        record('ok', p.name, 'blocked: ' + r.error.slice(0, 80));
        await pg.query('rollback to savepoint sp');
      } else if (r.rows.length === 0) {
        record('ok', p.name, 'blocked silently (0 rows)');
        await pg.query('release savepoint sp');
      } else {
        record('crit', p.name, p.msg(r));
        await pg.query('rollback to savepoint sp');
      }
    }
  } finally {
    await pg.query('rollback');
  }
}

// ============================================================
async function checkCustomerDataLeak() {
  if (!probeUid || !targetUid) {
    record('warn', 'data-leak', 'skipped — provide both --probe-uid and --target-uid');
    return;
  }
  console.log(fmt('bold', '\n=== 5. Customer-to-customer data isolation ==='));
  await pg.query('begin');
  try {
    await pg.query('set local role authenticated');
    await pg.query(`set local request.jwt.claims to '{"sub":"${probeUid}","role":"authenticated"}'`);
    const probes = [
      ['profiles', 'id'],
      ['deals', 'customer_id'],
      ['tax_engagements', 'customer_id'],
      ['customer_events', 'customer_id'],
      ['inquiries', 'user_id'],
      ['documents', 'user_id'],
      ['invoices', 'user_id'],
    ];
    let leaks = 0;
    for (const [tbl, col] of probes) {
      await pg.query('savepoint sp');
      const r = await pg.query(
        `select count(*)::int n from public.${tbl} where ${col} = $1`,
        [targetUid]
      ).catch(e => ({ error: e.message }));
      if (r.error) {
        record('warn', 'leak:' + tbl, 'query failed: ' + r.error.slice(0, 60));
        await pg.query('rollback to savepoint sp');
      } else if (r.rows[0].n === 0) {
        record('ok',   'leak:' + tbl, '0 rows visible (good)');
        await pg.query('release savepoint sp');
      } else {
        record('crit', 'leak:' + tbl, `${r.rows[0].n} rows of target leaked`);
        leaks++;
        await pg.query('release savepoint sp');
      }
    }
    if (leaks === 0) record('ok', 'data-isolation', 'no cross-customer leaks detected');
  } finally {
    await pg.query('rollback');
  }
}

// ============================================================
async function checkSecurityHeaders() {
  if (!site) {
    record('warn', 'headers', 'skipped — no --site provided');
    return;
  }
  console.log(fmt('bold', '\n=== 6. HTTP security headers on ' + site + ' ==='));
  const r = await rest('HEAD', site, {});
  const required = {
    'strict-transport-security': 'HSTS',
    'x-content-type-options':    'X-Content-Type-Options',
    'x-frame-options':           'X-Frame-Options',
    'referrer-policy':           'Referrer-Policy',
    'permissions-policy':        'Permissions-Policy',
  };
  for (const [h, label] of Object.entries(required)) {
    const v = r.headers[h];
    if (v) record('ok',   'header:' + label, String(v).slice(0, 80));
    else   record('warn', 'header:' + label, 'missing');
  }
}

// ============================================================
async function main() {
  console.log(fmt('bold', `🔐 Supabase Security Audit · ${new Date().toISOString()}\n`));
  await pg.connect();
  await pg.query('reset role').catch(() => {});
  try {
    await checkRlsCoverage();
    await checkAnonExposure();
    await checkUpdatePolicies();
    await checkPrivilegeEscalationLive();
    await checkCustomerDataLeak();
    await checkSecurityHeaders();
  } finally {
    await pg.end();
  }

  // Summary
  const sum = { ok: 0, warn: 0, crit: 0 };
  for (const f of findings) sum[f.level]++;
  console.log(fmt('bold', '\n=== Summary ==='));
  console.log(`  ${fmt('ok',   '✅ pass')} : ${sum.ok}`);
  console.log(`  ${fmt('warn', '🟡 warn')} : ${sum.warn}`);
  console.log(`  ${fmt('crit', '🚨 crit')} : ${sum.crit}`);
  process.exit(sum.crit > 0 ? 1 : 0);
}

main().catch(e => { console.error('FATAL:', e); process.exit(2); });


path: scripts/hotfix-role-lock.sql
content: -- ============================================================
-- Hotfix template: lock down role / email columns on a "profiles"-style table.
--
-- Apply this when `audit.js` reports:
--   🚨 policy:<table>.<name> — UPDATE policy lacks WITH CHECK on role/email
--
-- HOW TO USE:
--   1. Replace `public.profiles` below with the affected table name if
--      different (typically it really is `profiles` / `users` / `members`).
--   2. Confirm the column name is `role` (the rest of the policy assumes it).
--   3. Run via: node ~/.openclaw/workspace/projects/oganim/deploy/run-migration.cjs <this-file>
--      ...or the equivalent migration tool for the project.
--
-- The migration is idempotent. Safe to re-run.
-- ============================================================

reset role;

-- 1. Replace the existing self-update policy with one that locks role + email.
drop policy if exists profiles_self_update on public.profiles;

create policy profiles_self_update
  on public.profiles
  for update
  using (auth.uid() = id)
  with check (
    auth.uid() = id
    and role  is not distinct from (select role  from public.profiles where id = auth.uid())
    and email is not distinct from (select email from public.profiles where id = auth.uid())
  );

-- 2. Belt-and-suspenders DB trigger: even if the policy is later changed by
--    mistake, this trigger rejects role mutations from any non-staff caller.
create or replace function public.tg_profile_role_lock()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
  caller_is_staff boolean;
begin
  -- service_role / postgres bypass: auth.uid() is null for those.
  if auth.uid() is null then
    return NEW;
  end if;

  if NEW.role is distinct from OLD.role then
    select coalesce(
             (select role in ('admin','agent') from public.profiles where id = auth.uid()),
             false)
      into caller_is_staff;
    if not caller_is_staff then
      raise exception 'profile.role can only be changed by staff (caller=%)', auth.uid()
        using errcode = '42501';
    end if;
  end if;
  return NEW;
end;
$$;

drop trigger if exists trg_profile_role_lock on public.profiles;
create trigger trg_profile_role_lock
  before update of role on public.profiles
  for each row execute function public.tg_profile_role_lock();


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)