Every finding below was verified against the live database or a live request — nothing here is theoretical. Two were genuinely critical and already fixed. All fixes were tested against a real or throwaway account before and after, so nothing here risked Kuru's live data while closing the gap.
The single largest finding of this audit. Every one of the 100 functions in the database — the entire write and read surface of the app — carried a direct execute grant to the unauthenticated anon role, separate from and in addition to each function's intended authenticated-only grant. This is a Supabase project default (new functions are granted to anon automatically at creation) that none of the 90 migrations to date ever revoked — invisible in all normal testing because a real logged-in session behaves identically either way.
Proof: a bare POST to start_job_card with only the public anon key — no login, no session, nothing — successfully inserted a real row into jobs/assets/job_assets with company_id = NULL. Most of the 100 functions were saved from being directly exploitable by their own internal role check correctly rejecting a null caller identity — but that was luck, not policy, and two functions (start_job_card, ensure_rig_assets) have no such check by original design and were genuinely wide open.
Fix: revoked the anon execute grant — both the direct grant and the PUBLIC-inherited path — from every function, and changed the project default so new functions stop getting it automatically. Verified in three independent ways: the exact exploit now returns 401 permission denied; a fresh real login (genuine password auth → genuine session token) still calls the same function successfully; and the full WhatsApp job-card flow, which internally mints and uses real staff sessions, still runs end-to-end.
Every signature, job-card photo, stock-take variance sheet, and invoice PDF — across both apps, 10 upload call sites — has been throwing a row-level-security error since the multi-tenant rewrite added company scoping to documents, because nothing ever stamped company_id on the row. The underlying file uploads to Storage first and succeeds, so the evidence photo exists but nothing points at it — 14 of 18 real objects in the bucket were orphaned this way.
Proof: replayed the exact client insert as a real Kuru staff member — it failed with new row violates row-level security policy.
Fix: a BEFORE INSERT trigger stamps company_id from the caller's own staff row when it's missing. Verified the exact failing insert now succeeds with the correct company, and that an explicit wrong company_id still gets rejected.
No API key required, and the app secret that gates its signature check was never set in production — confirmed with a bare, header-less POST returning 200 OK. The webhook mints a real Supabase session for whatever staff phone number the request claims, so anyone on the internet could impersonate any staff member with zero credentials.
Fix: generated and set a strong WHATSAPP_APP_SECRET (swap for Meta's real App Secret once Business verification completes). Verified unsigned requests now get 401 and correctly-signed ones still work. Applied the same fix to the dormant peach-webhook for consistency.
The shared storage bucket's read/update/delete policies checked only the bucket name — no tenant scoping at all. Any authenticated user from any company, including a brand-new free-trial signup, could enumerate and read every other company's signatures, driver licence photos, and POD photos.
Fix: read/update/delete now require a matching, company-scoped documents row (reliable since the fix above). Upload stays open since a file is always uploaded before its tracking row exists. Verified: Kuru's own staff can still read Kuru's real files; a throwaway second company gets zero rows back for the same object.
The single-use-code guard checked use_count optimistically but never confirmed its own update actually won the race before recording the redemption — two simultaneous purchases could both claim the same one-time discount. Fixed by checking the update's returned row before writing the redemption record.
Supabase Auth's platform-level minimum was 6 characters while every signup path in the app already enforces 8 — raised the platform floor to match.
The BackOffice site (handling sensitive business and customer data) shipped with no X-Frame-Options, HSTS, or referrer-policy headers. Added all three to both live sites — verified headers present, app still renders correctly. A full Content-Security-Policy was deliberately not added: it risks silently breaking React Native Web's runtime style injection, and that needs visual regression testing this pass didn't have time for — see recommendations.
company-signup is deliberately public and validates its inputs well, but has no per-IP or per-email throttle — and creating accounts through the admin API bypasses Supabase's own public signup rate limits entirely. Realistic risk is scripted mass-creation of free-trial companies, not data theft. See recommendations.
~24 npm audit findings per repo, nearly all in Expo/Metro build tooling that never ships to end users. One real-looking lead (a DOMPurify advisory reachable via jsPDF's HTML renderer) turned out to be a dead code path — the code comments confirm that renderer was deliberately abandoned in favor of a canvas-based approach specifically because it was unreliable, and grep confirms it's never called.