App check set
CurrentApp check set v1.0
The rules that answer the row-level security, object-level authorisation and CORS rows of rubric v1.0 for an app or a template, and read its bundles for credentials. Each listing's published files are read as text every night at the version it pins; nothing is installed, built or run, and no deployed app is visited. Released 2026-10-01, and never edited after release: a change is a new version.
sha256:a26a2bfa0436a6dcb57fb5e5e6a63596b8a9968bd0799dd4b24c5734d4e17bf1
SHA-256 over the line ooruby-app-checks/v1 and the set's RFC 8785 JSON, printed in full at the foot of this page.
Rules running
5 of 5
none without a hand count
Listings read
54 of 55
42 apps and 13 templates
True matches
6
listings, counted by hand
False matches
0
8 in the draft, before release
Counted by hand on 2026-10-01: every rule, enabled or not, over every app and template listing at the version it pins, read twice. As the nightly job reads it: 1,337 files, 36 MB. And over every text file, to look for what the nightly reading misses: 6,734 files, 198 MB. Every match was read in context, with any credential value blanked. A rule that finds nothing real and something false does not run; zero and zero does.
- 01
Table left without row-level security
- What it reads
- Every SQL file under a supabase/migrations folder the package ships, at any depth, grouped by the folder each app sits in and applied in file-name order to an empty model of the public schema: tables created, renamed, moved and dropped; row-level security switched on and off; policies created, altered, renamed and dropped; the statements inside a DO block and a plain EXECUTE read too. Read as text. Nothing is applied to any database.
- What counts
- A table the migrations create in the public schema and never leave under row-level security. In a Supabase project such a table can be read and written by anyone holding the project's public key, which the app ships to every browser by design. An app whose migrations switch row-level security on in a way a reading cannot follow (an EXECUTE of SQL built at run time, an event trigger that does it for every new table) is counted as undetermined, never as a match.
Matches
Constructedcreate table public.notes ( id uuid primary key default gen_random_uuid(), owner uuid references auth.users, body text ); -- and no "alter table public.notes enable row level security" in any later migration
No case was found: no listing in the catalogue ships Supabase migrations, and every table in the study's sample of AI-built apps was put under row-level security.
Leaves alone
create table public.notes ( id uuid primary key default gen_random_uuid(), owner uuid references auth.users, body text ); alter table public.notes enable row level security;
The same table, put under row-level security by a later statement. A test fails the build if the rule ever matches it.
Hand count
0 true · 0 false · 0 missed
No listing ships a supabase/migrations folder, so there was nothing to read. 3 packages ship SQL migrations for databases only their own process reaches (SQLite, and Postgres through Drizzle), which this check does not read by design.
- The same reading, counted by hand on ooruby App Audit #1's 20-app subsample: 136 tables, none flagged and none missed.
What this does not cover: A reading of files cannot prove a policy is enforced at run time: whether these migrations were applied, whether row-level security was switched on another way (the dashboard, SQL run by hand), or what a table holds. A database only the app's own server can reach (SQLite, or Postgres behind a server that holds the only connection) needs no row-level security for this reason, and its migrations are not read.
- 02
Policy that lets any caller change every row
- What it reads
- Every SQL file under a supabase/migrations folder the package ships, at any depth, grouped by the folder each app sits in and applied in file-name order to an empty model of the public schema: tables created, renamed, moved and dropped; row-level security switched on and off; policies created, altered, renamed and dropped; the statements inside a DO block and a plain EXECUTE read too. Read as text. Nothing is applied to any database. Each policy is followed through every later statement that changes or drops it.
- What counts
- A permissive policy for ALL, UPDATE or DELETE, on a table under row-level security, granted to public, anon or authenticated, whose USING expression is the constant true or asks only whether the caller is signed in (auth.uid() IS NOT NULL, auth.role() = 'authenticated'). It lets a caller change or delete any row by naming its id, which is what object-level authorisation exists to prevent.
Matches
Seen in the studycreate policy "Users can update items" on public.items for update to authenticated using (true);
The study counted 34 write policies of this kind in AI-built apps and confirmed every one by hand: the expression is the constant true or asks only whether the caller is signed in. Written out here with invented names.
Leaves alone
create policy "Owners can update their items" on public.items for update to authenticated using (auth.uid() = owner_id);
A policy that looks at which row is the caller's. This check does not try to judge such an expression, so it never matches one. A test fails the build if the rule ever matches it.
Hand count
0 true · 0 false · 0 missed
No listing ships a supabase/migrations folder, so there was nothing to read.
- The same reading, counted by hand on ooruby App Audit #1: all 34 policies it flagged were what it says, and none of the 156 other write policies read in its subsample was missed.
What this does not cover: Whether the app means every account to edit everything (an admin tool whose only account is its owner's), whether sign-up is open (a dashboard setting), and anything about a policy whose expression looks at the row. Whether the migrations were applied is not known. Policies that let anyone read every row are not counted here.
- 03
Server that answers every origin
- What it reads
- Every JavaScript and TypeScript file read from the package, bundles included and comment lines left out, because an app's server usually lives in its bundle rather than in an api folder. The nightly reading takes migrations, env files and server files first, then the four largest code files, then the rest, up to 40 files and 4 MB a package: caps chosen to keep a night's reading inside the job's time, not measured limits of the method.
- What counts
- Access-Control-Allow-Origin set to a literal * (an object entry, a header call, or a name and value pair), a CORS option of origin: "*", or a CORS helper called with no options, which answers every origin. Any web page a person has open can then call that server from their browser and read the answer.
Matches
Seen in the catalogueconst HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", }; function withHeaders(res) { for (const [key, value] of Object.entries(HEADERS)) res.setHeader(key, value); }From the local server a command-line app in the catalogue starts, with its names changed.
Leaves alone
function cors(req, res) { const origin = req.headers.origin || ""; if (ALLOWED_ORIGINS.has(origin)) res.setHeader("access-control-allow-origin", origin); }An origin answered only when it is on a list. Seen in the catalogue too, and left alone. A test fails the build if the rule ever matches it.
Hand count
6 true · 0 false · 1 missed · the nightly reading sees 6 of 6
Every code file of the 54 packages read (5,230), and every line in them that names Access-Control-Allow-Origin, a CORS helper or origin: true (36 lines), read in context.
- Six command-line apps start a local web server that sets the header to * on every response, in ten files between them: a literal in a header object, in a header call, a CORS helper with no options, and a websocket server's origin option. Each is what the rule says.
- Missed: one app's local gateway takes its origin from a setting that falls back to * when none is set, written through a variable the rule does not follow.
- Left alone, correctly: three servers that echo an origin only when it is on a list, a client reading the header from a response, a list of header names in a bundled HTTP library, and a scanner's own pattern for this shape.
- Before the picker correction below, the nightly reading saw five of the six.
What this does not cover: Whether it matters. A server that checks a token sent in a header gives a foreign page nothing it could not do without the browser, and a server bound to the loopback address is reachable only from pages open on the same machine (which is still every page that machine opens). A value chosen at run time, such as a variable that falls back to *, is not read: the hand count found one such server, and it is not in the figure.
- 04
Echoed origin with credentials allowed
- What it reads
- Every JavaScript and TypeScript file read from the package, bundles included and comment lines left out, because an app's server usually lives in its bundle rather than in an api folder. The nightly reading takes migrations, env files and server files first, then the four largest code files, then the rest, up to 40 files and 4 MB a package: caps chosen to keep a night's reading inside the job's time, not measured limits of the method.
- What counts
- A server that copies the request's Origin header into Access-Control-Allow-Origin, or sets the CORS option origin: true, in a file that also allows credentials. Any site can then send a visitor's cookies with a request and read the answer.
Matches
Constructedres.setHeader("Access-Control-Allow-Origin", req.headers.origin); res.setHeader("Access-Control-Allow-Credentials", "true");Neither the catalogue nor the study had a case of this shape.
Leaves alone
if (allowed.has(req.headers.origin)) { res.setHeader("Access-Control-Allow-Origin", req.headers.origin); res.setHeader("Vary", "Origin"); }An origin echoed only from a list, with no credentials. Seen in the catalogue, and left alone. A test fails the build if the rule ever matches it.
Hand count
0 true · 0 false · 0 missed
The same 5,230 code files and 36 lines.
- No server echoes any origin with credentials allowed. One sends a wildcard and credentials together, which browsers refuse to honour, and is counted as a wildcard.
What this does not cover: Whether the server reads cookies at all, whether the echo is guarded by a check this reading does not follow, and anything decided at run time.
- 05
Credential in a recognised live format
- What it reads
- Every file read from the package, bundles included. A match is counted and withheld: the listing's page says how many, never the value, the file or the line.
- What counts
- A string in a format the check names: a Supabase service-role or secret key, a live or test payment secret key, a model provider's API key, a code host, messaging, cloud or email service token, or a private key together with its body. Not counted: anything written as an example (a run of x, your, example, too few distinct characters), a Supabase anon or publishable key, and the demo key Supabase's local tools print for everybody, which are public by design.
Matches
Seen in the studyconst OPENAI_API_KEY = "<openai key, value withheld>";
The study found a model provider's key in its live format in code an AI-built app ships to the browser. Written out here with the value replaced by its kind, as the reading by hand sees it: a key is never printed.
Leaves alone
const match = pem.match(/-----BEGIN PRIVATE KEY-----[\s\S]+?-----END PRIVATE KEY-----/);
Code that reads a key somebody supplies. Before release the check matched the header alone and misread code like this (see the hand count); a private key now counts only with its body. A test fails the build if the rule ever matches it.
Hand count
0 true · 0 false · 0 missed
Every text file of the 54 packages read (6,734), and every line that assigns a value of 16 characters or more, mixing letters and digits, to a name containing key, secret, token or password (77 lines), read with the value blanked.
- No credential in a format the check names. Of the assignments read for misses, two are analytics and telemetry ingestion keys, which those services issue to be shipped in client code and which are outside the formats the check names; the rest are cache and domain-separation constants, field names, translated interface strings, a public key and test values.
Before release, 8 false: The draft matched a private-key header on its own. All eight such matches, in two packages, were code that reads a key somebody supplies, a form's placeholder text, or a library checking a header; none carried a key. A private key now counts only when at least 60 base64 characters and the matching END line follow the header.
What this does not cover: Whether a key still works (none is ever tried, because trying one would be using it), credentials in formats the check does not name, anything in a repository's history, and files the nightly reading does not reach when a package is over its caps.
How the count was taken
Every App and Template listing in the catalogue, at the version it pins, from the npm registry's own tarball, checked against the registry's digest and read in memory as text. The nightly job reads the same published files through jsDelivr; the count read the tarball the catalogue's scans already verify. Not read: omniroute (tarball over the 100 MB the count will download).
A match is true when it is what the rule says it looks for, read in context; false when the rule misread something else. Lines that might hold a case the rule did not match were set aside and read too, and what they found is counted as missed. The raw results, counts only, are committed beside the rules with the method and the date; where each match sits is not published, because a finding attached to a location is a target.
The nightly picker first took files under server folders only, and missed one app whose server is a server.ts beside its other source. It now also takes files named server or proxy, and the nightly reading sees all six wildcard servers.
Canonical app check set JSON
{"released":"2026-10-01","rubric":"1.0","rules":[{"counterExample":{"note":"The same table, put under row-level security by a later statement.","text":"create table public.notes (\n id uuid primary key default gen_random_uuid(),\n owner uuid references auth.users,\n body text\n);\nalter table public.notes enable row level security;"},"counts":"A table the migrations create in the public schema and never leave under row-level security. In a Supabase project such a table can be read and written by anyone holding the project's public key, which the app ships to every browser by design. An app whose migrations switch row-level security on in a way a reading cannot follow (an EXECUTE of SQL built at run time, an event trigger that does it for every new table) is counted as undetermined, never as a match.","enabled":true,"example":{"note":"No case was found: no listing in the catalogue ships Supabase migrations, and every table in the study's sample of AI-built apps was put under row-level security.","provenance":"constructed","text":"create table public.notes (\n id uuid primary key default gen_random_uuid(),\n owner uuid references auth.users,\n body text\n);\n-- and no \"alter table public.notes enable row level security\" in any later migration"},"id":"rls_table_without_rls","label":"Table left without row-level security","maps":["CWE-285","A01:2021"],"method":"Every SQL file under a supabase/migrations folder the package ships, at any depth, grouped by the folder each app sits in and applied in file-name order to an empty model of the public schema: tables created, renamed, moved and dropped; row-level security switched on and off; policies created, altered, renamed and dropped; the statements inside a DO block and a plain EXECUTE read too. Read as text. Nothing is applied to any database.","notCovered":"A reading of files cannot prove a policy is enforced at run time: whether these migrations were applied, whether row-level security was switched on another way (the dashboard, SQL run by hand), or what a table holds. A database only the app's own server can reach (SQLite, or Postgres behind a server that holds the only connection) needs no row-level security for this reason, and its migrations are not read.","row":"rls","severity":"warning"},{"counterExample":{"note":"A policy that looks at which row is the caller's. This check does not try to judge such an expression, so it never matches one.","text":"create policy \"Owners can update their items\" on public.items\n for update to authenticated\n using (auth.uid() = owner_id);"},"counts":"A permissive policy for ALL, UPDATE or DELETE, on a table under row-level security, granted to public, anon or authenticated, whose USING expression is the constant true or asks only whether the caller is signed in (auth.uid() IS NOT NULL, auth.role() = 'authenticated'). It lets a caller change or delete any row by naming its id, which is what object-level authorisation exists to prevent.","enabled":true,"example":{"note":"The study counted 34 write policies of this kind in AI-built apps and confirmed every one by hand: the expression is the constant true or asks only whether the caller is signed in. Written out here with invented names.","provenance":"study","text":"create policy \"Users can update items\" on public.items\n for update to authenticated\n using (true);"},"id":"bola_any_row_policy","label":"Policy that lets any caller change every row","maps":["CWE-639","API1:2023"],"method":"Every SQL file under a supabase/migrations folder the package ships, at any depth, grouped by the folder each app sits in and applied in file-name order to an empty model of the public schema: tables created, renamed, moved and dropped; row-level security switched on and off; policies created, altered, renamed and dropped; the statements inside a DO block and a plain EXECUTE read too. Read as text. Nothing is applied to any database. Each policy is followed through every later statement that changes or drops it.","notCovered":"Whether the app means every account to edit everything (an admin tool whose only account is its owner's), whether sign-up is open (a dashboard setting), and anything about a policy whose expression looks at the row. Whether the migrations were applied is not known. Policies that let anyone read every row are not counted here.","row":"bola","severity":"warning"},{"counterExample":{"note":"An origin answered only when it is on a list. Seen in the catalogue too, and left alone.","text":"function cors(req, res) {\n const origin = req.headers.origin || \"\";\n if (ALLOWED_ORIGINS.has(origin)) res.setHeader(\"access-control-allow-origin\", origin);\n}"},"counts":"Access-Control-Allow-Origin set to a literal * (an object entry, a header call, or a name and value pair), a CORS option of origin: \"*\", or a CORS helper called with no options, which answers every origin. Any web page a person has open can then call that server from their browser and read the answer.","enabled":true,"example":{"note":"From the local server a command-line app in the catalogue starts, with its names changed.","provenance":"catalogue","text":"const HEADERS = {\n \"Access-Control-Allow-Origin\": \"*\",\n \"Access-Control-Allow-Methods\": \"GET, POST, OPTIONS\",\n};\nfunction withHeaders(res) {\n for (const [key, value] of Object.entries(HEADERS)) res.setHeader(key, value);\n}"},"id":"cors_wildcard","label":"Server that answers every origin","maps":["CWE-942","A05:2021"],"method":"Every JavaScript and TypeScript file read from the package, bundles included and comment lines left out, because an app's server usually lives in its bundle rather than in an api folder. The nightly reading takes migrations, env files and server files first, then the four largest code files, then the rest, up to 40 files and 4 MB a package: caps chosen to keep a night's reading inside the job's time, not measured limits of the method.","notCovered":"Whether it matters. A server that checks a token sent in a header gives a foreign page nothing it could not do without the browser, and a server bound to the loopback address is reachable only from pages open on the same machine (which is still every page that machine opens). A value chosen at run time, such as a variable that falls back to *, is not read: the hand count found one such server, and it is not in the figure.","row":"cors","severity":"warning"},{"counterExample":{"note":"An origin echoed only from a list, with no credentials. Seen in the catalogue, and left alone.","text":"if (allowed.has(req.headers.origin)) {\n res.setHeader(\"Access-Control-Allow-Origin\", req.headers.origin);\n res.setHeader(\"Vary\", \"Origin\");\n}"},"counts":"A server that copies the request's Origin header into Access-Control-Allow-Origin, or sets the CORS option origin: true, in a file that also allows credentials. Any site can then send a visitor's cookies with a request and read the answer.","enabled":true,"example":{"note":"Neither the catalogue nor the study had a case of this shape.","provenance":"constructed","text":"res.setHeader(\"Access-Control-Allow-Origin\", req.headers.origin);\nres.setHeader(\"Access-Control-Allow-Credentials\", \"true\");"},"id":"cors_reflect_credentials","label":"Echoed origin with credentials allowed","maps":["CWE-942","CWE-346","A05:2021"],"method":"Every JavaScript and TypeScript file read from the package, bundles included and comment lines left out, because an app's server usually lives in its bundle rather than in an api folder. The nightly reading takes migrations, env files and server files first, then the four largest code files, then the rest, up to 40 files and 4 MB a package: caps chosen to keep a night's reading inside the job's time, not measured limits of the method.","notCovered":"Whether the server reads cookies at all, whether the echo is guarded by a check this reading does not follow, and anything decided at run time.","row":"cors","severity":"critical"},{"counterExample":{"note":"Code that reads a key somebody supplies. Before release the check matched the header alone and misread code like this (see the hand count); a private key now counts only with its body.","text":"const match = pem.match(/-----BEGIN PRIVATE KEY-----[\\s\\S]+?-----END PRIVATE KEY-----/);"},"counts":"A string in a format the check names: a Supabase service-role or secret key, a live or test payment secret key, a model provider's API key, a code host, messaging, cloud or email service token, or a private key together with its body. Not counted: anything written as an example (a run of x, your, example, too few distinct characters), a Supabase anon or publishable key, and the demo key Supabase's local tools print for everybody, which are public by design.","enabled":true,"example":{"note":"The study found a model provider's key in its live format in code an AI-built app ships to the browser. Written out here with the value replaced by its kind, as the reading by hand sees it: a key is never printed.","provenance":"study","text":"const OPENAI_API_KEY = \"<openai key, value withheld>\";"},"id":"secret_live_format","label":"Credential in a recognised live format","maps":["CWE-798","CWE-540","A07:2021"],"method":"Every file read from the package, bundles included. A match is counted and withheld: the listing's page says how many, never the value, the file or the line.","notCovered":"Whether a key still works (none is ever tried, because trying one would be using it), credentials in formats the check does not name, anything in a repository's history, and files the nightly reading does not reach when a package is over its caps.","row":"secrets","severity":"critical"}],"version":"1.0"}Version archive