DAST scanner · passive

Client Exposure

Whatever a bundler ships to the browser is public. isitsecure reads the site's own JavaScript, filtered of vendor bundles, and looks for three kinds of leftovers: a Supabase service_role key, which bypasses Row-Level Security; URLs that point at localhost or staging; and process.env names the build never replaced. It sends no requests of its own.

DASTCriticalLowSecrets in client codeSnapshot-based

Three checks over one string

The input is a single string: the concatenated content of the site's first-party scripts. Vendor and framework bundles were removed by a shared filter before the scanner ever sees them, so a minified library cannot trigger a finding. Three independent checks run over that string.

First-party JS vendor bundles already filtered out createClient(U, "eyJhbGci…") fetch("http://localhost:4000/api") const db = process.env.DB_URL process.env.NEXT_PUBLIC_API no request is ever sent; only captured text is read JWT shape, then role claim decode the payload; role must equal service_role CRITICAL · confidence 0.98 Internal or non-production URL localhost, private IPs, .local .internal .test, staging-style hosts LOW · confidence 0.75 Un-replaced env reference process.env.X or import.meta.env.X, unless X is a public prefix LOW · confidence 0.7 each check stops at 10 findings · URLs and names reported once

1The scanner receives the first-party JavaScript of the page as one string. If it is empty there is nothing to do. Only first-party code is read: HTML, vendor bundles and third-party scripts are out of scope.

2Check one finds anything shaped like a JWT, decodes its payload, and keeps it only when the role claim is service_role. That is the one CRITICAL finding. The next scene walks through it.

3Check two matches URLs whose host is localhost, a loopback or private address, a .local, .internal or .test name, or a host that starts with staging, dev, qa and similar. A production bundle should not know those exist, so each is a LOW.

4Check three finds process.env.NAME and import.meta.env.NAME left in the shipped code. A bundler should have replaced them. The leftover leaks the variable's name, LOW. A name with a public prefix such as NEXT_PUBLIC_ is meant to ship and is skipped.

5Findings carry the page URL as their endpoint. Each check keeps at most 10 and reports a given URL, token or variable name once.

Built from the code, not a markdown doc

This scanner has no page under docs/scanners/. Everything here is taken from isitsecure/engine/scanners/client_exposure_scanner.py and the README scanner-table row ("Secrets in client JS: Supabase service_role keys, internal URLs, unreplaced env placeholders"). The regexes, the public-prefix list, severities and confidences are read directly from that file.

Why one JWT is Critical and another is ignored

A Supabase project ships two keys. The anon key is designed to sit in the browser, because Row-Level Security limits what it can do. The service_role key skips RLS entirely. The two look identical until the payload is decoded, so the scanner decodes it.

found in the bundle eyJhbGciOiJIUzI1NiJ9 . eyJyb2xlIjoi… . SflKxwRJSMeKKF2QT4… header payload signature never checked base64 decode the payload {"role": …} {"role": "anon"} {"role": "service_role"} ignored · anon key expected in the browser, RLS applies CRITICAL · service_role key exposed full read and write, RLS bypassed evidence keeps only the ends of the token: eyJhbGciOiJIUzI1…KF2QT4 first 16 characters, an ellipsis, last 6 · confidence 0.98

1The pattern looks for three base64url parts joined by dots, where the first two start with eyJ (the encoding of {"). Each distinct token is processed once.

2The middle part is base64-decoded into JSON. The signature is not verified: the scanner only needs to read the claim, not to trust the token.

3The role claim is lowercased and compared. anon is the key every Supabase front end ships, so it is ignored. So is any token that fails to decode or has another role.

4A role of service_role is CRITICAL. The finding keeps a redacted form of the token, never the full secret, and tells the owner to rotate the key and use it server-side only.

Why it matters

The finding text is blunt: the service_role key "bypasses Row-Level Security and grants full read/write access to the entire database. Anyone viewing the page can extract it." Rotation is the only fix once it has shipped, because the old key is already in every cached copy of the bundle.

What stays quiet

Two of the checks run on names and URLs, which are easy to over-match. These rows show what the code lets through and what it reports.

found in the first-party bundle verdict process.env.STRIPE_SECRETraw reference survived the build LOW · un-replaced environment variableleaks the name STRIPE_SECRET process.env.NEXT_PUBLIC_API_URLframework-public prefix quiet · meant to shipNEXT_PUBLIC_ is on the allowed list import.meta.env.VITE_SUPABASE_URLVite public prefix quiet · meant to shipVITE_ is on the allowed list "http://localhost:4000/api"development host in production code LOW · internal or non-production URLreported once per distinct URL "https://api.example.com/v1"public production host quiet · no internal host patternnot localhost, private, or staging-style

1Five strings the scanner might meet in a bundle. Two sets of rules decide them: a list of env-var prefixes that are allowed to ship, and a pattern for hosts that should not appear in production.

2A bare process.env.STRIPE_SECRET means the bundler left a server-side name in the output. The name must be upper case, at least three characters, and have no public prefix. LOW, because it leaks a name, not a value.

3NEXT_PUBLIC_ variables are copied into the bundle on purpose. Eight prefixes are allowed: NEXT_PUBLIC_, VITE_, REACT_APP_, PUBLIC_, VUE_APP_, GATSBY_, EXPO_PUBLIC_, NUXT_PUBLIC_.

4The import.meta.env form is handled by the same rule, so a VITE_ name is quiet too.

5A URL whose host is localhost, 127.0.0.1, 0.0.0.0, a 10.x, 192.168.x or 172.16-31.x address, or a .local, .internal or .test name is reported at LOW, with confidence 0.75.

6An ordinary production URL matches none of those patterns and is not reported. The scanner does not judge whether an API host is correct, only whether it looks like an internal one.

Vulnerable vs fixed client code

The fix is to keep the privileged key on the server and let only intentionally public values reach the bundle.

// shipped to every visitor in app.js
const admin = createClient(URL, "eyJhbGciOi…")   // payload: {"role":"service_role"}  CRITICAL
const api   = "http://localhost:4000/api"            // internal URL  LOW
const db    = process.env.DB_URL                    // un-replaced  LOW

Reference: what the code does

ItemValueWhere it comes from
Inputfirst-party JavaScript only, vendor and framework bundles filtered out (Next.js /_next/static/chunks/app|pages/ page chunks, webpack app bundles and sibling-brand CDN domains count as first-party); empty input returns nothingsnapshot.first_party_js_content
service_role JWTtoken matches eyJ….eyJ….sig; payload role lowercased equals service_role → CRITICAL, confidence 0.98; anon ignored_JWT_RE, _check_service_role_jwt
Internal URLhttp(s):// then localhost, 127.0.0.1, 0.0.0.0, 10.x, 192.168.x, 172.16-31.x, *.local / *.internal / *.test, or a host beginning with staging, stg, dev, qa, internal, preprod or uat → LOW, confidence 0.75_INTERNAL_URL_RE
Un-replaced envprocess.env.NAME or import.meta.env.NAME, NAME upper case, 3+ characters, no public prefix → LOW, confidence 0.7_PROCESS_ENV_RE, _IMPORT_META_ENV_RE, _PUBLIC_ENV_PREFIXES
Capsat most 10 findings per check; one finding per distinct token, URL or variable name_MAX_FINDINGS_PER_KIND
Evidencetoken shown as first 16 characters, ..., last 6redacted in _check_service_role_jwt
Findingcategory CLIENT_EXPOSURE, source DAST_URL, endpoint = page URLthe three DeepFinding builders
Networknonemodule docstring
Scan depthruns at both quick and deepfactory.py DAST list
Things it does not do

It decodes only JWTs, so a service_role key that has been split, encoded or loaded at runtime is not found, and other secrets (cloud keys, private API tokens) are left to the secret scanners. It does not read inline scripts in the HTML or third-party bundles. The host-prefix rule for staging names matches any host that merely starts with dev, qa and the like, for example developers.example.com, so a LOW here deserves a glance at the URL.