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 leaked env vars", "is my API key exposed", "scan for credential leaks".
Install
npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-secrets
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
git clone https://github.com/JoviDeCroock/pracht.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Pracht 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
Files (pracht)
-
SKILL.md 7.9 KB
--- name: audit-secrets version: 1.2.0 description: | 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 leaked env vars", "is my API key exposed", "scan for credential leaks". allowed-tools: - Bash - Read - Grep - Glob --- # Pracht 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 build` fails when a client chunk references a non-public env var (anything not `PRACHT_PUBLIC_`-prefixed and not a Vite built-in), naming the variable, chunk, and likely source module. - `pracht verify` (and `pracht doctor`) check the build-time env-safety report and re-run the literal chunk scan against an existing `dist/client` output. - 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: 1. Run `pracht inspect routes --json` to enumerate routes. 2. For each route, read the loader. 3. Build a small dataflow trace from sensitive identifiers (and `process.env.*` / `context.env.*` reads) to the `return` statement(s). 4. Flag any spread (`...row`, `...user`, `...env`) that originates from a source containing secrets. 5. 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 in `pracht-state` as `root` and in every route-state response, like loader data. For `@pracht/query` that is every successful query cached during the request, so flag a `queryFn` or `setQueryData` in a loader that caches a secret-bearing value. - `head()` returns: rare, but a `meta` value containing a token leaks into HTML. - `headers()` returns: flag values that look like secrets. For SSG/ISG pages, document headers enter `dist/server/headers-manifest.json`; every serverful build currently also copies that manifest to public `dist/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 by `audit-headers` — cross-reference it. - `<Form>` `action` URLs 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.example` if 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`) or `import.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-side `VITE_*` 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.env` that is not a single-key access — a bare reference, destructuring, a spread, or bracket access such as `const env = import.meta.env` or `import.meta.env["MODE"]`. Vite replaces those with an object literal holding **every** exposed variable, so the `VITE_*` values land in the bundle with no accessor text left for a name-based grep to find. Use `publicEnv` to enumerate public values. - Confirm server-side env access uses `serverEnv` (from `@pracht/core/env/server`) or `context.env` rather than ad-hoc globals. ## Step 6: Report | File:Line | Identifier / source | Sink (loader return / client import / etc.) | Severity | | --------- | ------------------- | ------------------------------------------- | -------- | Severities: - `error` — direct flow from `process.env.SECRET_*` / `serverEnv.SECRET_*` to loader return. - `error` — `PRACHT_PUBLIC_*_SECRET` style client-public or allowlisted `VITE_*_SECRET` secret-shaped name. - `error` — `envSafety: false` or an allowlisted name that looks secret-shaped. - `warn` — spread of a row that may contain secret columns. - `warn` — client component imports a module that reads `process.env`. - `info` — header value that looks like a token. ## Rules 1. Heuristic-first; document false-positive risk per finding. 2. Recommend an explicit allowlist projection (`{ id, name, email }`) over blocklist filtering. 3. For Cloudflare apps, secrets live in `context.env` — same risk profile as `process.env`. Treat them identically. 4. Never print suspected secret values into the report — refer by name only. 5. If you find a likely committed secret, recommend immediate rotation in addition to removal from history. 6. Report only — do not auto-fix. Propose the projection/rotation/config change; never apply it. $ARGUMENTS
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.