SENTRISSample report

This is the whole report.

Below is an unedited Sentris scan, rendered by the same code that renders a paying customer's. Nothing is trimmed for the screenshot and nothing is held back: what you are looking at is what lands in your dashboard.

Repository
sentris/demo-vulnerable-app
Scanned
14 August 2026

That repo is ours and it is broken on purpose: it is one of the fixtures every rule check is tested against on every commit. We do not publish customer findings, so the only report we can show you in full is one about a repo we own.

F

Security grade

At least one critical exposure.

9 exposures · 3 critical

5 confirmed : we read the thing itself, not a pattern that resembles it. 4 potential: the check is missing; whether it is reachable is not proven.

critical×3high×4medium×2
  • criticalC2 · confirmed

    Stripe live secret key exposed in your repository

    sk_live_••••ABCD: .env

    Fix

    Remove the Stripe secret key (sk_live_…) from client code. Use the publishable key (pk_live_…) in the browser and keep sk_live_ server-side only. Roll it now: Stripe Dashboard → Developers → API keys → Roll key.
  • criticalC1 · confirmed

    Policy "anyone can write orders" on public.orders allows unrestricted access

    create policy "anyone can write orders" on public.orders for all using (true);

    Fix

    A for all policy with true lets anyone holding the anon key write this table. Scope it to the row owner:
    
    drop policy "anyone can write orders" on public.orders;
    
    create policy "anyone can write orders"
      on public.orders for all
      to authenticated
      using (auth.uid() = user_id)
      with check (auth.uid() = user_id);
  • criticalC1 · potential

    Table public.leads has no Row Level Security

    create table public.leads ( id uuid primary key, email text, phone text );, no "enable row level security" for public.leads anywhere in your SQL

    Fix

    Tables in the public schema are readable through your project's anon key unless RLS is on. Enable it and add a policy that scopes rows to their owner:
    
    alter table public.leads enable row level security;
    
    create policy "Owners read their own rows"
      on public.leads for select
      to authenticated
      using (auth.uid() = user_id);
    
    If you already enabled RLS from the Supabase dashboard, add the statement to a migration anyway so the next deploy cannot silently drop it.
  • highC8 · confirmed

    .env is committed to the repository

    An environment file with non-placeholder values is tracked in git.

    Fix

    Assume every value in this file is public. It is in every clone, every fork, and in the history of anyone who has ever pulled, deleting the file now does not change any of that.
    
    1. Rotate every credential in it. This is the step that actually closes the hole; the rest is hygiene.
    2. Stop tracking it and keep the local copy:
    
    git rm --cached .env
    echo ".env*.local\n.env" >> .gitignore
    git commit -m "stop tracking .env"
    
    3. Commit a `.env.example` with the KEYS and no values, so the next person knows what to set without being handed your secrets.
    
    4. Purging history (`git filter-repo`, BFG) is optional and rewrites every hash. Do it if the repo is public, but only after step 1, never instead of it.
  • highC3 · potential

    API route src/app/api/orders/[id]/route.ts takes a caller-supplied id with no auth check

    Route handler reads a caller-controlled identifier and queries data, and no authentication or ownership check appears in the file, while using a service_role client that bypasses RLS

    Fix

    Static analysis cannot prove this is exploitable: it can only show the check is missing. Verify by calling the route as user A with user B's id.
    
    Identify the caller and scope the query to them:
    
    const { data: { user } } = await supabase.auth.getUser();
    if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    
    const { data, error } = await supabase
      .from("items")
      .select("*")
      .eq("id", id)
      .eq("user_id", user.id)   // ownership, not just the id from the URL
      .single();
    
    This route uses a service_role client, which ignores RLS: the ownership check here is the ONLY thing standing between a caller and every other user's rows. Prefer a request-scoped anon client so RLS still applies.
  • highC7 · potential

    API route src/app/api/profile/route.ts writes the whole request body to the database

    `body`, the parsed request body, is passed straight to a write on profiles, so every column the caller includes in the JSON is set, not only the ones the form sends

    Fix

    Verify by sending a column the form never offers: `{"role":"admin"}`, `{"is_admin":true}`, `{"credits":999999}`, `{"user_id":"<someone else>"}`, and reading the row back.
    
    Being allowed to update a row is not the same as being allowed to update every column of it, and Row Level Security does not draw that line: a policy that says "you may update your own row" says nothing about which fields you may touch.
    
    Name the columns you accept, and take the rest from the server:
    
    import { z } from "zod";
    
    const Body = z.object({
      display_name: z.string().min(1).max(80),
      bio: z.string().max(500).optional(),
    }).strict();   // .strict() rejects anything else instead of ignoring it
    
    const parsed = Body.safeParse(await req.json());
    if (!parsed.success) return NextResponse.json({ error: "Bad request" }, { status: 400 });
    
    await supabase
      .from("profiles")
      .update(parsed.data)          // only the fields above
      .eq("id", user.id);           // the row, decided by the session
    
    Destructuring works just as well as a schema: `const { display_name, bio } = await req.json()` is an allowlist too. What must not happen is the parsed object reaching the write untouched.
  • highC6 · potential

    Function public.get_orders_for_user runs with its owner's rights and never checks the caller

    create or replace function public.get_orders_for_user(p_user_id uuid) returns setof orders language plpgsql se: no auth.uid(), auth.jwt() or equivalent anywhere in the function

    Fix

    Verify by calling it with nothing but your project's anon key:
    
    curl -X POST "https://<ref>.supabase.co/rest/v1/rpc/get_orders_for_user" \
      -H "apikey: <anon key>" -H "Content-Type: application/json" -d '{}'
    
    Every function in the public schema is an endpoint. `security definer` makes it run as its owner, so Row Level Security on the tables it reads does not apply to whoever called it: the policies are still there, they just are not being asked. Derive the identity inside the function instead of trusting an argument:
    
    create or replace function public.get_orders_for_user(...)
    returns setof orders
    language sql
    security definer
    set search_path = ''
    as $$
      select * from public.orders
      where user_id = (select auth.uid());   -- the caller, not an argument
    $$;
    
    Then take away the reach it does not need:
    
    revoke execute on function public.get_orders_for_user from anon;
    
    If this function is only ever called by a trigger or from your own server with the service_role key, the revoke alone closes it.
  • mediumC4 · confirmed

    Storage bucket "invoices" is public

    insert into storage.buckets (id, name, public) values ('invoices', 'invoices', true);

    Fix

    Every object in a public bucket is readable by anyone with the URL, no key, no login. That is correct for avatars and marketing assets, and a leak for anything user-owned (uploads, documents, invoices).
    
    If it should be private, flip the bucket and serve files through signed URLs:
    
    update storage.buckets set public = false where id = 'invoices';
    
    create policy "Owners read their own files"
      on storage.objects for select
      to authenticated
      using (bucket_id = 'invoices' and owner = auth.uid());
    
    Then hand out links with createSignedUrl(path, 60) instead of getPublicUrl(path).
  • mediumC17 · confirmed

    Storage bucket "invoices" accepts any file type and any size

    the insert into storage.buckets lists neither file_size_limit nor allowed_mime_types, so the storage service enforces neither. Any caller holding the anon key can upload anything, of any size, directly, no route handler of yours is involved.

    Fix

    Set both limits on the bucket, where they are enforced on every path in:
    
    update storage.buckets
       set file_size_limit = 5242880,                       -- 5 MB
           allowed_mime_types = array['image/png','image/jpeg','image/webp']
     where id = 'invoices';
    
    Or in supabase/config.toml, so a fresh environment gets the same rules:
    
    [storage.buckets.invoices]
    public = false
    file_size_limit = "5MiB"
    allowed_mime_types = ["image/png", "image/jpeg", "image/webp"]
    
    Three things the mime list does not do, and that you still want:
    
    · It trusts the Content-Type the uploader sends. Check the file's magic bytes server-side if the content matters.
    · It does not stop `image/svg+xml` from containing script. Leave SVG off the list unless you need it, and serve it from a bucket you do not link to from your own origin.
    · It does not choose the filename. Generate the storage path yourself (`${user.id}/${crypto.randomUUID()}.png`): a caller-supplied name is how one user overwrites another's file.

Checked

  • · Row Level Security on every table in your public schema, and policies that grant unrestricted access via using (true)
  • · Hardcoded secrets committed to the repo: service_role, Stripe, and AI-provider keys, including .env files
  • · API routes that take a caller-supplied id with no auth check (reported as potential: two requests with your own throwaway test accounts turn it into a proof)
  • · Supabase storage buckets marked public in config.toml, a migration, or a createBucket() call

Out of scope

  • · Server infrastructure & DDoS
  • · Dependency CVEs
  • · TLS / cryptography depth
  • · Social engineering
  • · Business logic beyond access control
  • · Mobile apps
  • · Load / performance

Point-in-time, findings-based results. Sentris is not a penetration test, not a certification, and carries no warranty. We report reproducible exposures with evidence and a fix: we prove them, we do not exploit them.

Now run it on yours.

Scanning is free and takes no login. You see how many exposures you have before you pay for anything.

Scan my app