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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.projectChecks = projectChecks;
|
|
37
|
+
const ts_morph_1 = require("ts-morph");
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const util_1 = require("../util");
|
|
41
|
+
/** Line number (1-based) of the first occurrence of `needle` in `text`, else 1. */
|
|
42
|
+
function lineOf(text, needle) {
|
|
43
|
+
const idx = text.indexOf(needle);
|
|
44
|
+
if (idx < 0)
|
|
45
|
+
return 1;
|
|
46
|
+
return text.slice(0, idx).split('\n').length;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* All `payload` and `@payloadcms/*` packages must be on the exact same version;
|
|
50
|
+
* a mismatch is one of the most common causes of mysterious build/runtime breaks.
|
|
51
|
+
* Reads the root package.json (skips ranges/tags/workspace specs we can't compare).
|
|
52
|
+
*/
|
|
53
|
+
function dependencyVersionMismatch(ctx) {
|
|
54
|
+
const pkgPath = path.join(ctx.root, 'package.json');
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = fs.readFileSync(pkgPath, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
let pkg;
|
|
63
|
+
try {
|
|
64
|
+
pkg = JSON.parse(text);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
// Defensive schema guard: a malformed package.json (non-object root, or
|
|
70
|
+
// dependencies that aren't objects) must not throw — just skip the check.
|
|
71
|
+
if (typeof pkg !== 'object' || pkg === null || Array.isArray(pkg))
|
|
72
|
+
return [];
|
|
73
|
+
const asObj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
74
|
+
const deps = { ...asObj(pkg.dependencies), ...asObj(pkg.devDependencies) };
|
|
75
|
+
const byVersion = new Map();
|
|
76
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
77
|
+
if (name !== 'payload' && !name.startsWith('@payloadcms/'))
|
|
78
|
+
continue;
|
|
79
|
+
if (typeof spec !== 'string')
|
|
80
|
+
continue;
|
|
81
|
+
const v = spec.replace(/^[\^~>=<\s]+/, '');
|
|
82
|
+
if (!/^\d+\.\d+\.\d+/.test(v))
|
|
83
|
+
continue; // skip ranges/tags/workspace:* etc.
|
|
84
|
+
const arr = byVersion.get(v) ?? [];
|
|
85
|
+
arr.push(name);
|
|
86
|
+
byVersion.set(v, arr);
|
|
87
|
+
}
|
|
88
|
+
if (byVersion.size < 2)
|
|
89
|
+
return [];
|
|
90
|
+
const summary = [...byVersion.entries()].map(([v, names]) => `${names.length}×${v}`).join(', ');
|
|
91
|
+
const line = lineOf(text, '@payloadcms/') || lineOf(text, '"payload"');
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
ruleId: 'dependency-version-mismatch',
|
|
95
|
+
category: 'config',
|
|
96
|
+
severity: 'error',
|
|
97
|
+
message: `Mismatched payload / @payloadcms/* versions in package.json (${summary}) — keep all Payload packages on one version`,
|
|
98
|
+
file: ctx.rel(pkgPath),
|
|
99
|
+
line,
|
|
100
|
+
column: 1,
|
|
101
|
+
hint: 'Pin every payload and @payloadcms/* dependency to the exact same version.',
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Cross-file: relationship/upload fields that form a cycle (A → B → A or a
|
|
107
|
+
* self-reference). Cycles are often legitimate (category trees, related posts),
|
|
108
|
+
* so this is `info` — a reminder that maxDepth must bound population to avoid
|
|
109
|
+
* runaway queries.
|
|
110
|
+
*/
|
|
111
|
+
function circularRelationships(files, ctx) {
|
|
112
|
+
const adj = new Map();
|
|
113
|
+
const nodeBySlug = new Map();
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
for (const obj of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression)) {
|
|
116
|
+
if ((0, util_1.getConfigKind)(obj) !== 'collection')
|
|
117
|
+
continue;
|
|
118
|
+
const slugInit = (0, util_1.propInit)(obj, 'slug');
|
|
119
|
+
if (!slugInit || !ts_morph_1.Node.isStringLiteral(slugInit))
|
|
120
|
+
continue;
|
|
121
|
+
const slug = slugInit.getLiteralValue();
|
|
122
|
+
nodeBySlug.set(slug, slugInit);
|
|
123
|
+
const targets = adj.get(slug) ?? new Set();
|
|
124
|
+
for (const field of obj.getDescendantsOfKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression)) {
|
|
125
|
+
const t = (0, util_1.propInit)(field, 'type');
|
|
126
|
+
if (!t || !ts_morph_1.Node.isStringLiteral(t))
|
|
127
|
+
continue;
|
|
128
|
+
const tv = t.getLiteralValue();
|
|
129
|
+
if (tv !== 'relationship' && tv !== 'upload')
|
|
130
|
+
continue;
|
|
131
|
+
const rel = (0, util_1.propInit)(field, 'relationTo');
|
|
132
|
+
if (!rel)
|
|
133
|
+
continue;
|
|
134
|
+
if (ts_morph_1.Node.isStringLiteral(rel))
|
|
135
|
+
targets.add(rel.getLiteralValue());
|
|
136
|
+
else if (ts_morph_1.Node.isArrayLiteralExpression(rel)) {
|
|
137
|
+
for (const el of rel.getElements())
|
|
138
|
+
if (ts_morph_1.Node.isStringLiteral(el))
|
|
139
|
+
targets.add(el.getLiteralValue());
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
adj.set(slug, targets);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const known = new Set(adj.keys());
|
|
146
|
+
// Tarjan's SCC: O(V+E). A collection is in a cycle if its strongly-connected
|
|
147
|
+
// component has >1 member, or it references itself (self-loop).
|
|
148
|
+
let index = 0;
|
|
149
|
+
const idx = new Map();
|
|
150
|
+
const low = new Map();
|
|
151
|
+
const onStack = new Set();
|
|
152
|
+
const stack = [];
|
|
153
|
+
const sccOf = new Map();
|
|
154
|
+
const strongconnect = (v) => {
|
|
155
|
+
idx.set(v, index);
|
|
156
|
+
low.set(v, index);
|
|
157
|
+
index++;
|
|
158
|
+
stack.push(v);
|
|
159
|
+
onStack.add(v);
|
|
160
|
+
for (const w of adj.get(v) ?? []) {
|
|
161
|
+
if (!known.has(w))
|
|
162
|
+
continue;
|
|
163
|
+
if (!idx.has(w)) {
|
|
164
|
+
strongconnect(w);
|
|
165
|
+
low.set(v, Math.min(low.get(v), low.get(w)));
|
|
166
|
+
}
|
|
167
|
+
else if (onStack.has(w)) {
|
|
168
|
+
low.set(v, Math.min(low.get(v), idx.get(w)));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (low.get(v) === idx.get(v)) {
|
|
172
|
+
const comp = [];
|
|
173
|
+
let w;
|
|
174
|
+
do {
|
|
175
|
+
w = stack.pop();
|
|
176
|
+
onStack.delete(w);
|
|
177
|
+
comp.push(w);
|
|
178
|
+
} while (w !== v);
|
|
179
|
+
for (const m of comp)
|
|
180
|
+
sccOf.set(m, comp);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
for (const v of known)
|
|
184
|
+
if (!idx.has(v))
|
|
185
|
+
strongconnect(v);
|
|
186
|
+
const findings = [];
|
|
187
|
+
for (const slug of known) {
|
|
188
|
+
const comp = sccOf.get(slug) ?? [slug];
|
|
189
|
+
// NOTE: the selfLoop check is NOT redundant — Tarjan puts a self-referencing
|
|
190
|
+
// node in a size-1 SCC, indistinguishable from an acyclic node, so a plain
|
|
191
|
+
// SCC-size>1 test would miss real self-loops (e.g. articles -> articles).
|
|
192
|
+
const selfLoop = adj.get(slug)?.has(slug) ?? false;
|
|
193
|
+
if (comp.length < 2 && !selfLoop)
|
|
194
|
+
continue;
|
|
195
|
+
const node = nodeBySlug.get(slug);
|
|
196
|
+
if (!node)
|
|
197
|
+
continue;
|
|
198
|
+
const pathStr = comp.length >= 2 ? `${comp.join(' → ')} → ${comp[0]}` : `${slug} → ${slug}`;
|
|
199
|
+
findings.push((0, util_1.makeFinding)(node, ctx, 'circular-relationship', 'correctness', 'info', `Circular relationship: ${pathStr} — ensure maxDepth bounds population so queries don't recurse endlessly`, 'Cycles are fine (e.g. category trees); just keep query depth small and avoid populating the loop fully.'));
|
|
200
|
+
}
|
|
201
|
+
return findings;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Cross-file check: the same slug defined on more than one collection/global.
|
|
205
|
+
* Slugs must be unique — Payload derives DB collections and admin/API routes
|
|
206
|
+
* from them, so a duplicate silently shadows one definition.
|
|
207
|
+
*/
|
|
208
|
+
function projectChecks(files, ctx) {
|
|
209
|
+
const bySlug = new Map();
|
|
210
|
+
for (const file of files) {
|
|
211
|
+
for (const obj of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression)) {
|
|
212
|
+
const kind = (0, util_1.getConfigKind)(obj);
|
|
213
|
+
if (kind !== 'collection' && kind !== 'global')
|
|
214
|
+
continue;
|
|
215
|
+
const slugInit = (0, util_1.propInit)(obj, 'slug');
|
|
216
|
+
if (!slugInit || !ts_morph_1.Node.isStringLiteral(slugInit))
|
|
217
|
+
continue;
|
|
218
|
+
const val = slugInit.getLiteralValue();
|
|
219
|
+
const arr = bySlug.get(val) ?? [];
|
|
220
|
+
arr.push(slugInit);
|
|
221
|
+
bySlug.set(val, arr);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const findings = [];
|
|
225
|
+
for (const [val, nodes] of bySlug) {
|
|
226
|
+
if (nodes.length < 2)
|
|
227
|
+
continue;
|
|
228
|
+
for (const n of nodes) {
|
|
229
|
+
findings.push((0, util_1.makeFinding)(n, ctx, 'duplicate-slug', 'config', 'error', `Duplicate slug "${val}" — defined ${nodes.length} times across collections/globals`, 'Slugs must be unique; Payload builds DB collections and routes from them.'));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
findings.push(...dependencyVersionMismatch(ctx));
|
|
233
|
+
findings.push(...circularRelationships(files, ctx));
|
|
234
|
+
return findings;
|
|
235
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.qualityChecks = void 0;
|
|
4
|
+
const ts_morph_1 = require("ts-morph");
|
|
5
|
+
const util_1 = require("../util");
|
|
6
|
+
const LOOP_KINDS = new Set([
|
|
7
|
+
ts_morph_1.SyntaxKind.ForStatement,
|
|
8
|
+
ts_morph_1.SyntaxKind.ForOfStatement,
|
|
9
|
+
ts_morph_1.SyntaxKind.ForInStatement,
|
|
10
|
+
ts_morph_1.SyntaxKind.WhileStatement,
|
|
11
|
+
ts_morph_1.SyntaxKind.DoStatement,
|
|
12
|
+
]);
|
|
13
|
+
const ITER_METHODS = new Set(['map', 'forEach', 'filter', 'reduce', 'flatMap', 'some', 'every']);
|
|
14
|
+
/** Is this node lexically inside a loop or an array-iteration callback? */
|
|
15
|
+
function insideIteration(node) {
|
|
16
|
+
let cur = node.getParent();
|
|
17
|
+
while (cur) {
|
|
18
|
+
if (LOOP_KINDS.has(cur.getKind()))
|
|
19
|
+
return true;
|
|
20
|
+
if (ts_morph_1.Node.isCallExpression(cur)) {
|
|
21
|
+
const expr = cur.getExpression();
|
|
22
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(expr) && ITER_METHODS.has(expr.getName())) {
|
|
23
|
+
// only count it if our node is in an argument (the callback), not the array
|
|
24
|
+
if (cur.getArguments().some((a) => a === node || a.getDescendants().includes(node)))
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
cur = cur.getParent();
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A Local API read (`payload.find`/`findByID`/`findGlobal`) inside a loop or
|
|
34
|
+
* map/forEach callback is the classic N+1: one query per iteration. Batch it
|
|
35
|
+
* (a single `find` with an `in` filter) instead.
|
|
36
|
+
*/
|
|
37
|
+
const hookNPlusOne = {
|
|
38
|
+
id: 'hook-n-plus-one',
|
|
39
|
+
category: 'correctness',
|
|
40
|
+
describe: 'Local API read inside a loop / map (N+1 query)',
|
|
41
|
+
run(file, ctx) {
|
|
42
|
+
const findings = [];
|
|
43
|
+
for (const call of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
44
|
+
const local = (0, util_1.isLocalApiCall)(call);
|
|
45
|
+
if (!local || !util_1.READ_METHODS.has(local.method))
|
|
46
|
+
continue;
|
|
47
|
+
if (!insideIteration(call))
|
|
48
|
+
continue;
|
|
49
|
+
// If the queried collection/global is a string literal, every iteration hits
|
|
50
|
+
// the SAME collection -> a real, batchable N+1 (warning). If it's dynamic
|
|
51
|
+
// (a variable/expression), the loop likely spans different collections per
|
|
52
|
+
// item and can't be trivially batched -> info.
|
|
53
|
+
const target = (0, util_1.propInit)(local.arg, 'collection') ?? (0, util_1.propInit)(local.arg, 'global');
|
|
54
|
+
const literalTarget = !!target && ts_morph_1.Node.isStringLiteral(target);
|
|
55
|
+
const severity = literalTarget ? 'warning' : 'info';
|
|
56
|
+
findings.push((0, util_1.makeFinding)(call, ctx, 'hook-n-plus-one', 'correctness', severity, literalTarget
|
|
57
|
+
? `payload.${local.method} runs inside a loop/iteration on a fixed collection — one query per item (N+1)`
|
|
58
|
+
: `payload.${local.method} runs inside a loop on a dynamic collection — batch where possible (likely heterogeneous, so info)`, 'If the collection is fixed, fetch once with { where: { id: { in: ids } } } and map over the result.'));
|
|
59
|
+
}
|
|
60
|
+
return findings;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const SENSITIVE_LOG_WORDS = new Set([
|
|
64
|
+
'password',
|
|
65
|
+
'token',
|
|
66
|
+
'secret',
|
|
67
|
+
'apikey',
|
|
68
|
+
'privatekey',
|
|
69
|
+
'creditcard',
|
|
70
|
+
'ssn',
|
|
71
|
+
'cvv',
|
|
72
|
+
]);
|
|
73
|
+
const CONSOLE_METHODS = new Set(['log', 'error', 'warn', 'info', 'debug', 'trace']);
|
|
74
|
+
/**
|
|
75
|
+
* Logging a sensitive value (password/token/secret/…) leaks it into stdout and
|
|
76
|
+
* log aggregators. We match explicit sensitive property accesses in console.* args.
|
|
77
|
+
*/
|
|
78
|
+
const sensitiveDataLogged = {
|
|
79
|
+
id: 'sensitive-data-logged',
|
|
80
|
+
category: 'security',
|
|
81
|
+
describe: 'console.* logs a sensitive value (password/token/secret/…)',
|
|
82
|
+
run(file, ctx) {
|
|
83
|
+
const findings = [];
|
|
84
|
+
for (const call of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
85
|
+
const expr = call.getExpression();
|
|
86
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
87
|
+
continue;
|
|
88
|
+
if (expr.getExpression().getText() !== 'console')
|
|
89
|
+
continue;
|
|
90
|
+
if (!CONSOLE_METHODS.has(expr.getName()))
|
|
91
|
+
continue;
|
|
92
|
+
for (const arg of call.getArguments()) {
|
|
93
|
+
// property accesses like data.password, req.user.token
|
|
94
|
+
const accesses = arg.getDescendantsOfKind(ts_morph_1.SyntaxKind.PropertyAccessExpression);
|
|
95
|
+
const targets = accesses.length ? accesses : (ts_morph_1.Node.isPropertyAccessExpression(arg) ? [arg] : []);
|
|
96
|
+
const hit = targets.some((a) => (0, util_1.nameSegments)(a.getName()).some((s) => SENSITIVE_LOG_WORDS.has(s)));
|
|
97
|
+
if (hit) {
|
|
98
|
+
findings.push((0, util_1.makeFinding)(call, ctx, 'sensitive-data-logged', 'security', 'warning', 'A sensitive value (password/token/secret/…) is written to the console', 'Remove the log or redact the field; logs end up in stdout and aggregators.'));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
exports.qualityChecks = [hookNPlusOne, sensitiveDataLogged];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderingChecks = void 0;
|
|
4
|
+
const ts_morph_1 = require("ts-morph");
|
|
5
|
+
const util_1 = require("../util");
|
|
6
|
+
// Signals that a file deals with Payload rich text / CMS HTML.
|
|
7
|
+
const CMS_IMPORT = /(@payloadcms\/richtext|richtext-lexical|\blexical\b|serializeLexical|slate|RichText|from ['"]payload)/i;
|
|
8
|
+
// Signals that the rendered expression is CMS content.
|
|
9
|
+
const CMS_NAME = /(richtext|\bcontent\b|\bbody\b|\bhtml\b|lexical|serialized|\bblocks?\b|\bdescription\b)/i;
|
|
10
|
+
// Signals the HTML is already sanitized.
|
|
11
|
+
const SANITIZED = /sanitize|dompurify|\bpurify\b|clean\s*\(/i;
|
|
12
|
+
function htmlExpressionText(attr) {
|
|
13
|
+
if (!ts_morph_1.Node.isJsxAttribute(attr))
|
|
14
|
+
return '';
|
|
15
|
+
const init = attr.getInitializer();
|
|
16
|
+
if (!init || !ts_morph_1.Node.isJsxExpression(init))
|
|
17
|
+
return '';
|
|
18
|
+
const inner = init.getExpression();
|
|
19
|
+
if (!inner)
|
|
20
|
+
return '';
|
|
21
|
+
if (ts_morph_1.Node.isObjectLiteralExpression(inner)) {
|
|
22
|
+
const p = inner.getProperty('__html');
|
|
23
|
+
if (p && ts_morph_1.Node.isPropertyAssignment(p))
|
|
24
|
+
return p.getInitializer()?.getText() ?? '';
|
|
25
|
+
return inner.getText();
|
|
26
|
+
}
|
|
27
|
+
return inner.getText();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The block-render seam: rendering Payload rich text / block HTML into React
|
|
31
|
+
* (often shadcn/ui) via dangerouslySetInnerHTML without sanitization is a stored
|
|
32
|
+
* XSS risk when the source content is user-writable. We only flag cases that
|
|
33
|
+
* look like CMS content (otherwise it's a generic React concern for react-doctor).
|
|
34
|
+
*/
|
|
35
|
+
const unsafeRichtextRender = {
|
|
36
|
+
id: 'unsafe-richtext-render',
|
|
37
|
+
category: 'rendering',
|
|
38
|
+
describe: 'dangerouslySetInnerHTML renders CMS/rich-text content without sanitization',
|
|
39
|
+
run(file, ctx) {
|
|
40
|
+
const findings = [];
|
|
41
|
+
const fileText = file.getFullText();
|
|
42
|
+
const importSignal = CMS_IMPORT.test(fileText);
|
|
43
|
+
for (const attr of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxAttribute)) {
|
|
44
|
+
if (attr.getNameNode().getText() !== 'dangerouslySetInnerHTML')
|
|
45
|
+
continue;
|
|
46
|
+
const html = htmlExpressionText(attr);
|
|
47
|
+
if (SANITIZED.test(html))
|
|
48
|
+
continue; // already sanitized
|
|
49
|
+
// only flag when it clearly handles CMS/rich-text content
|
|
50
|
+
if (!importSignal && !CMS_NAME.test(html))
|
|
51
|
+
continue;
|
|
52
|
+
findings.push((0, util_1.makeFinding)(attr, ctx, 'unsafe-richtext-render', 'rendering', 'info', 'dangerouslySetInnerHTML renders rich-text/CMS HTML — review this sink: a static tool cannot tell whether the source is sanitized', 'If the HTML can ever come from user input, sanitize it (e.g. DOMPurify) and restrict write access to the source collection.'));
|
|
53
|
+
}
|
|
54
|
+
return findings;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
exports.renderingChecks = [unsafeRichtextRender];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.routeChecks = void 0;
|
|
4
|
+
const ts_morph_1 = require("ts-morph");
|
|
5
|
+
const util_1 = require("../util");
|
|
6
|
+
/**
|
|
7
|
+
* Fail-open secret check: `if (secret && header !== ...) return 401` means that
|
|
8
|
+
* when the secret is unset the guard is skipped and the endpoint is public.
|
|
9
|
+
*/
|
|
10
|
+
const cronNotFailClosed = {
|
|
11
|
+
id: 'cron-not-fail-closed',
|
|
12
|
+
category: 'security',
|
|
13
|
+
describe: 'Secret/cron auth check is fail-open (missing secret leaves endpoint public)',
|
|
14
|
+
run(file, ctx) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
for (const ifStmt of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.IfStatement)) {
|
|
17
|
+
const cond = ifStmt.getExpression();
|
|
18
|
+
if (!ts_morph_1.Node.isBinaryExpression(cond))
|
|
19
|
+
continue;
|
|
20
|
+
if (cond.getOperatorToken().getKind() !== ts_morph_1.SyntaxKind.AmpersandAmpersandToken)
|
|
21
|
+
continue;
|
|
22
|
+
const leftText = cond.getLeft().getText();
|
|
23
|
+
// Left operand is a bare secret-ish truthiness guard.
|
|
24
|
+
if (!/secret|token|apikey|api_key/i.test(leftText))
|
|
25
|
+
continue;
|
|
26
|
+
if (/[=!<>]/.test(leftText))
|
|
27
|
+
continue; // left already a comparison, not a bare guard
|
|
28
|
+
const thenText = ifStmt.getThenStatement().getText();
|
|
29
|
+
if (!/(401|403|unauthorized|forbidden|return|throw)/i.test(thenText))
|
|
30
|
+
continue;
|
|
31
|
+
findings.push((0, util_1.makeFinding)(ifStmt, ctx, 'cron-not-fail-closed', 'security', 'error', 'Auth guard is fail-open: if the secret is unset the check is skipped and the endpoint stays public', 'Fail closed: if (!secret) return 500/false BEFORE comparing the header.'));
|
|
32
|
+
}
|
|
33
|
+
return findings;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
function bodyHasMutation(node) {
|
|
37
|
+
for (const call of node.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
38
|
+
const local = (0, util_1.isLocalApiCall)(call);
|
|
39
|
+
if (local && util_1.MUTATION_METHODS.has(local.method))
|
|
40
|
+
return call;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* GET handlers that write are triggered by prefetch / link scanners / double
|
|
46
|
+
* renders. Detects Next.js `export function GET` and Payload endpoints with
|
|
47
|
+
* method:'get' whose body performs a Local API mutation.
|
|
48
|
+
*/
|
|
49
|
+
const sideEffectInGet = {
|
|
50
|
+
id: 'side-effect-in-get',
|
|
51
|
+
category: 'correctness',
|
|
52
|
+
describe: 'GET handler performs a write (prefetch/scanner can trigger it)',
|
|
53
|
+
run(file, ctx) {
|
|
54
|
+
const findings = [];
|
|
55
|
+
const path = file.getFilePath();
|
|
56
|
+
const isCron = /\/cron\//i.test(path);
|
|
57
|
+
const isUnsub = !isCron && /unsubscribe|opt-?out/i.test(path);
|
|
58
|
+
const sev = isCron ? 'info' : isUnsub ? 'warning' : 'error';
|
|
59
|
+
const note = isCron
|
|
60
|
+
? ' (Vercel cron requires GET — ensure the write is idempotent)'
|
|
61
|
+
: isUnsub
|
|
62
|
+
? ' (email one-click unsubscribe — but mail-client prefetch can auto-trigger it; prefer RFC 8058 List-Unsubscribe-Post or a confirm step)'
|
|
63
|
+
: '';
|
|
64
|
+
// Next.js route handlers: export async function GET(...) {}
|
|
65
|
+
for (const fn of file.getFunctions()) {
|
|
66
|
+
if (fn.getName() !== 'GET')
|
|
67
|
+
continue;
|
|
68
|
+
const body = fn.getBody();
|
|
69
|
+
if (!body)
|
|
70
|
+
continue;
|
|
71
|
+
const mutation = bodyHasMutation(body);
|
|
72
|
+
if (mutation) {
|
|
73
|
+
findings.push((0, util_1.makeFinding)(fn.getNameNode() ?? fn, ctx, 'side-effect-in-get', 'correctness', sev, `GET route handler performs a write — link prefetch or email scanners can trigger it unintentionally${note}`, 'Move the side effect to POST, or add an idempotency guard.'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Payload custom endpoints: { method: 'get', handler: ... }
|
|
77
|
+
for (const obj of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression)) {
|
|
78
|
+
const methodProp = obj.getProperty('method');
|
|
79
|
+
if (!methodProp || !ts_morph_1.Node.isPropertyAssignment(methodProp))
|
|
80
|
+
continue;
|
|
81
|
+
const m = methodProp.getInitializer()?.getText().replace(/['"`]/g, '').toLowerCase();
|
|
82
|
+
if (m !== 'get')
|
|
83
|
+
continue;
|
|
84
|
+
const handlerProp = obj.getProperty('handler');
|
|
85
|
+
if (!handlerProp)
|
|
86
|
+
continue;
|
|
87
|
+
const mutation = bodyHasMutation(obj);
|
|
88
|
+
if (mutation) {
|
|
89
|
+
findings.push((0, util_1.makeFinding)(methodProp, ctx, 'side-effect-in-get', 'correctness', sev, `Payload endpoint with method:'get' performs a write${note}`, 'Use method:\'post\' for mutating endpoints, or guard against repeated calls.'));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return findings;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
/** Returning error.message / error.stack to the client leaks internals. */
|
|
96
|
+
const leaksErrorMessage = {
|
|
97
|
+
id: 'leaks-error-message',
|
|
98
|
+
category: 'security',
|
|
99
|
+
describe: 'Internal error message/stack returned to the client',
|
|
100
|
+
run(file, ctx) {
|
|
101
|
+
const findings = [];
|
|
102
|
+
for (const pa of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.PropertyAssignment)) {
|
|
103
|
+
const name = pa.getName().replace(/['"`]/g, '');
|
|
104
|
+
if (name !== 'error' && name !== 'message')
|
|
105
|
+
continue;
|
|
106
|
+
const initText = pa.getInitializer()?.getText() ?? '';
|
|
107
|
+
if (/(^|[^\w.])(err|error|e|ex|exception)\s*\.\s*(message|stack)\b/.test(initText)) {
|
|
108
|
+
findings.push((0, util_1.makeFinding)(pa, ctx, 'leaks-error-message', 'security', 'warning', 'Response leaks an internal error message/stack to the client', 'Log the error server-side; return a generic message like "Internal server error".'));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return findings;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
exports.routeChecks = [cronNotFailClosed, sideEffectInGet, leaksErrorMessage];
|