create-pracht 0.2.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/package.json +3 -2
- package/skills/add-auth/SKILL.md +346 -0
- package/skills/add-db/SKILL.md +316 -0
- package/skills/add-i18n/SKILL.md +239 -0
- package/skills/add-observability/SKILL.md +270 -0
- package/skills/audit-a11y/SKILL.md +170 -0
- package/skills/audit-auth/SKILL.md +144 -0
- package/skills/audit-bundles/SKILL.md +167 -0
- package/skills/audit-csrf/SKILL.md +179 -0
- package/skills/audit-deps/SKILL.md +138 -0
- package/skills/audit-headers/SKILL.md +186 -0
- package/skills/audit-islands/SKILL.md +144 -0
- package/skills/audit-loaders/SKILL.md +135 -0
- package/skills/audit-redirects/SKILL.md +146 -0
- package/skills/audit-secrets/SKILL.md +150 -0
- package/skills/audit-seo/SKILL.md +164 -0
- package/skills/audit-shells/SKILL.md +142 -0
- package/skills/configure-isg/SKILL.md +172 -0
- package/skills/migrate-nextjs/SKILL.md +519 -0
- package/skills/pracht-debug/SKILL.md +146 -0
- package/skills/pracht-deploy/SKILL.md +208 -0
- package/skills/pracht-scaffold/SKILL.md +191 -0
- package/skills/pracht-test-api/SKILL.md +165 -0
- package/skills/pre-deploy/SKILL.md +164 -0
- package/skills/scaffold-e2e/SKILL.md +189 -0
- package/skills/scaffold-tests/SKILL.md +283 -0
- package/skills/tune-render-mode/SKILL.md +168 -0
- package/skills/typed-routes/SKILL.md +203 -0
- package/skills/upgrade-pracht/SKILL.md +151 -0
- package/src/index.js +472 -31
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: audit-islands
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: |
|
|
5
|
+
Audit pracht islands usage: find over-hydrated routes that should use
|
|
6
|
+
`hydration: "islands"` or `"none"`, dead interactivity outside the islands
|
|
7
|
+
directory, non-serializable island props, mis-tuned client strategies, and
|
|
8
|
+
invalid render/hydration combinations.
|
|
9
|
+
Use when asked to "audit islands", "reduce hydration", "why is this island
|
|
10
|
+
not interactive", "should this page be an island", or "check partial
|
|
11
|
+
hydration".
|
|
12
|
+
allowed-tools:
|
|
13
|
+
- Bash
|
|
14
|
+
- Read
|
|
15
|
+
- Grep
|
|
16
|
+
- Glob
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Pracht Audit Islands
|
|
20
|
+
|
|
21
|
+
Report-only audit of hydration modes and islands usage (see `docs/ISLANDS.md`).
|
|
22
|
+
Pracht hydrates the whole page by default (`hydration: "full"`); routes can opt
|
|
23
|
+
into `"islands"` (only components from the islands directory hydrate) or
|
|
24
|
+
`"none"` (zero JS). This audit finds routes shipping JS they don't need and
|
|
25
|
+
islands wired in ways that break at render time or ship dead handlers.
|
|
26
|
+
|
|
27
|
+
## Step 1: Enumerate routes and hydration modes
|
|
28
|
+
|
|
29
|
+
If the pracht MCP server is registered (see docs/MCP.md), prefer its tools
|
|
30
|
+
(`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`) over
|
|
31
|
+
shelling out.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pracht inspect routes --json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Prerequisite: `pracht inspect` needs a vite config with the pracht plugin
|
|
38
|
+
wired up.
|
|
39
|
+
|
|
40
|
+
Each route entry carries `render` and `hydration` (`null` = framework default,
|
|
41
|
+
i.e. full hydration). Also locate the islands directory: default `src/islands/`,
|
|
42
|
+
configurable via `pracht({ islandsDir })` in the vite config. In pages-router
|
|
43
|
+
apps (`mode: "pages"`), hydration is the per-file `export const HYDRATION =
|
|
44
|
+
"islands" | "none"` constant; islands still live in `src/islands/`.
|
|
45
|
+
|
|
46
|
+
## Step 2: Measure what each route actually ships
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pracht build --json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Prerequisite: this runs a full production build (`--json` implies `--analyze`).
|
|
53
|
+
Interpret the totals hydration-aware:
|
|
54
|
+
|
|
55
|
+
- Full-hydration routes: route chunks + shell chunks + shared entry.
|
|
56
|
+
- `hydration: "islands"` routes: islands bootstrap + island chunks only — no
|
|
57
|
+
shared client entry. The listed island chunks are an **upper bound** (every
|
|
58
|
+
island in the app); a page only downloads the islands it renders.
|
|
59
|
+
- `hydration: "none"` routes report `0b`.
|
|
60
|
+
|
|
61
|
+
## Step 3: Run the checks
|
|
62
|
+
|
|
63
|
+
### 3a. Over-hydration (the headline check)
|
|
64
|
+
|
|
65
|
+
For every route with `hydration` `null`/`"full"`: read the route module, its
|
|
66
|
+
shell, and their imported components. If the page is mostly static — no
|
|
67
|
+
hooks, no event handlers, or only one or two isolated widgets — flag it:
|
|
68
|
+
|
|
69
|
+
- Zero interactivity → recommend `hydration: "none"` (`warn`; `info` if the
|
|
70
|
+
route's total gzip JS is already tiny).
|
|
71
|
+
- A few isolated widgets → recommend `hydration: "islands"` with the widgets
|
|
72
|
+
moved into the islands directory (`warn`). Cite the Step 2 total as the
|
|
73
|
+
payload this change removes.
|
|
74
|
+
|
|
75
|
+
Render-mode fit is owned by `/tune-render-mode`; deep chunk analysis is owned
|
|
76
|
+
by `/audit-bundles`. Point there instead of duplicating their findings.
|
|
77
|
+
|
|
78
|
+
### 3b. Dead interactivity on islands routes (`error`)
|
|
79
|
+
|
|
80
|
+
On a `hydration: "islands"` route, everything outside the islands directory
|
|
81
|
+
renders as inert HTML — an `onClick` in a regular component silently does
|
|
82
|
+
nothing. Grep the route tree of every islands route for event handlers and
|
|
83
|
+
hooks in components **not** under the islands directory and flag each one.
|
|
84
|
+
Islands are auto-discovered from that directory only (default and named
|
|
85
|
+
function-component exports alike; each becomes its own code-split chunk) —
|
|
86
|
+
there is no wrapper to mark a component elsewhere as an island.
|
|
87
|
+
|
|
88
|
+
### 3c. Island props and children (`error`)
|
|
89
|
+
|
|
90
|
+
Island props are serialized to JSON in the HTML. At each island call site on
|
|
91
|
+
an islands route, flag props that are functions, symbols, bigints, class
|
|
92
|
+
instances (`Date`, `Map`, ...), JSX elements, or circular — rendering throws a
|
|
93
|
+
descriptive error naming the offending prop path. Passing children into an
|
|
94
|
+
island from a server component also throws (unsupported in v1): move the
|
|
95
|
+
content inside the island or pass a serializable prop.
|
|
96
|
+
|
|
97
|
+
### 3d. Hydration strategy tuning (`info`)
|
|
98
|
+
|
|
99
|
+
Each island usage picks a strategy via the framework-owned `client` prop:
|
|
100
|
+
`"load"` (default, chunk is `<link rel="modulepreload">`-ed), `"idle"`
|
|
101
|
+
(requestIdleCallback), `"visible"` (IntersectionObserver; chunk fetched only
|
|
102
|
+
when triggered). Flag default-`load` islands that are plausibly below the fold
|
|
103
|
+
or not needed at first paint (comment sections, newsletter signups, footers) →
|
|
104
|
+
suggest `client="visible"` or `client="idle"`.
|
|
105
|
+
|
|
106
|
+
### 3e. Invalid combinations (`error`)
|
|
107
|
+
|
|
108
|
+
`render: "spa"` always implies full hydration; combining it with
|
|
109
|
+
`hydration: "islands"` or `"none"` is a configuration error. Flag any such
|
|
110
|
+
route (manifest field or pages-router `RENDER_MODE`/`HYDRATION` pair).
|
|
111
|
+
|
|
112
|
+
### 3f. MPA navigation assumptions (`warn`)
|
|
113
|
+
|
|
114
|
+
Routes with `hydration: "islands"` or `"none"` never load the client router:
|
|
115
|
+
navigation to, from, and between them is full-document (MPA-style), and
|
|
116
|
+
route-state prefetching is skipped for them. Flag apps that rely on
|
|
117
|
+
client-side state surviving navigation across such routes (in-memory stores,
|
|
118
|
+
module-level caches shared between pages) — every navigation is a fresh
|
|
119
|
+
document.
|
|
120
|
+
|
|
121
|
+
## Step 4: Report
|
|
122
|
+
|
|
123
|
+
| Route | File | Severity | Finding | Suggested fix |
|
|
124
|
+
| ----- | ---- | -------- | ------- | ------------- |
|
|
125
|
+
|
|
126
|
+
Severities: `error` (breaks or throws today: dead handlers, non-serializable
|
|
127
|
+
props, spa+islands), `warn` (works but wasteful or fragile), `info` (tuning
|
|
128
|
+
opportunity). Include the measured gzip totals for every over-hydration
|
|
129
|
+
finding.
|
|
130
|
+
|
|
131
|
+
## Rules
|
|
132
|
+
|
|
133
|
+
1. Report only — never edit routes, components, or config. Hand changes to
|
|
134
|
+
`/tune-render-mode` (modes) or the user.
|
|
135
|
+
2. Use `pracht inspect routes --json` as the source of truth for hydration
|
|
136
|
+
modes — groups inherit `hydration`, so reading `src/routes.ts` manually
|
|
137
|
+
under-counts.
|
|
138
|
+
3. Islands components behave like plain components on full-hydration routes
|
|
139
|
+
and inside other islands — only flag 3b/3c on `hydration: "islands"`
|
|
140
|
+
routes.
|
|
141
|
+
4. Verify hydration in a running app via `html[data-pracht-islands-hydrated="true"]`
|
|
142
|
+
(set after all `load` islands hydrate) and per-island `data-hydrated="true"`.
|
|
143
|
+
|
|
144
|
+
$ARGUMENTS
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: audit-loaders
|
|
3
|
+
version: 1.1.0
|
|
4
|
+
description: |
|
|
5
|
+
Audit pracht route loaders for serializability, leaked secrets,
|
|
6
|
+
unsafe loader caching, browser-only API misuse, and missing AbortSignal plumbing.
|
|
7
|
+
Use when asked to "audit loaders", "check loader data", "find serialization
|
|
8
|
+
bugs", "are my loaders safe", or "loader security review".
|
|
9
|
+
allowed-tools:
|
|
10
|
+
- Bash
|
|
11
|
+
- Read
|
|
12
|
+
- Grep
|
|
13
|
+
- Glob
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Pracht Audit Loaders
|
|
17
|
+
|
|
18
|
+
Static analysis of every loader in the project. The framework serializes loader
|
|
19
|
+
return values to the client via `window.__PRACHT_STATE__`, so anything returned
|
|
20
|
+
ends up in the browser — including secrets you never meant to expose.
|
|
21
|
+
|
|
22
|
+
## Step 1: Enumerate routes
|
|
23
|
+
|
|
24
|
+
If the pracht MCP server is registered (see docs/MCP.md), prefer its tools
|
|
25
|
+
(`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`) over
|
|
26
|
+
shelling out.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pracht inspect routes --json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Prerequisite: `pracht inspect` needs a vite config with the pracht plugin
|
|
33
|
+
wired up.
|
|
34
|
+
|
|
35
|
+
For every route entry, read `loaderFile ?? file` and inspect the `loader`
|
|
36
|
+
export there. Loaders may live in a separate data module wired via the
|
|
37
|
+
manifest (`RouteConfig.loader`); the inspect JSON surfaces that as
|
|
38
|
+
`loaderFile` (null when the loader lives in the route module itself). Reading
|
|
39
|
+
only `file` misses every externalized loader.
|
|
40
|
+
|
|
41
|
+
## Step 2: Run the five checks
|
|
42
|
+
|
|
43
|
+
For each `loader` (and `getStaticPaths` when present):
|
|
44
|
+
|
|
45
|
+
### 2a. Serializability
|
|
46
|
+
|
|
47
|
+
Flag returns that contain any of:
|
|
48
|
+
|
|
49
|
+
| Construct | Why it breaks |
|
|
50
|
+
| ---------------------------- | ----------------------------------- |
|
|
51
|
+
| `Date`, `Map`, `Set`, `URL` | Not preserved by `JSON.stringify` |
|
|
52
|
+
| Class instances | Lose prototype on the client |
|
|
53
|
+
| `Function` / arrow values | Stripped silently |
|
|
54
|
+
| `Promise` | Becomes `{}` |
|
|
55
|
+
| Circular refs | Throws at serialize time |
|
|
56
|
+
| `Buffer` / typed arrays | Becomes `{}` or numeric keys |
|
|
57
|
+
| `bigint` | `JSON.stringify` throws |
|
|
58
|
+
| `undefined` in arrays/object | Drops keys; arrays become `null` |
|
|
59
|
+
|
|
60
|
+
Recommend converting to `string` (ISO for dates), plain arrays, or plain objects
|
|
61
|
+
before return.
|
|
62
|
+
|
|
63
|
+
### 2b. Secret leaks
|
|
64
|
+
|
|
65
|
+
Ownership note: deep secret scanning is owned by `/audit-secrets` and by
|
|
66
|
+
`pracht verify`'s env scan — keep this check brief and point the user there
|
|
67
|
+
rather than triple-reporting the same findings. Here, only flag what falls
|
|
68
|
+
out of reading the return value anyway:
|
|
69
|
+
|
|
70
|
+
Grep the loader body and anything it returns for:
|
|
71
|
+
|
|
72
|
+
- `process.env.*` references that flow into the return value.
|
|
73
|
+
- `context.env.*` (Cloudflare bindings) flowing into the return value.
|
|
74
|
+
- Variables named `*SECRET*`, `*TOKEN*`, `*KEY*`, `*PASSWORD*`, `*PRIVATE*`,
|
|
75
|
+
`*API_KEY*` reaching the return.
|
|
76
|
+
- Spreads of full DB rows containing `password_hash`, `mfa_secret`, etc.
|
|
77
|
+
|
|
78
|
+
Loaders run server-side but **the return value crosses the wire**. Always
|
|
79
|
+
project to a smaller shape before returning.
|
|
80
|
+
|
|
81
|
+
### 2c. Browser-only APIs at module top level or in loader
|
|
82
|
+
|
|
83
|
+
Flag any of these accessed unconditionally inside `loader` or at the top level
|
|
84
|
+
of a route module that is rendered SSR/SSG/ISG:
|
|
85
|
+
|
|
86
|
+
- `window`, `document`, `navigator`, `localStorage`, `sessionStorage`,
|
|
87
|
+
`IntersectionObserver`, `matchMedia`, `requestAnimationFrame`.
|
|
88
|
+
|
|
89
|
+
These crash on the server. For SPA-only routes (`render: "spa"`) it's fine
|
|
90
|
+
inside the component — but never inside `loader`.
|
|
91
|
+
|
|
92
|
+
### 2d. AbortSignal plumbing
|
|
93
|
+
|
|
94
|
+
For loaders that call `fetch` or any I/O:
|
|
95
|
+
|
|
96
|
+
- The framework passes `signal` in `LoaderArgs`.
|
|
97
|
+
- Verify it is forwarded to outbound `fetch(url, { signal })` calls and to any
|
|
98
|
+
database client that accepts cancellation.
|
|
99
|
+
- A loader that ignores `signal` keeps work running after the client navigates
|
|
100
|
+
away.
|
|
101
|
+
|
|
102
|
+
### 2e. Loader cache safety
|
|
103
|
+
|
|
104
|
+
For routes with a positive `loaderCache` value, flag loader data whose freshness
|
|
105
|
+
or visibility depends on cookies, authorization headers, sessions, user identity,
|
|
106
|
+
permissions, or request-specific context. Route-state HTTP caching is `private`,
|
|
107
|
+
so shared proxies cannot reuse it, but a stale response can still survive logout,
|
|
108
|
+
account switching, or permission changes in the same browser.
|
|
109
|
+
|
|
110
|
+
Recommend `loaderCache: false`/`0` for personalized or authorization-sensitive
|
|
111
|
+
loaders. Use a positive duration only when every field in the returned data can be
|
|
112
|
+
safely reused for that long. Do not confuse `loaderCache` with ISG `revalidate` or
|
|
113
|
+
the short-lived in-memory prefetch cache; they are independent policies.
|
|
114
|
+
|
|
115
|
+
## Step 3: Report
|
|
116
|
+
|
|
117
|
+
Produce a markdown table:
|
|
118
|
+
|
|
119
|
+
| Route | File | Severity | Finding | Suggested fix |
|
|
120
|
+
| ----- | ---- | -------- | ------- | ------------- |
|
|
121
|
+
|
|
122
|
+
Severities: `error` (secret leak, crash), `warn` (serialization risk, missing
|
|
123
|
+
signal), `info` (style nit).
|
|
124
|
+
|
|
125
|
+
## Rules
|
|
126
|
+
|
|
127
|
+
1. Use `pracht inspect routes --json` as the source of truth — do not glob
|
|
128
|
+
`src/routes/**` and risk missing manifest wiring or catching orphan files.
|
|
129
|
+
2. Read the actual loader source — do not infer from names.
|
|
130
|
+
3. For each finding, point at the file and line.
|
|
131
|
+
4. Do not auto-fix. Hand the user the report; let them choose.
|
|
132
|
+
5. If the loader returns a typed shape from a DB ORM, recommend an explicit
|
|
133
|
+
`select` or projection step rather than spreading the row.
|
|
134
|
+
|
|
135
|
+
$ARGUMENTS
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: audit-redirects
|
|
3
|
+
version: 1.1.0
|
|
4
|
+
description: |
|
|
5
|
+
Find open-redirect vulnerabilities in pracht loaders, middleware, and
|
|
6
|
+
navigation calls. The framework guards both ends — the client router drops
|
|
7
|
+
unsafe URL schemes, and the server `redirect()` helper rejects unsafe
|
|
8
|
+
schemes and CRLF injection — but a hand-rolled 3xx Response bypasses every
|
|
9
|
+
guard, and even a guarded redirect to an attacker-chosen origin can phish.
|
|
10
|
+
Use when asked to "audit redirects", "check for open redirects", "is my
|
|
11
|
+
?redirect= param safe", or "review login redirect handling".
|
|
12
|
+
allowed-tools:
|
|
13
|
+
- Bash
|
|
14
|
+
- Read
|
|
15
|
+
- Grep
|
|
16
|
+
- Glob
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Pracht Audit Redirects
|
|
20
|
+
|
|
21
|
+
The classic open-redirect bug: `/login?redirect=https://evil.example` →
|
|
22
|
+
after login, the app redirects the user to attacker territory carrying a fresh
|
|
23
|
+
session.
|
|
24
|
+
|
|
25
|
+
What the framework guarantees: the client router drops non-`http(s):` schemes
|
|
26
|
+
at the navigation boundary, and server-side `redirect()` /
|
|
27
|
+
`buildRedirectResponse()` reject non-`http(s):` schemes and CR/LF injection
|
|
28
|
+
against the `Location` header. What it cannot decide is whether the target
|
|
29
|
+
*origin or path* is one you trust — and a raw
|
|
30
|
+
`new Response(null, { status: 302, headers: { location: userInput } })`
|
|
31
|
+
bypasses all of those guards entirely.
|
|
32
|
+
|
|
33
|
+
Prerequisites: `pracht inspect` requires a vite config that registers the
|
|
34
|
+
pracht plugin.
|
|
35
|
+
|
|
36
|
+
## Step 1: Inventory redirect sites
|
|
37
|
+
|
|
38
|
+
Bound the search first — do not grep the whole repo blind:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pracht inspect routes --json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If the pracht MCP server is registered (see `docs/MCP.md`), prefer its tools
|
|
45
|
+
(`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`) over
|
|
46
|
+
shelling out.
|
|
47
|
+
|
|
48
|
+
The resolved graph gives you each route's `file`, `loaderFile`, and
|
|
49
|
+
`middleware` names (resolve names to files via the `defineApp({ middleware })`
|
|
50
|
+
map in the manifest). Grep those files plus the API handler files from
|
|
51
|
+
`pracht inspect api --json`, and client components, for two distinct classes:
|
|
52
|
+
|
|
53
|
+
**Class A — guarded sites** (scheme/CRLF-safe, still open-redirect capable):
|
|
54
|
+
|
|
55
|
+
- Middleware/loaders/handlers returning `redirect(...)` or
|
|
56
|
+
`buildRedirectResponse(...)`.
|
|
57
|
+
- `useNavigate()(value)` and `<a href={value}>` where `value` is dynamic
|
|
58
|
+
(client guard applies).
|
|
59
|
+
- `prefetchRouteState(value)` calls with dynamic input.
|
|
60
|
+
|
|
61
|
+
**Class B — unguarded sites** (bypass every framework guard):
|
|
62
|
+
|
|
63
|
+
- Hand-rolled 3xx `Response`s: `new Response(null, { status: 302, headers:
|
|
64
|
+
{ location: ... } })` or `headers.set("location", ...)` in loaders,
|
|
65
|
+
middleware, or API handlers. With user input these allow not just open
|
|
66
|
+
redirects but also unsafe schemes and header-injection attempts — flag at
|
|
67
|
+
higher severity.
|
|
68
|
+
|
|
69
|
+
## Step 2: Trace the input
|
|
70
|
+
|
|
71
|
+
For each site, identify whether the redirect target is:
|
|
72
|
+
|
|
73
|
+
- **Static** — string literal. Safe.
|
|
74
|
+
- **Internal-derived** — built from `params`, `route.path`, or a closed
|
|
75
|
+
allowlist. Safe if the allowlist is verifiable.
|
|
76
|
+
- **Request-derived** — read from `url.searchParams.get(...)`,
|
|
77
|
+
`request.headers.get('referer')`, request body fields, cookie values, or
|
|
78
|
+
query parameters. **Suspect.**
|
|
79
|
+
|
|
80
|
+
Common suspect names: `redirect`, `redirectTo`, `next`, `returnTo`, `continue`,
|
|
81
|
+
`url`, `dest`, `goto`.
|
|
82
|
+
|
|
83
|
+
## Step 3: Check the gate
|
|
84
|
+
|
|
85
|
+
For each request-derived target, look for one of:
|
|
86
|
+
|
|
87
|
+
| Gate | Safe? |
|
|
88
|
+
| -------------------------------------- | ----- |
|
|
89
|
+
| Hardcoded allowlist of paths/origins | Yes |
|
|
90
|
+
| `target.startsWith('/')` AND `!target.startsWith('//')` | Yes — same-origin path only |
|
|
91
|
+
| `new URL(target, base).origin === url.origin` | Yes — origin comparison |
|
|
92
|
+
| `new URL(target).hostname === expected` | Yes if `expected` is trusted |
|
|
93
|
+
| No check | **Open redirect** |
|
|
94
|
+
| `target.includes(domain)` (substring) | **Bypassable** (`evil.com#yourdomain.com`) |
|
|
95
|
+
| Regex without anchors | **Likely bypassable** |
|
|
96
|
+
|
|
97
|
+
`startsWith('/')` alone is **not** sufficient — `//evil.example/path` parses
|
|
98
|
+
as a protocol-relative URL and most browsers treat it as cross-origin. Require
|
|
99
|
+
both `startsWith('/')` AND `!startsWith('//')`, or use `URL` parsing.
|
|
100
|
+
|
|
101
|
+
Remember: `redirect()`'s built-in validation covers scheme and CRLF only — it
|
|
102
|
+
happily redirects to any well-formed `http(s)` origin, so Class A sites still
|
|
103
|
+
need an origin/path gate for request-derived targets.
|
|
104
|
+
|
|
105
|
+
## Step 4: Report
|
|
106
|
+
|
|
107
|
+
| File:Line | Class (A/B) | Source | Target expression | Gate | Severity | Verdict |
|
|
108
|
+
| --------- | ----------- | ------ | ----------------- | ---- | -------- | ------- |
|
|
109
|
+
|
|
110
|
+
Severity is the primary scale; the verdict is a secondary domain label:
|
|
111
|
+
|
|
112
|
+
- `error` / `open` — request-derived target, no check. A Class B site here is
|
|
113
|
+
the worst case (no scheme/CRLF guard either) — say so explicitly.
|
|
114
|
+
- `warn` / `risky` — substring/regex check; suggest URL-parse rewrite.
|
|
115
|
+
- `info` / `safe` — static or properly gated. For Class B sites that are
|
|
116
|
+
static today, still recommend migrating to `redirect()` so the scheme/CRLF
|
|
117
|
+
guards apply if the target ever becomes dynamic.
|
|
118
|
+
|
|
119
|
+
For each `open`/`risky` finding, propose a fix snippet, e.g.:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const raw = url.searchParams.get("redirect") ?? "/dashboard";
|
|
123
|
+
const safe = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/dashboard";
|
|
124
|
+
return redirect(safe, { request });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Step 5: Cross-check with the framework guards
|
|
128
|
+
|
|
129
|
+
Note in the report that pracht drops `javascript:`, `data:`, `vbscript:`,
|
|
130
|
+
`blob:`, and `file:` schemes both in the client router (since #122) and in
|
|
131
|
+
server-side `redirect()`/`buildRedirectResponse()` — so a guarded open
|
|
132
|
+
redirect cannot become script execution. But it can still phish (redirect to
|
|
133
|
+
a look-alike origin) and leak session/referrer headers, and Class B raw
|
|
134
|
+
Responses get none of this protection.
|
|
135
|
+
|
|
136
|
+
## Rules
|
|
137
|
+
|
|
138
|
+
1. Default to suspicion for any request-derived target.
|
|
139
|
+
2. Recommend `URL` parsing over string prefix checks for non-trivial gates.
|
|
140
|
+
3. Do not trust `referer` as a gate; it is forgeable and often stripped.
|
|
141
|
+
4. After-login redirects are the most dangerous — user is authenticated.
|
|
142
|
+
5. Recommend `redirect()` over hand-rolled 3xx Responses so the scheme/CRLF
|
|
143
|
+
guards apply.
|
|
144
|
+
6. Do not auto-fix; surface the gap and propose the patch.
|
|
145
|
+
|
|
146
|
+
$ARGUMENTS
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: audit-secrets
|
|
3
|
+
version: 1.1.0
|
|
4
|
+
description: |
|
|
5
|
+
Detect environment variables and secrets that leak from the server into
|
|
6
|
+
the client bundle via loader return values, hydration state, or accidental
|
|
7
|
+
imports of server-only modules from client code paths.
|
|
8
|
+
Use when asked to "audit secrets", "find leaked env vars", "is my API key
|
|
9
|
+
exposed", "check client bundle for secrets", or "scan for credential leaks".
|
|
10
|
+
allowed-tools:
|
|
11
|
+
- Bash
|
|
12
|
+
- Read
|
|
13
|
+
- Grep
|
|
14
|
+
- Glob
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Pracht Audit Secrets
|
|
18
|
+
|
|
19
|
+
Pracht serializes loader return values into the `pracht-state` JSON script and
|
|
20
|
+
hydrates the client from them. Anything a loader returns ends up readable in the
|
|
21
|
+
browser. The Vite plugin also strips server-only exports from route files, but
|
|
22
|
+
it cannot save you from a value that flows into the return.
|
|
23
|
+
|
|
24
|
+
Prerequisites: `pracht inspect` requires a vite config that registers the
|
|
25
|
+
pracht plugin; the env-safety report under `dist/client/_pracht/` requires a
|
|
26
|
+
prior `pracht build`. If the pracht MCP server is registered (see
|
|
27
|
+
`docs/MCP.md`), prefer its tools (`inspect_routes`, `inspect_api`,
|
|
28
|
+
`inspect_build`, `doctor`, `verify`) over shelling out.
|
|
29
|
+
|
|
30
|
+
## Step 0: Run the native env safety check
|
|
31
|
+
|
|
32
|
+
Pracht has built-in env leak detection (see `docs/ENV.md`):
|
|
33
|
+
|
|
34
|
+
- `pracht build` fails when a client chunk references a non-public env var
|
|
35
|
+
(anything not `PRACHT_PUBLIC_`-prefixed and not a Vite built-in),
|
|
36
|
+
naming the variable, chunk, and likely source module.
|
|
37
|
+
- `pracht verify` (and `pracht doctor`) check the build-time env-safety report
|
|
38
|
+
and re-run the literal chunk scan against an existing `dist/client` output.
|
|
39
|
+
- Client-side imports of `@pracht/core/env/server` (`serverEnv`) fail the build.
|
|
40
|
+
|
|
41
|
+
Run `pracht verify` or `pracht doctor` (or a build) first and fold the
|
|
42
|
+
findings into the report.
|
|
43
|
+
Check `vite.config.*` for `envSafety: { allow: [...] }` / `envSafety: false` —
|
|
44
|
+
every allowlisted name and a disabled check are findings to review, since they
|
|
45
|
+
bypass the native gate. The native check detects _references_, not values, so
|
|
46
|
+
the dataflow steps below are still required.
|
|
47
|
+
|
|
48
|
+
## Step 1: Identify "secret-shaped" identifiers
|
|
49
|
+
|
|
50
|
+
Build a regex of variable names treated as sensitive:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|PASSWORD|PASSPHRASE|SESSION[_-]?SECRET|JWT[_-]?SECRET|WEBHOOK[_-]?SECRET|DATABASE[_-]?URL|DB[_-]?URL|CONNECTION[_-]?STRING|CLIENT[_-]?SECRET|REFRESH[_-]?TOKEN
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Also flag any direct `process.env.X` or `context.env.X` reference where `X`
|
|
57
|
+
matches the above.
|
|
58
|
+
|
|
59
|
+
## Step 2: Server → client flow analysis
|
|
60
|
+
|
|
61
|
+
For every route file, trace whether a sensitive value reaches the loader's
|
|
62
|
+
return value. Steps:
|
|
63
|
+
|
|
64
|
+
1. Run `pracht inspect routes --json` to enumerate routes.
|
|
65
|
+
2. For each route, read the loader.
|
|
66
|
+
3. Build a small dataflow trace from sensitive identifiers (and `process.env.*`
|
|
67
|
+
/ `context.env.*` reads) to the `return` statement(s).
|
|
68
|
+
4. Flag any spread (`...row`, `...user`, `...env`) that originates from a
|
|
69
|
+
source containing secrets.
|
|
70
|
+
5. Flag direct returns of objects that name sensitive keys.
|
|
71
|
+
|
|
72
|
+
This is heuristic — favor over-flagging over silent leaks. Note false-positive
|
|
73
|
+
risk in the report.
|
|
74
|
+
|
|
75
|
+
## Step 3: Module import boundaries
|
|
76
|
+
|
|
77
|
+
Grep client-rendered files (route components, shells, anything imported from
|
|
78
|
+
them) for imports of:
|
|
79
|
+
|
|
80
|
+
- `node:*` builtins
|
|
81
|
+
- `@pracht/adapter-*`
|
|
82
|
+
- Local `src/server/**` modules
|
|
83
|
+
- Modules whose top level reads `process.env.*`
|
|
84
|
+
|
|
85
|
+
The Vite plugin strips the server-only exports `loader`, `head`, `headers`,
|
|
86
|
+
`getStaticPaths`, and `markdown` from route files when serving the client
|
|
87
|
+
query (`middleware` is not a route-file export — middleware lives in the
|
|
88
|
+
manifest); see the client module transform notes in `docs/ARCHITECTURE.md`
|
|
89
|
+
and `docs/ENV.md`. But a component that imports `../server/db` will still
|
|
90
|
+
pull `db` into the client bundle. Flag those imports.
|
|
91
|
+
|
|
92
|
+
## Step 4: Hidden surfaces
|
|
93
|
+
|
|
94
|
+
Check for accidental exposure outside loaders:
|
|
95
|
+
|
|
96
|
+
- `head()` returns: rare, but a `meta` value containing a token leaks into HTML.
|
|
97
|
+
- `headers()` returns: flag values that look like secrets. For SSG/ISG pages,
|
|
98
|
+
document headers can be copied into `dist/client/_pracht/headers.json`, which
|
|
99
|
+
is public client output and may be replayed on static responses. This skill
|
|
100
|
+
owns secret VALUES in headers; header policy (CSP, HSTS, weakened defaults)
|
|
101
|
+
is owned by `audit-headers` — cross-reference it.
|
|
102
|
+
- `<Form>` `action` URLs containing tokens in the query string.
|
|
103
|
+
- `prefetchRouteState(url)` calls with sensitive query params.
|
|
104
|
+
- Inline `<script>` content emitted from custom shells.
|
|
105
|
+
|
|
106
|
+
## Step 5: `.env` discipline
|
|
107
|
+
|
|
108
|
+
- Confirm `.env*` is in `.gitignore`.
|
|
109
|
+
- Confirm generated starters preserve `!.env.example` if they ignore `.env*`.
|
|
110
|
+
- Grep tracked files for likely committed secrets (long random strings near
|
|
111
|
+
identifier names from step 1).
|
|
112
|
+
- Confirm client-side env access goes through `publicEnv` (from
|
|
113
|
+
`@pracht/core`) or `import.meta.env.PRACHT_PUBLIC_*`; `VITE_*` values are
|
|
114
|
+
still exposed by Vite compatibility wiring, but Pracht does not treat them as
|
|
115
|
+
intentionally public, so flag client-side `VITE_*` references unless they are
|
|
116
|
+
explicitly allowlisted and reviewed. Warn loudly if a public env name has a
|
|
117
|
+
secret-shaped name.
|
|
118
|
+
- Confirm server-side env access uses `serverEnv` (from
|
|
119
|
+
`@pracht/core/env/server`) or `context.env` rather than ad-hoc globals.
|
|
120
|
+
|
|
121
|
+
## Step 6: Report
|
|
122
|
+
|
|
123
|
+
| File:Line | Identifier / source | Sink (loader return / client import / etc.) | Severity |
|
|
124
|
+
| --------- | ------------------- | ------------------------------------------- | -------- |
|
|
125
|
+
|
|
126
|
+
Severities:
|
|
127
|
+
|
|
128
|
+
- `error` — direct flow from `process.env.SECRET_*` / `serverEnv.SECRET_*` to
|
|
129
|
+
loader return.
|
|
130
|
+
- `error` — `PRACHT_PUBLIC_*_SECRET` style client-public or allowlisted `VITE_*_SECRET`
|
|
131
|
+
secret-shaped name.
|
|
132
|
+
- `error` — `envSafety: false` or an allowlisted name that looks secret-shaped.
|
|
133
|
+
- `warn` — spread of a row that may contain secret columns.
|
|
134
|
+
- `warn` — client component imports a module that reads `process.env`.
|
|
135
|
+
- `info` — header value that looks like a token.
|
|
136
|
+
|
|
137
|
+
## Rules
|
|
138
|
+
|
|
139
|
+
1. Heuristic-first; document false-positive risk per finding.
|
|
140
|
+
2. Recommend an explicit allowlist projection (`{ id, name, email }`) over
|
|
141
|
+
blocklist filtering.
|
|
142
|
+
3. For Cloudflare apps, secrets live in `context.env` — same risk profile as
|
|
143
|
+
`process.env`. Treat them identically.
|
|
144
|
+
4. Never print suspected secret values into the report — refer by name only.
|
|
145
|
+
5. If you find a likely committed secret, recommend immediate rotation in
|
|
146
|
+
addition to removal from history.
|
|
147
|
+
6. Report only — do not auto-fix. Propose the projection/rotation/config
|
|
148
|
+
change; never apply it.
|
|
149
|
+
|
|
150
|
+
$ARGUMENTS
|