payload-doctor 0.5.4
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/CHANGELOG.md +279 -0
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/SKILL.md +50 -0
- package/dist/checks/access.js +303 -0
- package/dist/checks/config.js +444 -0
- package/dist/checks/index.js +15 -0
- package/dist/checks/project.js +235 -0
- package/dist/checks/quality.js +106 -0
- package/dist/checks/rendering.js +57 -0
- package/dist/checks/routes.js +114 -0
- package/dist/cli.js +256 -0
- package/dist/fix.js +38 -0
- package/dist/report.js +171 -0
- package/dist/score.js +19 -0
- package/dist/suppress.js +76 -0
- package/dist/types.js +2 -0
- package/dist/util.js +281 -0
- package/dist/version.js +5 -0
- package/package.json +54 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is loosely
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com).
|
|
5
|
+
|
|
6
|
+
## 0.5.4
|
|
7
|
+
|
|
8
|
+
Minor polish from a second-pass review. No behaviour change on findings (fixtures still 0 FP).
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- `--fix` header now states the file count: `5× in 3 files — a.ts (3), b.ts (2)`.
|
|
12
|
+
|
|
13
|
+
### Docs
|
|
14
|
+
- Reframed the `returnsTrue()` non-goal as scope demarcation (a hypothetical
|
|
15
|
+
`always-truthy-expression` check is a separate concern), not risk-avoidance.
|
|
16
|
+
|
|
17
|
+
## 0.5.3
|
|
18
|
+
|
|
19
|
+
Closeout of the v0.5.2 code-review feedback (review rated 5/5). No behaviour change on
|
|
20
|
+
real projects (fixtures unchanged: 0 false positives).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- `--fix` groups locations by file (`5× — a.ts (3), b.ts (2), +1 more`) instead of showing
|
|
24
|
+
only the first occurrence — easier to see where a rule actually fires.
|
|
25
|
+
- Context-specific fix snippets extended beyond the `mass-assignment` pilot to
|
|
26
|
+
`missing-owner-enforcement` (names the slug and the owner field in a beforeChange stamp),
|
|
27
|
+
`collection-missing-access` (names the slug), and `reserved-field-name` (names the field).
|
|
28
|
+
|
|
29
|
+
### Docs
|
|
30
|
+
- Clarified that the self-loop check in cycle detection is intentional: Tarjan places a
|
|
31
|
+
self-referencing node in a size-1 SCC, so an SCC-size test alone would miss real self-loops.
|
|
32
|
+
- Documented a non-goal: `returnsTrue()` matches only a literal `true`, not constant-folded
|
|
33
|
+
expressions like `() => (!false)` (vanishingly rare, and folding would add FP risk).
|
|
34
|
+
|
|
35
|
+
## 0.5.2
|
|
36
|
+
|
|
37
|
+
Hardening from an external code review (rated 4.8/5, production-ready). All P0 and P1
|
|
38
|
+
items addressed; behaviour of the checks is unchanged on real projects (still 0 FPs on
|
|
39
|
+
the test fixtures). P2 ideas backlogged.
|
|
40
|
+
|
|
41
|
+
### Fixed (P0)
|
|
42
|
+
- CLI: `--min-score` now validates its argument (must be a number 0–100) and exits with a
|
|
43
|
+
clear message otherwise, instead of silently using `NaN`.
|
|
44
|
+
- CLI: the ts-morph project setup is wrapped in error handling, and an empty scan (no
|
|
45
|
+
`.ts/.tsx/.js/.jsx` files under the path) now prints a helpful message and exits 2
|
|
46
|
+
instead of silently scanning nothing.
|
|
47
|
+
|
|
48
|
+
### Changed (P1)
|
|
49
|
+
- Cycle detection (`circular-relationship`) now uses Tarjan's SCC — O(V+E) instead of a
|
|
50
|
+
per-node DFS — so it scales to large relationship graphs. Output is unchanged.
|
|
51
|
+
- `package.json` parsing in `dependency-version-mismatch` is defensively validated
|
|
52
|
+
(non-object root, non-object `dependencies`, non-string version specs are skipped, never throw).
|
|
53
|
+
- `returnsTrue()` is now AST-based instead of regex: it matches only an *unconditional*
|
|
54
|
+
`() => true` / `() => { return true }`, so it no longer false-matches on `() => true && x`
|
|
55
|
+
or a `return true` guarded by an `if`. More precise open-access detection.
|
|
56
|
+
- `--fix` output is now context-aware: each rule shows where it fires (`file:line`, with a
|
|
57
|
+
count) and checks can supply a context-specific snippet — e.g. `mass-assignment` names the
|
|
58
|
+
actual field and collection in its suggested field-level `access.create`.
|
|
59
|
+
|
|
60
|
+
## 0.5.1
|
|
61
|
+
|
|
62
|
+
Staying on **0.5.x** until the checks run cleanly across many more real projects
|
|
63
|
+
(target: ~10 more real-world runs before any 0.6/1.0). Data-driven against real
|
|
64
|
+
a real 285-file Payload project.
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
- `mass-assignment` (security, error/warning): an **auth** collection with open
|
|
68
|
+
`create` (public or any authenticated user) and a privileged field
|
|
69
|
+
(`roles`/`isAdmin`/…) lacking field-level `access.create` and a sanitizing
|
|
70
|
+
beforeChange hook — a registrant could send `roles: ['admin']` on create.
|
|
71
|
+
Scoped to auth collections; complements `user-writable-privileged-field` (update path).
|
|
72
|
+
|
|
73
|
+
### Changed (false-positive & severity tuning from real runs)
|
|
74
|
+
- `local-api-override-access`: server/job context (cron, webhooks, sync/worker/job
|
|
75
|
+
files, migrations) → `info` (the Local API default is expected there). User-facing
|
|
76
|
+
routes/actions unchanged.
|
|
77
|
+
- `missing-owner-enforcement`: admin/system-restricted `create` → `info` (system stamps
|
|
78
|
+
the owner). User-facing create incl. `ownerOrAdmin` stays `warning`.
|
|
79
|
+
- `unsafe-richtext-render`: `warning` → `info` (a static tool can't prove the HTML source
|
|
80
|
+
is sanitized; it's an audit pointer).
|
|
81
|
+
- `reserved-field-name`: (a) only checks objects inside a real `fields: [...]` array, so
|
|
82
|
+
raw-query aliases like `{ name: 'm.name' }` are no longer flagged; (b) ignores reserved
|
|
83
|
+
names on fields nested in `array`/`blocks`/`group` (array rows legitimately carry `id`).
|
|
84
|
+
Mongo-illegal `.`/`$` still flagged for real fields at any depth.
|
|
85
|
+
- `hook-n-plus-one`: fixed (string-literal) collection → `warning` (batchable N+1);
|
|
86
|
+
dynamic collection per item → `info` (likely heterogeneous, not trivially batchable).
|
|
87
|
+
- `hardcoded-secret`: in trusted system paths (seed/migrations/scripts) → `info`, so a
|
|
88
|
+
dev/seed default doesn't tank the score.
|
|
89
|
+
|
|
90
|
+
### Severity columns
|
|
91
|
+
- The per-rule Summary shows an `Xe/Yw/Zi` breakdown, in the text report and `--json`.
|
|
92
|
+
|
|
93
|
+
Total: 29 rules (26 per-file in `--list` + duplicate-slug, dependency-version-mismatch,
|
|
94
|
+
circular-relationship cross-file).
|
|
95
|
+
|
|
96
|
+
Deferred (need real-world validation, likely FP-prone or out of static scope):
|
|
97
|
+
`missing-rate-limit` (limiting often lives in middleware/infra), `exposed-admin-api`
|
|
98
|
+
(admin exposure is a deploy/infra concern, not visible in TS source).
|
|
99
|
+
|
|
100
|
+
## 0.5.0
|
|
101
|
+
|
|
102
|
+
### Added
|
|
103
|
+
- **Security depth:**
|
|
104
|
+
- `auth-weak-config` (warning): auth lockout disabled (`maxLoginAttempts: 0`) or a
|
|
105
|
+
`tokenExpiration` over 30 days. Only flags explicit weakenings — Payload's defaults
|
|
106
|
+
are safe, so a *missing* option is not flagged.
|
|
107
|
+
- `sensitive-data-logged` (warning): a `console.*` call that logs a password/token/
|
|
108
|
+
secret/apiKey/ssn/cvv value.
|
|
109
|
+
- **Schema & performance hygiene:**
|
|
110
|
+
- `reserved-field-name` (warning): a field name colliding with Payload-reserved fields
|
|
111
|
+
(`id`/`_id`/`createdAt`/`updatedAt`/`_status`/…) or illegal in MongoDB (`.`/`$`), and
|
|
112
|
+
collection slugs colliding with `payload-*` internals. Generic SQL keywords are NOT
|
|
113
|
+
flagged — the SQL adapter quotes identifiers, so they work.
|
|
114
|
+
- `excessive-max-depth` (warning): `maxDepth`/`defaultDepth` above 10.
|
|
115
|
+
- `missing-index-on-filter-field` (info): a commonly-filtered field (email/slug/username/
|
|
116
|
+
externalId/sku) without `index: true`.
|
|
117
|
+
- `hook-n-plus-one` (warning): a Local API read (`find`/`findByID`/`findGlobal`) inside a
|
|
118
|
+
loop or `map`/`forEach`/… callback — the classic N+1.
|
|
119
|
+
- `circular-relationship` (info, cross-file): relationship/upload fields forming a cycle
|
|
120
|
+
(A → B → A or self). Cycles are often legitimate, so it's an info reminder to bound `maxDepth`.
|
|
121
|
+
- The per-rule **Summary** now shows a severity breakdown per rule, e.g. `(6e/35w/17i)`,
|
|
122
|
+
in both the text report and the `--json` `summary` field.
|
|
123
|
+
|
|
124
|
+
Total: 28 rules (25 per-file in `--list` + duplicate-slug, dependency-version-mismatch,
|
|
125
|
+
circular-relationship as cross-file passes).
|
|
126
|
+
|
|
127
|
+
## 0.4.1
|
|
128
|
+
|
|
129
|
+
### Changed
|
|
130
|
+
- **Summary by rule now prints at the TOP** of the report (was at the bottom) — the
|
|
131
|
+
overview is visible without scrolling past every finding.
|
|
132
|
+
- `side-effect-in-get` is downgraded to `warning` on `unsubscribe`/`opt-out` routes
|
|
133
|
+
(email one-click unsubscribe is a GET convention), with a note about mail-client
|
|
134
|
+
prefetch and RFC 8058 List-Unsubscribe-Post. Cron stays `info`, everything else `error`.
|
|
135
|
+
|
|
136
|
+
### Added
|
|
137
|
+
- `--json` output now includes a `summary` array (per-rule rollup: ruleId, severity,
|
|
138
|
+
count, files). JSON `schema` bumped to `2`.
|
|
139
|
+
- Documented the score formula in `--help` and the README:
|
|
140
|
+
`max(0, 100 − 10·errors − 3·warnings)`; info findings don't affect it.
|
|
141
|
+
|
|
142
|
+
## 0.4.0
|
|
143
|
+
|
|
144
|
+
### Added
|
|
145
|
+
- Four new static checks:
|
|
146
|
+
- **`dependency-version-mismatch`** (config, error): reads `package.json` and
|
|
147
|
+
flags mixed `payload` / `@payloadcms/*` versions — a top cause of mysterious
|
|
148
|
+
build/runtime breaks. Skips ranges/tags/`workspace:*` it can't compare.
|
|
149
|
+
- **`relationship-missing-relationTo`** (correctness, error): a `relationship`
|
|
150
|
+
or `upload` field without `relationTo`.
|
|
151
|
+
- **`select-without-options`** (correctness, error): a `select`/`radio` field
|
|
152
|
+
without `options`.
|
|
153
|
+
- **`duplicate-field-name`** (correctness, error): two fields with the same
|
|
154
|
+
`name` in the same `fields` array (per-array namespace, no cross-namespace FPs).
|
|
155
|
+
|
|
156
|
+
## 0.3.1
|
|
157
|
+
|
|
158
|
+
### Added
|
|
159
|
+
- Four new checks:
|
|
160
|
+
- **`collection-missing-slug`** (config, error): a collection/global config
|
|
161
|
+
with `fields` but no `slug`. Only high-confidence configs are flagged
|
|
162
|
+
(inline `collections:`/`globals:` array elements, or objects typed
|
|
163
|
+
`CollectionConfig`/`GlobalConfig`), so nested field groups and tabs are safe.
|
|
164
|
+
- **`hook-missing-return`** (correctness, warning): a transforming hook
|
|
165
|
+
(`beforeChange`/`beforeValidate`/`beforeRead`/`afterRead`/`beforeDuplicate`)
|
|
166
|
+
that never returns a value — Payload uses the return value, so the change is
|
|
167
|
+
silently dropped. Side-effect-only hooks (`afterChange`…) are not flagged.
|
|
168
|
+
- **`duplicate-slug`** (config, error): the same slug on more than one
|
|
169
|
+
collection/global (cross-file check).
|
|
170
|
+
- **`admin-hidden-not-access`** (security, warning): a field with
|
|
171
|
+
`admin.hidden: true` and no field-level `access` — it is hidden in the Admin
|
|
172
|
+
UI but still returned by the REST/GraphQL API.
|
|
173
|
+
- **`--fix`**: prints a suggested fix snippet per rule. It never modifies files.
|
|
174
|
+
|
|
175
|
+
### Changed
|
|
176
|
+
- Sharpened the tagline/description to make clear this is for **Payload CMS**
|
|
177
|
+
(the framework), not for validating API request/response payloads.
|
|
178
|
+
|
|
179
|
+
## 0.3.0
|
|
180
|
+
|
|
181
|
+
### Added
|
|
182
|
+
- **`unsafe-richtext-render` check** (new `rendering` category): flags
|
|
183
|
+
`dangerouslySetInnerHTML` rendering CMS/rich-text content without visible
|
|
184
|
+
sanitization — the block-render seam where stored XSS hides. Only fires on
|
|
185
|
+
content that looks CMS-related (generic React stays out of scope).
|
|
186
|
+
- **`--summary`** flag and an always-on "Summary by rule" rollup (count +
|
|
187
|
+
distinct files per rule), so large reports don't scroll forever.
|
|
188
|
+
|
|
189
|
+
### Changed
|
|
190
|
+
- Score weighting: `error=10`, `warning=3`, **`info=0`** (info findings no longer
|
|
191
|
+
drag the score down).
|
|
192
|
+
- `side-effect-in-get` is downgraded to `info` inside `/cron/` routes (Vercel
|
|
193
|
+
cron requires GET); a reminder to keep the write idempotent is included.
|
|
194
|
+
|
|
195
|
+
## 0.2.3
|
|
196
|
+
|
|
197
|
+
### Changed
|
|
198
|
+
- Repository/author links now point to `github.com/metakraft`.
|
|
199
|
+
|
|
200
|
+
## 0.2.2
|
|
201
|
+
|
|
202
|
+
### Fixed
|
|
203
|
+
- A non-existent target path now prints a clear `Path not found:` error and exits
|
|
204
|
+
with code 2, instead of the ambiguous "No source files found" message.
|
|
205
|
+
|
|
206
|
+
### Added
|
|
207
|
+
- Scanning a single file (not just a directory) is now supported.
|
|
208
|
+
|
|
209
|
+
## 0.2.1
|
|
210
|
+
|
|
211
|
+
### Added
|
|
212
|
+
- Author / maintainer attribution (Leander M. von Kraft — Tierramor Agency) in
|
|
213
|
+
README, SKILL.md and `package.json`.
|
|
214
|
+
- `package.json` metadata: `author`, `homepage`, `repository`, `bugs`.
|
|
215
|
+
- `SECURITY.md`, `CONTRIBUTING.md`, and a GitHub Actions CI workflow that runs
|
|
216
|
+
`npm test` on Node 18/20/22.
|
|
217
|
+
- README disclaimer: provided as-is, free and open source, without warranty or
|
|
218
|
+
support obligation.
|
|
219
|
+
|
|
220
|
+
## 0.2.0
|
|
221
|
+
|
|
222
|
+
Tuned against a real-world Payload project to cut false positives.
|
|
223
|
+
|
|
224
|
+
### Fixed (false positives)
|
|
225
|
+
- **Blocks and globals are no longer mistaken for collections.** A new config
|
|
226
|
+
classifier uses array position, type annotation (`CollectionConfig` / `Block` /
|
|
227
|
+
`GlobalConfig`) and file path, and treats anything nested inside a `fields`
|
|
228
|
+
array as a block. This stops `collection-missing-access`, `open-access-function`,
|
|
229
|
+
`missing-owner-enforcement`, `user-writable-privileged-field` and
|
|
230
|
+
`token-field-readable` from firing on blocks.
|
|
231
|
+
- **Public registration** (`create: () => true` on an `auth` collection) is now
|
|
232
|
+
`info`, not `error`.
|
|
233
|
+
- **Explicit system writes** (`overrideAccess: true` with no `user`, e.g. cron /
|
|
234
|
+
webhooks) are now `info` instead of `error`; `overrideAccess: true` together
|
|
235
|
+
with a `user` is still reported by `override-access-true-with-user` (no double
|
|
236
|
+
reporting).
|
|
237
|
+
|
|
238
|
+
### Added
|
|
239
|
+
- **Inline suppression** (ESLint-style): `// payload-doctor-disable-next-line`,
|
|
240
|
+
`// payload-doctor-disable-line`, `// payload-doctor-disable` — optionally
|
|
241
|
+
scoped to specific rule ids, or all rules when none is given. Suppressed count
|
|
242
|
+
is shown in text and JSON output.
|
|
243
|
+
- `-V` / `--version` flag; version printed in the report header, `--help`, and as
|
|
244
|
+
`toolVersion` in JSON.
|
|
245
|
+
- Self-test fixtures for blocks, suppression and system writes.
|
|
246
|
+
|
|
247
|
+
## 0.1.0
|
|
248
|
+
|
|
249
|
+
Initial release.
|
|
250
|
+
|
|
251
|
+
### Checks
|
|
252
|
+
- Access control: `local-api-override-access`, `override-access-true-with-user`,
|
|
253
|
+
`collection-missing-access`, `open-access-function`, `missing-owner-enforcement`,
|
|
254
|
+
`user-writable-privileged-field`.
|
|
255
|
+
- Routes: `cron-not-fail-closed`, `side-effect-in-get`, `leaks-error-message`.
|
|
256
|
+
- Config & privacy: `hardcoded-secret`, `wide-open-cors`, `token-field-readable`.
|
|
257
|
+
|
|
258
|
+
### Tooling
|
|
259
|
+
- 0-100 health score with `great` / `needs-work` / `critical` bands.
|
|
260
|
+
- Text and `--json` output, `--verbose` hints, `--list`, `--min-score`,
|
|
261
|
+
`--no-exit-code`, `--no-color`. CI-friendly exit codes.
|
|
262
|
+
- Auto-detects `.ts/.tsx/.js/.jsx`; skips `node_modules`, `dist`, `.next`,
|
|
263
|
+
`build` and treats `migrations/seeds/scripts/tests` as trusted system code.
|
|
264
|
+
|
|
265
|
+
### False-positive hardening
|
|
266
|
+
- Word-segment matching (camelCase aware) so `hashtags`/`tokenizer` no longer
|
|
267
|
+
match the `hash`/`token` sensitive-field rule.
|
|
268
|
+
- Tightened the error-leak regex so identifiers like `code.message` /
|
|
269
|
+
`response.message` are no longer mistaken for `error.message`.
|
|
270
|
+
- `local-api-override-access` skips calls whose options object uses a spread
|
|
271
|
+
(the spread may carry `overrideAccess: false`).
|
|
272
|
+
- `missing-owner-enforcement` no longer fires when a `beforeValidate`/
|
|
273
|
+
`beforeChange` hook is present (it may be imported) or the owner field has a
|
|
274
|
+
`defaultValue`.
|
|
275
|
+
- Dropped speculative field names (`plan`, `tier`) from the privileged-field rule.
|
|
276
|
+
|
|
277
|
+
### Tests
|
|
278
|
+
- Self-test fixtures: `vulnerable` (must be flagged), `clean` and `tricky`
|
|
279
|
+
(must produce zero findings). Run with `npm test`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leander M. von Kraft (Tierramor Agency)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# payload-doctor
|
|
2
|
+
|
|
3
|
+
Static **security & correctness linter for [Payload CMS](https://payloadcms.com)** —
|
|
4
|
+
the TypeScript headless CMS. It scans your collections, access control, hooks,
|
|
5
|
+
routes and config for known anti-patterns — the kind that AI coding agents and
|
|
6
|
+
humans alike get wrong — and prints a **0–100 health score** with actionable findings.
|
|
7
|
+
|
|
8
|
+
> **Note:** this is for **Payload CMS** (the framework). It does *not* inspect or
|
|
9
|
+
> validate API request/response payloads (JSON/XML). If you came looking for that,
|
|
10
|
+
> this isn't it.
|
|
11
|
+
|
|
12
|
+
Think of it as a `react-doctor` for Payload. One command, no install:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx -y payload-doctor@latest .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
Payload's **Local API bypasses access control by default** (`overrideAccess` is
|
|
21
|
+
`true` unless you set it to `false`). It's the single most expensive footgun in a
|
|
22
|
+
Payload app: a route can authenticate a user and still hand them someone else's
|
|
23
|
+
records, because the collection's `access` functions never run. payload-doctor
|
|
24
|
+
catches that and a dozen related issues before they reach production.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# scan the current project
|
|
30
|
+
npx -y payload-doctor@latest .
|
|
31
|
+
|
|
32
|
+
# show fix hints
|
|
33
|
+
npx -y payload-doctor@latest . --verbose
|
|
34
|
+
|
|
35
|
+
# machine-readable output for CI / dashboards
|
|
36
|
+
npx -y payload-doctor@latest . --json
|
|
37
|
+
|
|
38
|
+
# big report? show only the per-rule rollup
|
|
39
|
+
npx -y payload-doctor@latest . --summary
|
|
40
|
+
|
|
41
|
+
# print a suggested fix per rule (never modifies files)
|
|
42
|
+
npx -y payload-doctor@latest . --fix
|
|
43
|
+
|
|
44
|
+
# fail a CI job if the score drops below a threshold
|
|
45
|
+
npx -y payload-doctor@latest . --min-score 80
|
|
46
|
+
|
|
47
|
+
# print the version
|
|
48
|
+
npx -y payload-doctor@latest --version
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Recommended workflow:** run it → fix the errors first → re-run and watch the
|
|
52
|
+
score climb. Keep a clean git state before applying fixes.
|
|
53
|
+
|
|
54
|
+
Score bands: **75–100 great · 50–74 needs work · 0–49 critical.**
|
|
55
|
+
|
|
56
|
+
The score is `max(0, 100 − 10·errors − 3·warnings)`; `info` findings don't affect
|
|
57
|
+
it. Ten or more errors floor it at `0` by design — once you're in the red, track
|
|
58
|
+
the dropping error/warning counts (and the per-rule summary) to gauge progress
|
|
59
|
+
rather than the score alone.
|
|
60
|
+
|
|
61
|
+
Exit code is `1` when any `error`-severity finding is present (or the score is
|
|
62
|
+
below `--min-score`), `0` otherwise — drop it straight into CI or a pre-commit
|
|
63
|
+
hook. Use `--no-exit-code` while you're adopting it.
|
|
64
|
+
|
|
65
|
+
## Suppressing intentional cases
|
|
66
|
+
|
|
67
|
+
Some findings are deliberate — an OAuth callback that writes on `GET`, a cron
|
|
68
|
+
job using `overrideAccess: true`. Silence them inline, ESLint-style:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// payload-doctor-disable-next-line local-api-override-access
|
|
72
|
+
await payload.update({ collection: 'jobs', id, data })
|
|
73
|
+
|
|
74
|
+
// payload-doctor-disable-line side-effect-in-get
|
|
75
|
+
|
|
76
|
+
// payload-doctor-disable side-effect-in-get ← whole file; omit the rule to silence all
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Rule names may be written with or without the `payload-doctor/` prefix. The
|
|
80
|
+
number of suppressed findings is reported so suppressions stay visible.
|
|
81
|
+
|
|
82
|
+
## Checks
|
|
83
|
+
|
|
84
|
+
| Rule | Category | Severity | What it catches |
|
|
85
|
+
|------|----------|----------|-----------------|
|
|
86
|
+
| `local-api-override-access` | security | error / warning | Local API call without `overrideAccess: false` — access control bypassed |
|
|
87
|
+
| `override-access-true-with-user` | security | error | `overrideAccess: true` while passing a `user` — control skipped on purpose |
|
|
88
|
+
| `collection-missing-access` | security | warning | Collection with no explicit `access` block |
|
|
89
|
+
| `open-access-function` | security | error / info | `access.{create,update,delete}` returns `true` (anyone can write) |
|
|
90
|
+
| `missing-owner-enforcement` | security | warning | User-owned collection that doesn't force ownership on create |
|
|
91
|
+
| `user-writable-privileged-field` | security | error | `roles` / `isAdmin` / … field without field-level `access.update` |
|
|
92
|
+
| `mass-assignment` | security | error/warning | Privileged field settable on create (auth collection, open create, no field `access.create`) |
|
|
93
|
+
| `cron-not-fail-closed` | security | error | Secret/cron guard that is fail-open when the secret is unset |
|
|
94
|
+
| `side-effect-in-get` | correctness | error | `GET` handler performs a write (prefetch / email scanners trigger it) |
|
|
95
|
+
| `leaks-error-message` | security | warning | Internal `error.message` / stack returned to the client |
|
|
96
|
+
| `hardcoded-secret` | security | error | Secret, key or connection string committed as a string literal |
|
|
97
|
+
| `wide-open-cors` | config | warning | CORS set to `'*'` |
|
|
98
|
+
| `token-field-readable` | privacy | warning | Token/hash/secret field exposed via API (no field-level `read`) |
|
|
99
|
+
| `unsafe-richtext-render` | rendering | info | `dangerouslySetInnerHTML` renders CMS/rich-text HTML — review the sink (source may or may not be sanitized) |
|
|
100
|
+
| `collection-missing-slug` | config | error | Collection/global config without a `slug` |
|
|
101
|
+
| `duplicate-slug` | config | error | Same slug on more than one collection/global |
|
|
102
|
+
| `hook-missing-return` | correctness | warning | Transforming hook (`beforeChange`/`afterRead`…) returns nothing |
|
|
103
|
+
| `admin-hidden-not-access` | security | warning | `admin.hidden` field with no field access — still returned by the API |
|
|
104
|
+
| `dependency-version-mismatch` | config | error | Mixed `payload` / `@payloadcms/*` versions in package.json |
|
|
105
|
+
| `relationship-missing-relationTo` | correctness | error | `relationship`/`upload` field without `relationTo` |
|
|
106
|
+
| `select-without-options` | correctness | error | `select`/`radio` field without `options` |
|
|
107
|
+
| `duplicate-field-name` | correctness | error | Two fields with the same `name` in one `fields` array |
|
|
108
|
+
| `auth-weak-config` | security | warning | Auth lockout disabled (`maxLoginAttempts: 0`) or very long `tokenExpiration` |
|
|
109
|
+
| `sensitive-data-logged` | security | warning | `console.*` logs a password/token/secret value |
|
|
110
|
+
| `reserved-field-name` | config | warning | Field/slug collides with a Payload-reserved or Mongo-illegal identifier |
|
|
111
|
+
| `excessive-max-depth` | config | warning | `maxDepth`/`defaultDepth` above 10 |
|
|
112
|
+
| `missing-index-on-filter-field` | config | info | Commonly-filtered field (email/slug/…) without `index: true` |
|
|
113
|
+
| `hook-n-plus-one` | correctness | warning | Local API read inside a loop / `map` (N+1 query) |
|
|
114
|
+
| `circular-relationship` | correctness | info | `relationship`/`upload` fields form a cycle (watch `maxDepth`) |
|
|
115
|
+
|
|
116
|
+
List them anytime with `npx -y payload-doctor@latest --list`.
|
|
117
|
+
|
|
118
|
+
> The checks are static heuristics, like any linter. They are tuned for low
|
|
119
|
+
> false-positives, but always review findings in context. Files under
|
|
120
|
+
> `migrations/`, `seeds/`, `scripts/` and tests are treated as trusted system
|
|
121
|
+
> code and skipped for the request-context rules.
|
|
122
|
+
|
|
123
|
+
## Use with AI coding agents
|
|
124
|
+
|
|
125
|
+
This repo ships a `SKILL.md`, so it works as an agent skill too:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npx skills add https://github.com/metakraft/payload-doctor --skill payload-doctor
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Then your agent can run the doctor after generating Payload code, fix the
|
|
132
|
+
flagged issues, and re-run to verify — closing the gap between "AI writes code"
|
|
133
|
+
and "code ships".
|
|
134
|
+
|
|
135
|
+
## Contributing
|
|
136
|
+
|
|
137
|
+
A check is a small object implementing the `Check` interface (see `src/types.ts`)
|
|
138
|
+
that receives a `ts-morph` `SourceFile` and returns `Finding[]`. Add yours to the
|
|
139
|
+
relevant file in `src/checks/` and register it in `src/checks/index.ts`. There are
|
|
140
|
+
self-test fixtures in `test/fixtures/` — an intentionally insecure project
|
|
141
|
+
(`vulnerable`), a secure reference (`clean`), and a `tricky` set of legitimate
|
|
142
|
+
patterns that must NOT produce findings (the false-positive guard). Build and run
|
|
143
|
+
the assertion suite with:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm install
|
|
147
|
+
npm test
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`npm test` builds the project and asserts that `vulnerable` is flagged while
|
|
151
|
+
`clean` and `tricky` stay silent. Add a fixture whenever you add or change a
|
|
152
|
+
check, and keep the false-positive rate low — a noisy linter gets disabled.
|
|
153
|
+
|
|
154
|
+
PRs that add checks, reduce false-positives, or add fixtures are welcome.
|
|
155
|
+
|
|
156
|
+
## Bugs & feature requests
|
|
157
|
+
|
|
158
|
+
Please use **[GitHub Issues](https://github.com/metakraft/payload-doctor/issues)** —
|
|
159
|
+
there are templates for bug reports (incl. false positives) and new-check requests.
|
|
160
|
+
Open-ended questions and "show & tell" go in
|
|
161
|
+
**[Discussions](https://github.com/metakraft/payload-doctor/discussions)**; suspected
|
|
162
|
+
security issues via a private **Security Advisory** (see `SECURITY.md`). `npm bugs`
|
|
163
|
+
from a project using the package opens the issue tracker directly.
|
|
164
|
+
|
|
165
|
+
## Author
|
|
166
|
+
|
|
167
|
+
Leander M. von Kraft — [www.metakraft.de](https://www.metakraft.de)
|
|
168
|
+
Tierramor Agency — AI-native project work.
|
|
169
|
+
|
|
170
|
+
## Disclaimer
|
|
171
|
+
|
|
172
|
+
Provided as-is, free and open source, **without warranty or any obligation** —
|
|
173
|
+
no support, no guarantees, no liability. The checks are static heuristics: they
|
|
174
|
+
help, but you remain responsible for reviewing findings and securing your own
|
|
175
|
+
code. Use at your own risk.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
[MIT](./LICENSE). Free to use, modify and distribute. No rights reserved beyond
|
|
180
|
+
the attribution the MIT license asks for.
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: payload-doctor
|
|
3
|
+
description: Static security and correctness auditor for Payload CMS projects. Use this skill whenever working on Payload CMS access control, collection configuration, hooks, custom endpoints, Next.js route handlers or server actions that read or write Payload data, or before merging any user-data route. Run it after generating or changing Payload code to catch access-control bypasses and related anti-patterns. Trigger on "Payload security", "Payload access control", "overrideAccess", "Local API bypass", "audit Payload", "Payload doctor", "is this Payload collection secure", "check my Payload config", "cron fail closed", "force ownership on create", "side-effect GET", or any request to review the security of a Payload collection, hook, endpoint or route.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# payload-doctor
|
|
7
|
+
|
|
8
|
+
A CLI that statically audits a Payload CMS project for security and correctness
|
|
9
|
+
anti-patterns and prints a 0-100 health score with file:line findings.
|
|
10
|
+
|
|
11
|
+
## How to run it
|
|
12
|
+
|
|
13
|
+
From the project root:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx -y payload-doctor@latest . --verbose
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add `--json` for machine-readable output, `--min-score N` to enforce a threshold
|
|
20
|
+
in CI, `--list` to see all checks.
|
|
21
|
+
|
|
22
|
+
## Workflow
|
|
23
|
+
|
|
24
|
+
1. Run the doctor on the Payload project.
|
|
25
|
+
2. Fix `error`-severity findings first, then warnings.
|
|
26
|
+
3. Re-run and confirm the score climbed and the findings are gone.
|
|
27
|
+
4. Keep a clean git state before applying fixes.
|
|
28
|
+
|
|
29
|
+
Score bands: 75-100 great, 50-74 needs work, 0-49 critical. Exit code is non-zero
|
|
30
|
+
when any error is present.
|
|
31
|
+
|
|
32
|
+
## What it checks
|
|
33
|
+
|
|
34
|
+
Access control (Local API `overrideAccess` bypass, open access functions,
|
|
35
|
+
missing collection access, ownership not forced on create, user-writable
|
|
36
|
+
privileged fields), routes (fail-open cron/secret guards, side-effect GETs, error
|
|
37
|
+
leaks), config (hardcoded secrets, wide-open CORS) and privacy (token/hash fields
|
|
38
|
+
readable via API). See README for the full table.
|
|
39
|
+
|
|
40
|
+
## Interpreting findings
|
|
41
|
+
|
|
42
|
+
Findings are static heuristics tuned for low false-positives. Always confirm in
|
|
43
|
+
context. The most important rule is `local-api-override-access`: Payload's Local
|
|
44
|
+
API defaults to `overrideAccess: true`, which bypasses collection access control,
|
|
45
|
+
so request-context calls must pass `overrideAccess: false` and `user` (or verify
|
|
46
|
+
ownership manually).
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
By Leander M. von Kraft — www.metakraft.de · Tierramor Agency. MIT licensed, provided as-is without warranty.
|