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/dist/util.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LOCAL_API_METHODS = exports.READ_METHODS = exports.MUTATION_METHODS = void 0;
|
|
4
|
+
exports.lineCol = lineCol;
|
|
5
|
+
exports.makeFinding = makeFinding;
|
|
6
|
+
exports.firstObjectArg = firstObjectArg;
|
|
7
|
+
exports.propText = propText;
|
|
8
|
+
exports.propInit = propInit;
|
|
9
|
+
exports.hasProp = hasProp;
|
|
10
|
+
exports.hasSpread = hasSpread;
|
|
11
|
+
exports.unquote = unquote;
|
|
12
|
+
exports.nameSegments = nameSegments;
|
|
13
|
+
exports.hasSegment = hasSegment;
|
|
14
|
+
exports.isLocalApiCall = isLocalApiCall;
|
|
15
|
+
exports.enclosingArrayProp = enclosingArrayProp;
|
|
16
|
+
exports.directTypeHint = directTypeHint;
|
|
17
|
+
exports.getConfigKind = getConfigKind;
|
|
18
|
+
exports.isCollectionConfig = isCollectionConfig;
|
|
19
|
+
exports.isAuthCollection = isAuthCollection;
|
|
20
|
+
exports.getFieldObjects = getFieldObjects;
|
|
21
|
+
exports.returnsTrue = returnsTrue;
|
|
22
|
+
exports.callsIn = callsIn;
|
|
23
|
+
exports.isSystemPath = isSystemPath;
|
|
24
|
+
exports.isServerJobPath = isServerJobPath;
|
|
25
|
+
exports.looksLikeRequestFile = looksLikeRequestFile;
|
|
26
|
+
const ts_morph_1 = require("ts-morph");
|
|
27
|
+
/** Payload Local API mutation methods (write operations). */
|
|
28
|
+
exports.MUTATION_METHODS = new Set(['create', 'update', 'delete']);
|
|
29
|
+
/** Payload Local API read methods. */
|
|
30
|
+
exports.READ_METHODS = new Set(['find', 'findByID', 'findGlobal']);
|
|
31
|
+
exports.LOCAL_API_METHODS = new Set([...exports.MUTATION_METHODS, ...exports.READ_METHODS]);
|
|
32
|
+
function lineCol(node) {
|
|
33
|
+
const sf = node.getSourceFile();
|
|
34
|
+
const { line, column } = sf.getLineAndColumnAtPos(node.getStart());
|
|
35
|
+
return { line, column };
|
|
36
|
+
}
|
|
37
|
+
function makeFinding(node, ctx, ruleId, category, severity, message, hint, fix) {
|
|
38
|
+
const { line, column } = lineCol(node);
|
|
39
|
+
return {
|
|
40
|
+
ruleId,
|
|
41
|
+
category,
|
|
42
|
+
severity,
|
|
43
|
+
message,
|
|
44
|
+
file: ctx.rel(node.getSourceFile().getFilePath()),
|
|
45
|
+
line,
|
|
46
|
+
column,
|
|
47
|
+
hint,
|
|
48
|
+
fix,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** First object-literal argument of a call, if any. */
|
|
52
|
+
function firstObjectArg(call) {
|
|
53
|
+
const arg = call.getArguments()[0];
|
|
54
|
+
if (arg && ts_morph_1.Node.isObjectLiteralExpression(arg))
|
|
55
|
+
return arg;
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
/** Get a string-ish value of an object property (initializer text without quotes). */
|
|
59
|
+
function propText(obj, name) {
|
|
60
|
+
const prop = obj.getProperty(name);
|
|
61
|
+
if (!prop || !ts_morph_1.Node.isPropertyAssignment(prop))
|
|
62
|
+
return undefined;
|
|
63
|
+
const init = prop.getInitializer();
|
|
64
|
+
return init?.getText();
|
|
65
|
+
}
|
|
66
|
+
/** Raw initializer node of an object property. */
|
|
67
|
+
function propInit(obj, name) {
|
|
68
|
+
const prop = obj.getProperty(name);
|
|
69
|
+
if (!prop || !ts_morph_1.Node.isPropertyAssignment(prop))
|
|
70
|
+
return undefined;
|
|
71
|
+
return prop.getInitializer();
|
|
72
|
+
}
|
|
73
|
+
function hasProp(obj, name) {
|
|
74
|
+
return obj.getProperty(name) !== undefined;
|
|
75
|
+
}
|
|
76
|
+
/** True if the object literal contains a spread (...x) we cannot statically see into. */
|
|
77
|
+
function hasSpread(obj) {
|
|
78
|
+
return obj.getProperties().some((p) => ts_morph_1.Node.isSpreadAssignment(p));
|
|
79
|
+
}
|
|
80
|
+
function unquote(s) {
|
|
81
|
+
if (s === undefined)
|
|
82
|
+
return undefined;
|
|
83
|
+
return s.replace(/^['"`]|['"`]$/g, '');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Split a field/identifier name into lowercase word segments, handling
|
|
87
|
+
* camelCase and separators. "resetToken" -> ["reset","token"],
|
|
88
|
+
* "hashtags" -> ["hashtags"]. Lets checks match whole words instead of
|
|
89
|
+
* fragile substrings (so "hashtag" never matches "hash").
|
|
90
|
+
*/
|
|
91
|
+
function nameSegments(name) {
|
|
92
|
+
return name
|
|
93
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
94
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.map((s) => s.toLowerCase());
|
|
97
|
+
}
|
|
98
|
+
/** True if any word segment of `name` is in the given set. */
|
|
99
|
+
function hasSegment(name, words) {
|
|
100
|
+
return nameSegments(name).some((seg) => words.has(seg));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A Payload Local API call looks like `x.<method>({ collection: '...', ... })`.
|
|
104
|
+
* We discriminate from generic Array#find etc. by requiring the first argument
|
|
105
|
+
* to be an object literal carrying a `collection` (or `global`) property.
|
|
106
|
+
*/
|
|
107
|
+
function isLocalApiCall(call) {
|
|
108
|
+
const expr = call.getExpression();
|
|
109
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
110
|
+
return undefined;
|
|
111
|
+
const method = expr.getName();
|
|
112
|
+
if (!exports.LOCAL_API_METHODS.has(method))
|
|
113
|
+
return undefined;
|
|
114
|
+
const arg = firstObjectArg(call);
|
|
115
|
+
if (!arg)
|
|
116
|
+
return undefined;
|
|
117
|
+
if (!hasProp(arg, 'collection') && !hasProp(arg, 'global'))
|
|
118
|
+
return undefined;
|
|
119
|
+
return { method, arg };
|
|
120
|
+
}
|
|
121
|
+
/** Name of the array property an object literal is an element of, if any. */
|
|
122
|
+
function enclosingArrayProp(obj) {
|
|
123
|
+
const arr = obj.getParentIfKind(ts_morph_1.SyntaxKind.ArrayLiteralExpression);
|
|
124
|
+
if (!arr)
|
|
125
|
+
return undefined;
|
|
126
|
+
const pa = arr.getParentIfKind(ts_morph_1.SyntaxKind.PropertyAssignment);
|
|
127
|
+
if (!pa)
|
|
128
|
+
return undefined;
|
|
129
|
+
return pa.getName().replace(/['"`]/g, '');
|
|
130
|
+
}
|
|
131
|
+
/** Type annotation directly attached to this object (const x: T =, as T, satisfies T). */
|
|
132
|
+
function directTypeHint(obj) {
|
|
133
|
+
let target = obj;
|
|
134
|
+
let p = obj.getParent();
|
|
135
|
+
while (p && (ts_morph_1.Node.isAsExpression(p) || ts_morph_1.Node.isSatisfiesExpression(p) || ts_morph_1.Node.isParenthesizedExpression(p))) {
|
|
136
|
+
const tn = p.getTypeNode?.();
|
|
137
|
+
if (tn)
|
|
138
|
+
return tn.getText();
|
|
139
|
+
target = p;
|
|
140
|
+
p = p.getParent();
|
|
141
|
+
}
|
|
142
|
+
if (p && ts_morph_1.Node.isVariableDeclaration(p) && p.getInitializer() === target) {
|
|
143
|
+
return p.getTypeNode()?.getText() ?? '';
|
|
144
|
+
}
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Classify a `{ slug, fields }` object. Payload blocks and globals share that
|
|
149
|
+
* shape with collections, so we use position, type annotation and path to avoid
|
|
150
|
+
* flagging blocks/globals as collections (the #1 false-positive source).
|
|
151
|
+
*/
|
|
152
|
+
function getConfigKind(obj) {
|
|
153
|
+
if (!hasProp(obj, 'slug') || !hasProp(obj, 'fields'))
|
|
154
|
+
return 'unknown';
|
|
155
|
+
const prop = enclosingArrayProp(obj);
|
|
156
|
+
if (prop === 'blocks')
|
|
157
|
+
return 'block';
|
|
158
|
+
if (prop === 'globals')
|
|
159
|
+
return 'global';
|
|
160
|
+
if (prop === 'collections')
|
|
161
|
+
return 'collection';
|
|
162
|
+
if (prop === 'fields')
|
|
163
|
+
return 'block'; // a sub-config inside fields, not a collection
|
|
164
|
+
// Nested inside any enclosing config that has `fields` -> it's a block/sub-config.
|
|
165
|
+
let cur = obj.getParent();
|
|
166
|
+
while (cur) {
|
|
167
|
+
if (ts_morph_1.Node.isObjectLiteralExpression(cur) && hasProp(cur, 'fields'))
|
|
168
|
+
return 'block';
|
|
169
|
+
cur = cur.getParent();
|
|
170
|
+
}
|
|
171
|
+
const hint = directTypeHint(obj);
|
|
172
|
+
if (/\bBlock\b/.test(hint))
|
|
173
|
+
return 'block';
|
|
174
|
+
if (/GlobalConfig/.test(hint))
|
|
175
|
+
return 'global';
|
|
176
|
+
if (/CollectionConfig/.test(hint))
|
|
177
|
+
return 'collection';
|
|
178
|
+
const path = obj.getSourceFile().getFilePath();
|
|
179
|
+
if (/\/blocks?\//i.test(path))
|
|
180
|
+
return 'block';
|
|
181
|
+
if (/\/globals?\//i.test(path))
|
|
182
|
+
return 'global';
|
|
183
|
+
if (/\/collections?\//i.test(path))
|
|
184
|
+
return 'collection';
|
|
185
|
+
return 'unknown';
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* True only for confirmed collection configs. Blocks, globals and nested field
|
|
189
|
+
* configs are excluded so collection-level checks don't misfire on them.
|
|
190
|
+
*/
|
|
191
|
+
function isCollectionConfig(obj) {
|
|
192
|
+
return getConfigKind(obj) === 'collection';
|
|
193
|
+
}
|
|
194
|
+
/** Does the collection look like an auth collection (public registration is normal)? */
|
|
195
|
+
function isAuthCollection(obj) {
|
|
196
|
+
return hasProp(obj, 'auth');
|
|
197
|
+
}
|
|
198
|
+
/** Return the `fields` array element object-literals of a collection config. */
|
|
199
|
+
function getFieldObjects(collection) {
|
|
200
|
+
const fieldsInit = propInit(collection, 'fields');
|
|
201
|
+
if (!fieldsInit || !ts_morph_1.Node.isArrayLiteralExpression(fieldsInit))
|
|
202
|
+
return [];
|
|
203
|
+
return fieldsInit
|
|
204
|
+
.getElements()
|
|
205
|
+
.filter(ts_morph_1.Node.isObjectLiteralExpression);
|
|
206
|
+
}
|
|
207
|
+
/** Does an arrow/function initializer effectively `return true`? */
|
|
208
|
+
/**
|
|
209
|
+
* True only if the function *unconditionally* returns boolean `true`:
|
|
210
|
+
* () => true (arrow expression body)
|
|
211
|
+
* () => { return true } (block whose first statement returns true)
|
|
212
|
+
* function () { return true }
|
|
213
|
+
* Conditional returns (a guard before `return true`), expressions like
|
|
214
|
+
* `() => true && x`, and unresolvable identifiers (`read: publicRead`) all
|
|
215
|
+
* return false. AST-based, so it doesn't false-match on substrings.
|
|
216
|
+
* Non-goal (scope demarcation, not risk-avoidance): this is a focused heuristic for the
|
|
217
|
+
* literal-`true` case. Folding always-truthy expressions like `() => (!false)` or
|
|
218
|
+
* `() => 1 === 1` is a separate concern with a different scope — it would belong in a
|
|
219
|
+
* dedicated `always-truthy-expression` check, not in this predicate.
|
|
220
|
+
*/
|
|
221
|
+
function returnsTrue(init) {
|
|
222
|
+
if (!init)
|
|
223
|
+
return false;
|
|
224
|
+
const firstStatementReturnsTrue = (body) => {
|
|
225
|
+
if (!ts_morph_1.Node.isBlock(body))
|
|
226
|
+
return false;
|
|
227
|
+
const first = body.getStatements()[0];
|
|
228
|
+
if (!first || !ts_morph_1.Node.isReturnStatement(first))
|
|
229
|
+
return false;
|
|
230
|
+
const expr = first.getExpression();
|
|
231
|
+
return !!expr && expr.getKind() === ts_morph_1.SyntaxKind.TrueKeyword;
|
|
232
|
+
};
|
|
233
|
+
if (ts_morph_1.Node.isArrowFunction(init)) {
|
|
234
|
+
const body = init.getBody();
|
|
235
|
+
if (ts_morph_1.Node.isBlock(body))
|
|
236
|
+
return firstStatementReturnsTrue(body);
|
|
237
|
+
let e = body;
|
|
238
|
+
while (ts_morph_1.Node.isParenthesizedExpression(e))
|
|
239
|
+
e = e.getExpression();
|
|
240
|
+
return e.getKind() === ts_morph_1.SyntaxKind.TrueKeyword;
|
|
241
|
+
}
|
|
242
|
+
if (ts_morph_1.Node.isFunctionExpression(init) || ts_morph_1.Node.isFunctionDeclaration(init)) {
|
|
243
|
+
const body = init.getBody();
|
|
244
|
+
return !!body && firstStatementReturnsTrue(body);
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
/** Find every CallExpression in a node subtree. */
|
|
249
|
+
function callsIn(node) {
|
|
250
|
+
return node.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression);
|
|
251
|
+
}
|
|
252
|
+
/** True if the file path looks like a migration/seed/script (system writes allowed). */
|
|
253
|
+
function isSystemPath(path) {
|
|
254
|
+
return /(migrations?|seeds?|scripts?|\.test\.|\.spec\.)/i.test(path);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Broader "runs as the system, not on behalf of an end-user request" signal:
|
|
258
|
+
* cron jobs, webhook receivers, background sync/worker/job files. In these the
|
|
259
|
+
* Local API's default `overrideAccess: true` is expected, not a bug.
|
|
260
|
+
*/
|
|
261
|
+
function isServerJobPath(path) {
|
|
262
|
+
if (isSystemPath(path))
|
|
263
|
+
return true;
|
|
264
|
+
if (/\/(cron|webhooks?|workers?|jobs|tasks|queue)\//i.test(path))
|
|
265
|
+
return true;
|
|
266
|
+
if (/(^|\/|[-.])sync(\.|-|$)/i.test(path) || /-(sync|worker|job|cron)\.(ts|js|tsx|jsx)$/i.test(path))
|
|
267
|
+
return true;
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
/** True if the file path looks like a request handler (Next route / server action). */
|
|
271
|
+
function looksLikeRequestFile(file) {
|
|
272
|
+
const p = file.getFilePath();
|
|
273
|
+
if (/\/(app|pages)\/.*\/route\.(ts|js|tsx|jsx)$/.test(p))
|
|
274
|
+
return true;
|
|
275
|
+
if (/\/api\//.test(p))
|
|
276
|
+
return true;
|
|
277
|
+
const text = file.getFullText();
|
|
278
|
+
if (/['"]use server['"]/.test(text))
|
|
279
|
+
return true;
|
|
280
|
+
return false;
|
|
281
|
+
}
|
package/dist/version.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "payload-doctor",
|
|
3
|
+
"version": "0.5.4",
|
|
4
|
+
"description": "Static security & correctness linter for Payload CMS (the headless CMS) — audits collections, access control, hooks, routes and config. Not an API-payload validator.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"payload-doctor": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"SKILL.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"prepublishOnly": "npm run build",
|
|
19
|
+
"test": "npm run build && node test/run.mjs",
|
|
20
|
+
"selftest": "node dist/cli.js test/fixtures/vulnerable --verbose"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"payload",
|
|
24
|
+
"payloadcms",
|
|
25
|
+
"security",
|
|
26
|
+
"linter",
|
|
27
|
+
"audit",
|
|
28
|
+
"static-analysis",
|
|
29
|
+
"access-control",
|
|
30
|
+
"nextjs",
|
|
31
|
+
"payload-cms",
|
|
32
|
+
"ts-morph"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"author": "Leander M. von Kraft (https://www.metakraft.de)",
|
|
36
|
+
"homepage": "https://github.com/metakraft/payload-doctor#readme",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/metakraft/payload-doctor.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/metakraft/payload-doctor/issues"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"ts-morph": "^24.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"typescript": "^5.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|