audit-secrets
Detect environment variables and secrets reaching the client bundle via loader return values, hydration state, or server-only modules imported from client code paths. Use for "audit secrets", "find le
SKILL.mdskills/audit-secrets/SKILL.md
GitHubPracht Audit Secrets
Pracht serializes loader return values into the pracht-state JSON script and
hydrates the client from them. Anything a loader returns ends up readable in the
browser. The Vite plugin also strips server-only exports from route files, but
it cannot save you from a value that flows into the return.
Prerequisites: pracht inspect requires a vite config that registers the
pracht plugin; the env-safety report under dist/client/_pracht/ requires a
prior pracht build. If the pracht MCP server is registered (see
https://pracht.resynapse.dev/docs/coding-agents#the-authoring-mcp-server), prefer its tools (inspect_routes, inspect_api,
inspect_build, doctor, verify) over shelling out.
Step 0: Run the native env safety check
Pracht has built-in env leak detection (see https://pracht.resynapse.dev/docs/env#client-leak-detection):
pracht buildfails when a client chunk references a non-public env var (anything notPRACHT_PUBLIC_-prefixed and not a Vite built-in), naming the variable, chunk, and likely source module.pracht verify(andpracht doctor) check the build-time env-safety report and re-run the literal chunk scan against an existingdist/clientoutput.- Client-side imports of
@pracht/core/env/server(serverEnv) fail the build.
Run pracht verify or pracht doctor (or a build) first and fold the
findings into the report.
Check vite.config.* for envSafety: { allow: [...] } / envSafety: false —
every allowlisted name and a disabled check are findings to review, since they
bypass the native gate. The native check detects references, not values, so
the dataflow steps below are still required.
Step 1: Identify "secret-shaped" identifiers
Build a regex of variable names treated as sensitive:
SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|PASSWORD|PASSPHRASE|SESSION[_-]?SECRET|JWT[_-]?SECRET|WEBHOOK[_-]?SECRET|DATABASE[_-]?URL|DB[_-]?URL|CONNECTION[_-]?STRING|CLIENT[_-]?SECRET|REFRESH[_-]?TOKEN
Also flag any direct process.env.X or context.env.X reference where X
matches the above.
Step 2: Server → client flow analysis
For every route file, trace whether a sensitive value reaches the loader's return value. Steps:
- Run
pracht inspect routes --jsonto enumerate routes. - For each route, read the loader.
- Build a small dataflow trace from sensitive identifiers (and
process.env.*/context.env.*reads) to thereturnstatement(s). - Flag any spread (
...row,...user,...env) that originates from a source containing secrets. - Flag direct returns of objects that name sensitive keys.
This is heuristic — favor over-flagging over silent leaks. Note false-positive risk in the report.
Step 3: Module import boundaries
Grep client-rendered files (route components, shells, the app root registered
as defineApp({ root }) or pages/_root.tsx, anything imported from them) for
imports of:
node:*builtins@pracht/adapter-*- Local
src/server/**modules - Modules whose top level reads
process.env.*
The Vite plugin strips the server-only exports loader, head, headers,
getStaticPaths, and markdown from route files when serving the client
query (middleware is not a route-file export — middleware lives in the
manifest); see https://pracht.resynapse.dev/docs/env#client-leak-detection. But a component that imports ../server/db will still
pull db into the client bundle. Flag those imports. The app root's browser
copy loses only dehydrate and the imports nothing else uses; setup(),
Root, hydrate(), and everything they import ship. A dehydrate bound in
the same destructuring as those (export const { setup, Root, dehydrate, hydrate } = createRoot(…)) or reached through export * ships with them, so
flag a server-only value passed into that call. A root whose other code calls
or re-exports dehydrate ships whole; flag its server-only imports too.
Step 4: Hidden surfaces
Check for accidental exposure outside loaders:
- The app root's
dehydrate()snapshot: it lands inpracht-stateasrootand in every route-state response, like loader data. For@pracht/querythat is every successful query cached during the request, so flag aqueryFnorsetQueryDatain a loader that caches a secret-bearing value. head()returns: rare, but ametavalue containing a token leaks into HTML.headers()returns: flag values that look like secrets. For SSG/ISG pages, document headers enterdist/server/headers-manifest.json; every serverful build currently also copies that manifest to publicdist/client/_pracht/headers.json(only Cloudflare reads it there). A pure static export omits the client copy but may mirror the server manifest into host configuration. This skill owns secret VALUES in headers; header policy (CSP, HSTS, weakened defaults) is owned byaudit-headers— cross-reference it.<Form>actionURLs containing tokens in the query string.prefetchRouteState(url)calls with sensitive query params.- Inline
<script>content emitted from custom shells.
Step 5: .env discipline
- Confirm
.env*is in.gitignore. - Confirm generated starters preserve
!.env.exampleif they ignore.env*. - Grep tracked files for likely committed secrets (long random strings near identifier names from step 1).
- Confirm client-side env access goes through
publicEnv(from@pracht/core) orimport.meta.env.PRACHT_PUBLIC_*;VITE_*values are still exposed by Vite compatibility wiring, but Pracht does not treat them as intentionally public, so flag client-sideVITE_*references unless they are explicitly allowlisted and reviewed. Warn loudly if a public env name has a secret-shaped name. - Flag any client-side read of
import.meta.envthat is not a single-key access — a bare reference, destructuring, a spread, or bracket access such asconst env = import.meta.envorimport.meta.env["MODE"]. Vite replaces those with an object literal holding every exposed variable, so theVITE_*values land in the bundle with no accessor text left for a name-based grep to find. UsepublicEnvto enumerate public values. - Confirm server-side env access uses
serverEnv(from@pracht/core/env/server) orcontext.envrather than ad-hoc globals.
Step 6: Report
| File:Line | Identifier / source | Sink (loader return / client import / etc.) | Severity |
|---|
Severities:
error— direct flow fromprocess.env.SECRET_*/serverEnv.SECRET_*to loader return.error—PRACHT_PUBLIC_*_SECRETstyle client-public or allowlistedVITE_*_SECRETsecret-shaped name.error—envSafety: falseor an allowlisted name that looks secret-shaped.warn— spread of a row that may contain secret columns.warn— client component imports a module that readsprocess.env.info— header value that looks like a token.
Rules
- Heuristic-first; document false-positive risk per finding.
- Recommend an explicit allowlist projection (
{ id, name, email }) over blocklist filtering. - For Cloudflare apps, secrets live in
context.env— same risk profile asprocess.env. Treat them identically. - Never print suspected secret values into the report — refer by name only.
- If you find a likely committed secret, recommend immediate rotation in addition to removal from history.
- Report only — do not auto-fix. Propose the projection/rotation/config change; never apply it.
$ARGUMENTS