server-action-audit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.fr.md +54 -0
- package/README.md +163 -2
- package/dist/chunk-GQSNZNKN.js +677 -0
- package/dist/chunk-GQSNZNKN.js.map +1 -0
- package/dist/cli.js +964 -0
- package/dist/cli.js.map +1 -0
- package/dist/eslint.cjs +796 -0
- package/dist/eslint.cjs.map +1 -0
- package/dist/eslint.d.cts +15 -0
- package/dist/eslint.d.ts +15 -0
- package/dist/eslint.js +112 -0
- package/dist/eslint.js.map +1 -0
- package/dist/index.cjs +933 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +123 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +234 -0
- package/dist/index.js.map +1 -0
- package/package.json +89 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { resolve as resolve2 } from "path";
|
|
5
|
+
|
|
6
|
+
// src/format.ts
|
|
7
|
+
var useColor = process.env.FORCE_COLOR ? true : !process.env.NO_COLOR && !!process.stdout.isTTY;
|
|
8
|
+
var paint = (code) => (text) => useColor ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
9
|
+
var red = paint("31");
|
|
10
|
+
var green = paint("32");
|
|
11
|
+
var yellow = paint("33");
|
|
12
|
+
var cyan = paint("36");
|
|
13
|
+
var dim = paint("2");
|
|
14
|
+
var bold = paint("1");
|
|
15
|
+
function describe(action) {
|
|
16
|
+
const parts = [];
|
|
17
|
+
if (action.status === "protected") parts.push(dim(action.reason ?? ""));
|
|
18
|
+
if (action.status === "public") parts.push(cyan(`public (${action.reason})`));
|
|
19
|
+
if (action.status === "unprotected") parts.push(red("no auth check"));
|
|
20
|
+
if (action.validated === false) parts.push(yellow("input not validated"));
|
|
21
|
+
return parts.join(dim(" \xB7 "));
|
|
22
|
+
}
|
|
23
|
+
function symbol(action) {
|
|
24
|
+
if (action.status === "unprotected") return red("\u2717");
|
|
25
|
+
if (action.validated === false) return yellow("!");
|
|
26
|
+
if (action.status === "public") return cyan("\u25CB");
|
|
27
|
+
return green("\u2713");
|
|
28
|
+
}
|
|
29
|
+
function formatPretty(report, options = {}) {
|
|
30
|
+
const { summary } = report;
|
|
31
|
+
const lines = [];
|
|
32
|
+
const fileCount = report.files.length;
|
|
33
|
+
lines.push(bold(`server-action-audit \xB7 ${summary.actions} Server Action${summary.actions === 1 ? "" : "s"} in ${fileCount} file${fileCount === 1 ? "" : "s"}`), "");
|
|
34
|
+
for (const { file, actions } of report.files) {
|
|
35
|
+
const shown = options.quiet ? actions.filter((a) => a.status === "unprotected" || a.validated === false) : actions;
|
|
36
|
+
if (!shown.length) continue;
|
|
37
|
+
lines.push(bold(file));
|
|
38
|
+
const width = Math.min(32, Math.max(...shown.map((a) => a.name.length)));
|
|
39
|
+
for (const action of shown) {
|
|
40
|
+
const where = dim(`${action.line}:${action.column}`.padEnd(7));
|
|
41
|
+
lines.push(` ${symbol(action)} ${action.name.padEnd(width)} ${where} ${describe(action)}`);
|
|
42
|
+
if (action.status === "unprotected" && action.hint) lines.push(` ${dim(action.hint)}`);
|
|
43
|
+
}
|
|
44
|
+
lines.push("");
|
|
45
|
+
}
|
|
46
|
+
for (const error of report.errors) lines.push(yellow(`skipped ${error.file}: ${error.message}`));
|
|
47
|
+
if (report.errors.length) lines.push("");
|
|
48
|
+
if (!summary.actions) {
|
|
49
|
+
lines.push("No Server Actions found.");
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
lines.push(
|
|
53
|
+
[
|
|
54
|
+
summary.unprotected ? red(`\u2717 ${summary.unprotected} unprotected`) : green("\u2717 0 unprotected"),
|
|
55
|
+
green(`\u2713 ${summary.protected} protected`),
|
|
56
|
+
cyan(`\u25CB ${summary.public} public`),
|
|
57
|
+
summary.unvalidated ? yellow(`! ${summary.unvalidated} with unvalidated input`) : dim("! 0 with unvalidated input")
|
|
58
|
+
].join(dim(" \xB7 "))
|
|
59
|
+
);
|
|
60
|
+
if (summary.unprotected) {
|
|
61
|
+
lines.push(
|
|
62
|
+
"",
|
|
63
|
+
dim("Every Server Action is a public endpoint: anyone can call it with a POST request."),
|
|
64
|
+
dim("Check the session inside each action, or mark intentional ones with // @public-action."),
|
|
65
|
+
dim("https://nextjs.org/docs/app/guides/data-security")
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
70
|
+
function formatJson(report) {
|
|
71
|
+
return JSON.stringify(
|
|
72
|
+
{
|
|
73
|
+
summary: report.summary,
|
|
74
|
+
files: report.files.map(({ file, actions }) => ({
|
|
75
|
+
file,
|
|
76
|
+
actions: actions.map(({ node: _node, ...action }) => action)
|
|
77
|
+
})),
|
|
78
|
+
errors: report.errors
|
|
79
|
+
},
|
|
80
|
+
null,
|
|
81
|
+
2
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
var AUTH_MESSAGE = (name) => `Server Action "${name}" has no authorization check: anyone can call it with a POST request. Check the session inside the action, or mark it with // @public-action if it is public on purpose.`;
|
|
85
|
+
var VALIDATION_MESSAGE = (name) => `Server Action "${name}" uses its arguments without validating them. They come straight from the client: parse them with a schema (Zod, Valibot\u2026).`;
|
|
86
|
+
function findings(report) {
|
|
87
|
+
const found = [];
|
|
88
|
+
for (const { file, actions } of report.files) {
|
|
89
|
+
for (const action of actions) {
|
|
90
|
+
if (action.status === "unprotected") {
|
|
91
|
+
found.push({ rule: "require-auth", level: "error", file, action, message: AUTH_MESSAGE(action.name) + (action.hint ? ` ${action.hint}` : "") });
|
|
92
|
+
}
|
|
93
|
+
if (action.validated === false) {
|
|
94
|
+
found.push({ rule: "require-input-validation", level: "warning", file, action, message: VALIDATION_MESSAGE(action.name) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
function formatGithub(report) {
|
|
101
|
+
const data = (text) => text.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
102
|
+
const property = (text) => data(text).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
103
|
+
const lines = findings(report).map(
|
|
104
|
+
({ rule, level, file, action, message }) => `::${level} file=${property(file)},line=${action.line},col=${action.column},title=${property(`server-action-audit/${rule}`)}::${data(message)}`
|
|
105
|
+
);
|
|
106
|
+
const { summary } = report;
|
|
107
|
+
lines.push(`server-action-audit: ${summary.actions} Server Actions, ${summary.unprotected} unprotected, ${summary.protected} protected, ${summary.public} public, ${summary.unvalidated} with unvalidated input.`);
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
function formatSarif(report, version) {
|
|
111
|
+
const rules = [
|
|
112
|
+
{
|
|
113
|
+
id: "require-auth",
|
|
114
|
+
name: "RequireAuth",
|
|
115
|
+
shortDescription: { text: "Server Action without an authorization check" },
|
|
116
|
+
fullDescription: { text: "Every Next.js Server Action is a public endpoint. This one does not check who is calling it." },
|
|
117
|
+
helpUri: "https://github.com/i6moons/server-action-audit#readme",
|
|
118
|
+
defaultConfiguration: { level: "error" },
|
|
119
|
+
properties: { tags: ["security", "nextjs"], "security-severity": "7.5", precision: "medium" }
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "require-input-validation",
|
|
123
|
+
name: "RequireInputValidation",
|
|
124
|
+
shortDescription: { text: "Server Action without input validation" },
|
|
125
|
+
fullDescription: { text: "The action uses arguments sent by the client without parsing them with a schema." },
|
|
126
|
+
helpUri: "https://github.com/i6moons/server-action-audit#readme",
|
|
127
|
+
defaultConfiguration: { level: "warning" },
|
|
128
|
+
properties: { tags: ["security", "nextjs"], "security-severity": "4.0", precision: "medium" }
|
|
129
|
+
}
|
|
130
|
+
];
|
|
131
|
+
const results = findings(report).map(({ rule, level, file, action, message }) => ({
|
|
132
|
+
ruleId: rule,
|
|
133
|
+
ruleIndex: rule === "require-auth" ? 0 : 1,
|
|
134
|
+
level,
|
|
135
|
+
message: { text: message },
|
|
136
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: file }, region: { startLine: action.line, startColumn: action.column } } }],
|
|
137
|
+
partialFingerprints: { actionName: `${file}#${action.name}` }
|
|
138
|
+
}));
|
|
139
|
+
return JSON.stringify(
|
|
140
|
+
{
|
|
141
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
142
|
+
version: "2.1.0",
|
|
143
|
+
runs: [{ tool: { driver: { name: "server-action-audit", version, informationUri: "https://github.com/i6moons/server-action-audit", rules } }, results }]
|
|
144
|
+
},
|
|
145
|
+
null,
|
|
146
|
+
2
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/scan.ts
|
|
151
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "fs";
|
|
152
|
+
import { join as join2, relative, sep } from "path";
|
|
153
|
+
|
|
154
|
+
// src/ast.ts
|
|
155
|
+
var SKIP = /* @__PURE__ */ new Set(["parent", "loc", "range", "tokens", "comments", "typeAnnotation", "returnType", "typeParameters"]);
|
|
156
|
+
function walk(root, visit) {
|
|
157
|
+
if (!root || typeof root.type !== "string") return;
|
|
158
|
+
if (visit(root) === false) return;
|
|
159
|
+
for (const key of Object.keys(root)) {
|
|
160
|
+
if (SKIP.has(key)) continue;
|
|
161
|
+
const value = root[key];
|
|
162
|
+
if (Array.isArray(value)) {
|
|
163
|
+
for (const child of value) if (child && typeof child.type === "string") walk(child, visit);
|
|
164
|
+
} else if (value && typeof value.type === "string") {
|
|
165
|
+
walk(value, visit);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function isFunction(node) {
|
|
170
|
+
return !!node && (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression");
|
|
171
|
+
}
|
|
172
|
+
function directives(body) {
|
|
173
|
+
const found = [];
|
|
174
|
+
for (const statement of body ?? []) {
|
|
175
|
+
if (statement.type !== "ExpressionStatement") break;
|
|
176
|
+
if (typeof statement.directive === "string") found.push(statement.directive);
|
|
177
|
+
else if (statement.expression?.type === "Literal" && typeof statement.expression.value === "string") found.push(statement.expression.value);
|
|
178
|
+
else break;
|
|
179
|
+
}
|
|
180
|
+
return found;
|
|
181
|
+
}
|
|
182
|
+
function functionDirectives(fn) {
|
|
183
|
+
return fn.body?.type === "BlockStatement" ? directives(fn.body.body) : [];
|
|
184
|
+
}
|
|
185
|
+
function unwrap(node) {
|
|
186
|
+
let current = node;
|
|
187
|
+
while (current.type === "ChainExpression" || current.type === "TSNonNullExpression" || current.type === "TSAsExpression" || current.type === "ParenthesizedExpression") {
|
|
188
|
+
current = current.expression;
|
|
189
|
+
}
|
|
190
|
+
return current;
|
|
191
|
+
}
|
|
192
|
+
function calleePath(callee) {
|
|
193
|
+
const node = unwrap(callee);
|
|
194
|
+
if (node.type === "Identifier") return node.name;
|
|
195
|
+
if (node.type === "MemberExpression") {
|
|
196
|
+
const property = !node.computed && node.property?.type === "Identifier" ? node.property.name : "*";
|
|
197
|
+
return `${calleePath(node.object)}.${property}`;
|
|
198
|
+
}
|
|
199
|
+
if (node.type === "Super") return "super";
|
|
200
|
+
if (node.type === "ThisExpression") return "this";
|
|
201
|
+
return "*";
|
|
202
|
+
}
|
|
203
|
+
function lastSegment(path) {
|
|
204
|
+
const index = path.lastIndexOf(".");
|
|
205
|
+
return index === -1 ? path : path.slice(index + 1);
|
|
206
|
+
}
|
|
207
|
+
function callsIn(root) {
|
|
208
|
+
const calls = [];
|
|
209
|
+
walk(root, (node) => {
|
|
210
|
+
if (node.type === "CallExpression") calls.push(node);
|
|
211
|
+
});
|
|
212
|
+
return calls;
|
|
213
|
+
}
|
|
214
|
+
function unconditionalCalls(fn, followReturned = false) {
|
|
215
|
+
const calls = [];
|
|
216
|
+
const expression = (node) => {
|
|
217
|
+
if (!node || typeof node.type !== "string" || isFunction(node)) return;
|
|
218
|
+
if (node.type === "CallExpression") calls.push(node);
|
|
219
|
+
for (const key of Object.keys(node)) {
|
|
220
|
+
if (SKIP.has(key)) continue;
|
|
221
|
+
const value = node[key];
|
|
222
|
+
if (Array.isArray(value)) value.forEach((child) => child && expression(child));
|
|
223
|
+
else if (value && typeof value.type === "string") expression(value);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const returned = (node) => {
|
|
227
|
+
if (followReturned && node && isFunction(node)) calls.push(...unconditionalCalls(node));
|
|
228
|
+
else expression(node);
|
|
229
|
+
};
|
|
230
|
+
const statement = (node) => {
|
|
231
|
+
if (!node) return;
|
|
232
|
+
switch (node.type) {
|
|
233
|
+
case "BlockStatement":
|
|
234
|
+
for (const child of node.body ?? []) statement(child);
|
|
235
|
+
break;
|
|
236
|
+
case "ExpressionStatement":
|
|
237
|
+
expression(node.expression);
|
|
238
|
+
break;
|
|
239
|
+
case "VariableDeclaration":
|
|
240
|
+
for (const declarator of node.declarations ?? []) expression(declarator.init);
|
|
241
|
+
break;
|
|
242
|
+
case "ReturnStatement":
|
|
243
|
+
returned(node.argument);
|
|
244
|
+
break;
|
|
245
|
+
case "ThrowStatement":
|
|
246
|
+
expression(node.argument);
|
|
247
|
+
break;
|
|
248
|
+
case "IfStatement":
|
|
249
|
+
expression(node.test);
|
|
250
|
+
break;
|
|
251
|
+
case "TryStatement":
|
|
252
|
+
statement(node.block);
|
|
253
|
+
break;
|
|
254
|
+
case "SwitchStatement":
|
|
255
|
+
expression(node.discriminant);
|
|
256
|
+
break;
|
|
257
|
+
case "DoWhileStatement":
|
|
258
|
+
statement(node.body);
|
|
259
|
+
break;
|
|
260
|
+
case "LabeledStatement":
|
|
261
|
+
statement(node.body);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
if (fn.body?.type === "BlockStatement") statement(fn.body);
|
|
266
|
+
else returned(fn.body);
|
|
267
|
+
return calls;
|
|
268
|
+
}
|
|
269
|
+
function callChain(call) {
|
|
270
|
+
const methods = [];
|
|
271
|
+
const functions = [];
|
|
272
|
+
const identifiers = [];
|
|
273
|
+
let current = unwrap(call);
|
|
274
|
+
for (; ; ) {
|
|
275
|
+
if (current.type === "CallExpression") {
|
|
276
|
+
for (const argument of current.arguments ?? []) {
|
|
277
|
+
if (isFunction(argument)) functions.unshift(argument);
|
|
278
|
+
else if (argument.type === "Identifier") identifiers.unshift(argument.name);
|
|
279
|
+
}
|
|
280
|
+
const callee = unwrap(current.callee);
|
|
281
|
+
if (callee.type === "MemberExpression") {
|
|
282
|
+
methods.unshift(!callee.computed && callee.property?.type === "Identifier" ? callee.property.name : "*");
|
|
283
|
+
current = unwrap(callee.object);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (callee.type === "Identifier") return { root: callee.name, methods, functions, identifiers };
|
|
287
|
+
return { root: null, methods, functions, identifiers };
|
|
288
|
+
}
|
|
289
|
+
if (current.type === "MemberExpression") {
|
|
290
|
+
methods.unshift(!current.computed && current.property?.type === "Identifier" ? current.property.name : "*");
|
|
291
|
+
current = unwrap(current.object);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (current.type === "AwaitExpression") {
|
|
295
|
+
current = unwrap(current.argument);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
return { root: current.type === "Identifier" ? current.name : null, methods, functions, identifiers };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function position(node) {
|
|
302
|
+
return node.loc ? { line: node.loc.start.line, column: node.loc.start.column + 1 } : { line: 0, column: 0 };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/options.ts
|
|
306
|
+
var EXACT_PATHS = ["auth"];
|
|
307
|
+
var NAMES = [
|
|
308
|
+
"currentUser",
|
|
309
|
+
// Clerk
|
|
310
|
+
"getServerSession",
|
|
311
|
+
// NextAuth v4
|
|
312
|
+
"getKindeServerSession",
|
|
313
|
+
// Kinde
|
|
314
|
+
"validateRequest",
|
|
315
|
+
// Lucia
|
|
316
|
+
"getIronSession",
|
|
317
|
+
// iron-session
|
|
318
|
+
"withAuth",
|
|
319
|
+
// WorkOS AuthKit
|
|
320
|
+
"verifyIdToken",
|
|
321
|
+
// Firebase Admin
|
|
322
|
+
"verifySessionCookie",
|
|
323
|
+
// Firebase Admin
|
|
324
|
+
"jwtVerify",
|
|
325
|
+
// jose
|
|
326
|
+
"verifyToken",
|
|
327
|
+
"verifyJwt",
|
|
328
|
+
"verifyAccessToken",
|
|
329
|
+
"getSession",
|
|
330
|
+
"verifySession",
|
|
331
|
+
"getCurrentUser",
|
|
332
|
+
"getAuthUser",
|
|
333
|
+
"getAuthenticatedUser",
|
|
334
|
+
"getSessionUser",
|
|
335
|
+
"getUserOrThrow",
|
|
336
|
+
"requireAuth",
|
|
337
|
+
"requireUser",
|
|
338
|
+
"requireSession",
|
|
339
|
+
"requireAdmin",
|
|
340
|
+
"requireRole",
|
|
341
|
+
"requirePermission",
|
|
342
|
+
"checkAuth",
|
|
343
|
+
"ensureAuth",
|
|
344
|
+
"ensureUser",
|
|
345
|
+
"assertAuth",
|
|
346
|
+
"assertUser",
|
|
347
|
+
"isAuthenticated",
|
|
348
|
+
"authenticate",
|
|
349
|
+
"authorize"
|
|
350
|
+
];
|
|
351
|
+
var SUFFIXES = [
|
|
352
|
+
"auth.getUser",
|
|
353
|
+
// Supabase
|
|
354
|
+
"auth.getClaims",
|
|
355
|
+
// Supabase
|
|
356
|
+
"auth.getSession",
|
|
357
|
+
// Supabase
|
|
358
|
+
"auth.protect",
|
|
359
|
+
// Clerk
|
|
360
|
+
"api.getSession"
|
|
361
|
+
// Better Auth
|
|
362
|
+
];
|
|
363
|
+
var AUTH_NAME = /auth|protect|guard|session|user|admin|private|secure|logged/i;
|
|
364
|
+
var HELPER_NAME = /auth|session|user|viewer|account|member|owner|admin|role|permission|access|identity|token|login|guard|protect|secure|private|require|ensure|assert|verify|check|current|^me$|^whoami$/i;
|
|
365
|
+
var GUEST_GUARD = /(?:throw|redirect|fail|reject|error)If(?:Authenticated|Authed|LoggedIn|SignedIn|Session)|requireGuest|guestOnly|onlyGuests?|(?:require|ensure|assert)(?:Not|No)(?:Authenticated|LoggedIn|SignedIn|Session|User)|unauthenticated|anonymous/i;
|
|
366
|
+
function resolveOptions(options = {}) {
|
|
367
|
+
const extra = options.authFunctions ?? [];
|
|
368
|
+
return {
|
|
369
|
+
exactPaths: new Set(EXACT_PATHS),
|
|
370
|
+
names: /* @__PURE__ */ new Set([...NAMES, ...extra.filter((name) => !name.includes("."))]),
|
|
371
|
+
suffixes: [...SUFFIXES, ...extra.filter((name) => name.includes("."))],
|
|
372
|
+
authWrappers: new Set(options.authWrappers ?? []),
|
|
373
|
+
authClients: new Set(options.authClients ?? []),
|
|
374
|
+
publicActions: new Set(options.publicActions ?? []),
|
|
375
|
+
ignore: options.ignore ?? []
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function isAuthCall(path, options) {
|
|
379
|
+
if (options.exactPaths.has(path)) return true;
|
|
380
|
+
if (options.names.has(lastSegment(path))) return true;
|
|
381
|
+
return options.suffixes.some((suffix) => path === suffix || path.endsWith(`.${suffix}`));
|
|
382
|
+
}
|
|
383
|
+
var VALIDATION_METHODS = /* @__PURE__ */ new Set(["parse", "safeParse", "parseAsync", "safeParseAsync", "validate", "validateSync", "validateAsync", "assert"]);
|
|
384
|
+
var NOT_VALIDATION = /* @__PURE__ */ new Set(["JSON", "Date", "Number", "URL", "path", "Math", "BigInt", "console", "assert"]);
|
|
385
|
+
function isValidationCall(path) {
|
|
386
|
+
const method = lastSegment(path);
|
|
387
|
+
if (!VALIDATION_METHODS.has(method)) return false;
|
|
388
|
+
const owner = path.includes(".") ? path.slice(0, path.lastIndexOf(".")) : "";
|
|
389
|
+
return !NOT_VALIDATION.has(lastSegment(owner) || owner);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/analyze.ts
|
|
393
|
+
var PUBLIC_MARKER = /@public-action\b/;
|
|
394
|
+
var CLIENT_METHODS = /* @__PURE__ */ new Set(["action", "stateAction", "handler", "createServerAction", "createAction"]);
|
|
395
|
+
var SCHEMA_METHODS = /* @__PURE__ */ new Set(["schema", "inputSchema", "input", "bindArgsSchemas"]);
|
|
396
|
+
var MAX_DEPTH = 4;
|
|
397
|
+
var ModuleScope = class {
|
|
398
|
+
constructor(ast, filename) {
|
|
399
|
+
this.ast = ast;
|
|
400
|
+
this.filename = filename;
|
|
401
|
+
for (const statement of ast.body ?? []) this.index(statement);
|
|
402
|
+
}
|
|
403
|
+
ast;
|
|
404
|
+
filename;
|
|
405
|
+
functions = /* @__PURE__ */ new Map();
|
|
406
|
+
imports = /* @__PURE__ */ new Map();
|
|
407
|
+
exports = /* @__PURE__ */ new Map();
|
|
408
|
+
/** Top-level declarations by name, with the statement that holds them (for comments). */
|
|
409
|
+
declarations = /* @__PURE__ */ new Map();
|
|
410
|
+
declare(name, init, statement) {
|
|
411
|
+
this.declarations.set(name, { init, statement });
|
|
412
|
+
if (isFunction(init)) this.functions.set(name, init);
|
|
413
|
+
else if (init.type === "CallExpression") {
|
|
414
|
+
const wrapped = (init.arguments ?? []).find((argument) => isFunction(argument));
|
|
415
|
+
if (wrapped) this.functions.set(name, wrapped);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
index(statement) {
|
|
419
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
420
|
+
if (statement.type === "ImportDeclaration" && statement.importKind !== "type") {
|
|
421
|
+
for (const specifier of statement.specifiers ?? []) {
|
|
422
|
+
const imported = specifier.type === "ImportDefaultSpecifier" ? "default" : specifier.type === "ImportNamespaceSpecifier" ? "*" : specifier.imported?.name ?? specifier.imported?.value;
|
|
423
|
+
this.imports.set(specifier.local.name, { source: statement.source.value, imported });
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (declaration?.type === "FunctionDeclaration" && declaration.id) {
|
|
428
|
+
this.declare(declaration.id.name, declaration, statement);
|
|
429
|
+
if (statement.type === "ExportNamedDeclaration") this.exports.set(declaration.id.name, declaration.id.name);
|
|
430
|
+
}
|
|
431
|
+
if (declaration?.type === "VariableDeclaration") {
|
|
432
|
+
for (const declarator of declaration.declarations ?? []) {
|
|
433
|
+
if (declarator.id?.type !== "Identifier" || !declarator.init) continue;
|
|
434
|
+
this.declare(declarator.id.name, declarator.init, statement);
|
|
435
|
+
if (statement.type === "ExportNamedDeclaration") this.exports.set(declarator.id.name, declarator.id.name);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (statement.type === "ExportNamedDeclaration" && !statement.declaration) {
|
|
439
|
+
for (const specifier of statement.specifiers ?? []) {
|
|
440
|
+
const exported = specifier.exported?.name ?? specifier.exported?.value;
|
|
441
|
+
const local = specifier.local?.name ?? specifier.local?.value;
|
|
442
|
+
this.exports.set(exported, statement.source ? { source: statement.source.value, imported: local } : local);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (statement.type === "ExportDefaultDeclaration") {
|
|
446
|
+
const value = statement.declaration;
|
|
447
|
+
if (value?.type === "Identifier") this.exports.set("default", value.name);
|
|
448
|
+
else if (value) {
|
|
449
|
+
this.declare("default", value, statement);
|
|
450
|
+
this.exports.set("default", "default");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
function scopeOf(module, context) {
|
|
456
|
+
let scope = context.scopes.get(module.filename);
|
|
457
|
+
if (!scope) {
|
|
458
|
+
scope = new ModuleScope(module.ast, module.filename);
|
|
459
|
+
context.scopes.set(module.filename, scope);
|
|
460
|
+
}
|
|
461
|
+
return scope;
|
|
462
|
+
}
|
|
463
|
+
function resolveFunction(name, scope, context, depth) {
|
|
464
|
+
const local = scope.functions.get(name);
|
|
465
|
+
if (local) return { fn: local, scope };
|
|
466
|
+
const binding = scope.imports.get(name);
|
|
467
|
+
if (!binding || !context.resolver || depth > MAX_DEPTH) return null;
|
|
468
|
+
return exportedFunction(binding, scope, context, depth + 1);
|
|
469
|
+
}
|
|
470
|
+
function exportedFunction(binding, from, context, depth) {
|
|
471
|
+
if (depth > MAX_DEPTH || binding.imported === "*") return null;
|
|
472
|
+
const module = context.resolver?.(binding.source, from.filename);
|
|
473
|
+
if (!module) return null;
|
|
474
|
+
const scope = scopeOf(module, context);
|
|
475
|
+
const entry = scope.exports.get(binding.imported);
|
|
476
|
+
if (entry === void 0) return null;
|
|
477
|
+
if (typeof entry === "string") {
|
|
478
|
+
const fn = scope.functions.get(entry);
|
|
479
|
+
return fn ? { fn, scope } : resolveFunction(entry, scope, context, depth);
|
|
480
|
+
}
|
|
481
|
+
return exportedFunction(entry, scope, context, depth + 1);
|
|
482
|
+
}
|
|
483
|
+
function authEvidence(fn, scope, context, depth = 0, seen = /* @__PURE__ */ new Set(), followReturned = false) {
|
|
484
|
+
if (depth > MAX_DEPTH || seen.has(fn)) return null;
|
|
485
|
+
seen.add(fn);
|
|
486
|
+
const calls = unconditionalCalls(fn, followReturned);
|
|
487
|
+
for (const call of calls) {
|
|
488
|
+
const path = calleePath(call.callee);
|
|
489
|
+
if (isAuthCall(path, context.options)) return `${path}()`;
|
|
490
|
+
}
|
|
491
|
+
for (const call of calls) {
|
|
492
|
+
const path = calleePath(call.callee);
|
|
493
|
+
if (path.includes(".") || path === "*" || GUEST_GUARD.test(path) || !HELPER_NAME.test(path)) continue;
|
|
494
|
+
const helper = resolveFunction(path, scope, context, depth);
|
|
495
|
+
if (!helper) continue;
|
|
496
|
+
const evidence = authEvidence(helper.fn, helper.scope, context, depth + 1, seen);
|
|
497
|
+
if (evidence) return `${path}() \u2192 ${evidence}`;
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
function boundNames(pattern, names = []) {
|
|
502
|
+
if (!pattern) return names;
|
|
503
|
+
if (pattern.type === "Identifier") names.push(pattern.name);
|
|
504
|
+
else if (pattern.type === "ObjectPattern") for (const property of pattern.properties ?? []) boundNames(property.type === "RestElement" ? property.argument : property.value, names);
|
|
505
|
+
else if (pattern.type === "ArrayPattern") for (const element of pattern.elements ?? []) boundNames(element, names);
|
|
506
|
+
else if (pattern.type === "AssignmentPattern") boundNames(pattern.left, names);
|
|
507
|
+
else if (pattern.type === "RestElement") boundNames(pattern.argument, names);
|
|
508
|
+
return names;
|
|
509
|
+
}
|
|
510
|
+
function closureEvidence(action, enclosing, scope, context) {
|
|
511
|
+
if (enclosing.body?.type !== "BlockStatement") return null;
|
|
512
|
+
const fromAuth = /* @__PURE__ */ new Map();
|
|
513
|
+
for (const statement of enclosing.body.body ?? []) {
|
|
514
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
515
|
+
for (const declarator of statement.declarations ?? []) {
|
|
516
|
+
const evidence = authEvidence({ type: "ArrowFunctionExpression", params: [], body: declarator.init }, scope, context);
|
|
517
|
+
if (evidence) for (const name of boundNames(declarator.id)) fromAuth.set(name, evidence);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (!fromAuth.size) return null;
|
|
521
|
+
let found = null;
|
|
522
|
+
walk(action.body, (node) => {
|
|
523
|
+
if (found) return false;
|
|
524
|
+
if (node.type === "Identifier" && fromAuth.has(node.name)) found = `${node.name} from ${fromAuth.get(node.name)} in the enclosing component`;
|
|
525
|
+
});
|
|
526
|
+
return found;
|
|
527
|
+
}
|
|
528
|
+
function declarationFrom(binding, from, context, depth) {
|
|
529
|
+
if (depth > MAX_DEPTH || binding.imported === "*" || !context.resolver) return null;
|
|
530
|
+
const module = context.resolver(binding.source, from.filename);
|
|
531
|
+
if (!module) return null;
|
|
532
|
+
const scope = scopeOf(module, context);
|
|
533
|
+
const entry = scope.exports.get(binding.imported);
|
|
534
|
+
if (typeof entry === "string") return resolveDeclaration(entry, scope, context, depth + 1);
|
|
535
|
+
return entry ? declarationFrom(entry, scope, context, depth + 1) : null;
|
|
536
|
+
}
|
|
537
|
+
function resolveDeclaration(name, scope, context, depth) {
|
|
538
|
+
const local = scope.declarations.get(name);
|
|
539
|
+
if (local) return { init: local.init, scope };
|
|
540
|
+
const binding = scope.imports.get(name);
|
|
541
|
+
return binding ? declarationFrom(binding, scope, context, depth) : null;
|
|
542
|
+
}
|
|
543
|
+
function definitionEvidence(name, scope, context, depth = 0) {
|
|
544
|
+
if (depth > MAX_DEPTH || GUEST_GUARD.test(name)) return null;
|
|
545
|
+
if (depth > 0 && (context.options.authClients.has(name) || context.options.authWrappers.has(name) || AUTH_NAME.test(name))) return name;
|
|
546
|
+
const definition = resolveFunction(name, scope, context, 0);
|
|
547
|
+
const evidence = definition && authEvidence(definition.fn, definition.scope, context, 1, /* @__PURE__ */ new Set(), true);
|
|
548
|
+
if (evidence) return `${name} \u2192 ${evidence}`;
|
|
549
|
+
const declaration = resolveDeclaration(name, scope, context, 0);
|
|
550
|
+
if (declaration?.init.type === "CallExpression") {
|
|
551
|
+
const chain = callChain(declaration.init);
|
|
552
|
+
for (const base of [chain.root, ...chain.identifiers]) {
|
|
553
|
+
if (!base || base === name) continue;
|
|
554
|
+
const inherited = definitionEvidence(base, declaration.scope, context, depth + 1);
|
|
555
|
+
if (inherited) return `${name} \u2192 ${inherited}`;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
function validatesDirectly(fn) {
|
|
561
|
+
return callsIn(fn.body).some((call) => isValidationCall(calleePath(call.callee)));
|
|
562
|
+
}
|
|
563
|
+
function parameterCount(fn) {
|
|
564
|
+
return (fn.params ?? []).length;
|
|
565
|
+
}
|
|
566
|
+
function leadingComments(node, comments, text) {
|
|
567
|
+
const start = node.range?.[0];
|
|
568
|
+
if (start === void 0) return [];
|
|
569
|
+
const found = [];
|
|
570
|
+
let cursor = start;
|
|
571
|
+
for (const comment of [...comments].reverse()) {
|
|
572
|
+
const [from, to] = comment.range ?? [Infinity, Infinity];
|
|
573
|
+
if (to > cursor) continue;
|
|
574
|
+
if (text.slice(to, cursor).trim() !== "") break;
|
|
575
|
+
found.push(comment);
|
|
576
|
+
cursor = from;
|
|
577
|
+
}
|
|
578
|
+
return found;
|
|
579
|
+
}
|
|
580
|
+
function bodyHeadComments(fn, comments) {
|
|
581
|
+
if (fn.body?.type !== "BlockStatement" || !fn.body.range) return [];
|
|
582
|
+
const [bodyStart, bodyEnd] = fn.body.range;
|
|
583
|
+
const statements = fn.body.body ?? [];
|
|
584
|
+
const firstReal = statements.find((statement) => !(statement.type === "ExpressionStatement" && typeof statement.directive === "string"));
|
|
585
|
+
const limit = firstReal?.range?.[0] ?? bodyEnd;
|
|
586
|
+
return comments.filter((comment) => comment.range && comment.range[0] > bodyStart && comment.range[1] <= limit);
|
|
587
|
+
}
|
|
588
|
+
function moduleCandidates(scope) {
|
|
589
|
+
const candidates = [];
|
|
590
|
+
for (const [exported, entry] of scope.exports) {
|
|
591
|
+
if (typeof entry !== "string") continue;
|
|
592
|
+
const declaration = scope.declarations.get(entry);
|
|
593
|
+
if (!declaration) continue;
|
|
594
|
+
const { init, statement } = declaration;
|
|
595
|
+
const target = init.type === "AwaitExpression" ? init.argument : init;
|
|
596
|
+
if (!isFunction(target) && target.type !== "CallExpression") continue;
|
|
597
|
+
const name = exported === "default" && target.id?.type === "Identifier" ? target.id.name : exported;
|
|
598
|
+
candidates.push({ name, kind: "module", target, anchors: [statement, init] });
|
|
599
|
+
}
|
|
600
|
+
return candidates;
|
|
601
|
+
}
|
|
602
|
+
function inlineCandidates(ast, skip) {
|
|
603
|
+
const candidates = [];
|
|
604
|
+
const visit = (node, parent, grandparent, enclosing) => {
|
|
605
|
+
if (isFunction(node) && !skip.has(node) && functionDirectives(node).includes("use server")) {
|
|
606
|
+
let name = node.id?.name;
|
|
607
|
+
if (!name && parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") name = parent.id.name;
|
|
608
|
+
if (!name && (parent?.type === "Property" || parent?.type === "MethodDefinition") && parent.key?.type === "Identifier") name = parent.key.name;
|
|
609
|
+
if (!name && parent?.type === "JSXExpressionContainer" && grandparent?.type === "JSXAttribute") name = `(inline ${grandparent.name?.name ?? "action"})`;
|
|
610
|
+
candidates.push({ name: name ?? "(inline)", kind: "inline", target: node, anchors: [parent?.type === "VariableDeclarator" ? grandparent ?? node : node], enclosing });
|
|
611
|
+
}
|
|
612
|
+
const inner = isFunction(node) ? node : enclosing;
|
|
613
|
+
for (const key of Object.keys(node)) {
|
|
614
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
615
|
+
const value = node[key];
|
|
616
|
+
if (Array.isArray(value)) {
|
|
617
|
+
for (const child of value) if (child && typeof child.type === "string") visit(child, node, parent, inner);
|
|
618
|
+
} else if (value && typeof value.type === "string") {
|
|
619
|
+
visit(value, node, parent, inner);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
visit(ast, null, null, null);
|
|
624
|
+
return candidates;
|
|
625
|
+
}
|
|
626
|
+
function analyzeCandidate(candidate, scope, context, comments, text) {
|
|
627
|
+
const { target } = candidate;
|
|
628
|
+
let reason = null;
|
|
629
|
+
let validated = null;
|
|
630
|
+
let hint = null;
|
|
631
|
+
const handlers = [];
|
|
632
|
+
if (isFunction(target)) {
|
|
633
|
+
handlers.push(target);
|
|
634
|
+
reason = authEvidence(target, scope, context);
|
|
635
|
+
if (!reason && candidate.enclosing) reason = closureEvidence(target, candidate.enclosing, scope, context);
|
|
636
|
+
validated = parameterCount(target) === 0 ? null : validatesDirectly(target);
|
|
637
|
+
} else {
|
|
638
|
+
const chain = callChain(target);
|
|
639
|
+
handlers.push(...chain.functions);
|
|
640
|
+
const isClient = chain.methods.some((method) => CLIENT_METHODS.has(method));
|
|
641
|
+
const root = chain.root;
|
|
642
|
+
const trusted = root !== null && (context.options.authClients.has(root) || context.options.authWrappers.has(root) || AUTH_NAME.test(root) && !GUEST_GUARD.test(root));
|
|
643
|
+
if (trusted) reason = isClient ? `${root} client` : `${root}() wrapper`;
|
|
644
|
+
for (const handler of chain.functions) reason ??= authEvidence(handler, scope, context);
|
|
645
|
+
for (const name of [root, ...chain.identifiers]) {
|
|
646
|
+
if (!reason && name) reason = definitionEvidence(name, scope, context);
|
|
647
|
+
}
|
|
648
|
+
if (isClient) {
|
|
649
|
+
validated = chain.methods.some((method) => SCHEMA_METHODS.has(method)) ? true : null;
|
|
650
|
+
} else if (root && /valid|schema|zod|parse/i.test(root)) {
|
|
651
|
+
validated = true;
|
|
652
|
+
} else {
|
|
653
|
+
const handler = chain.functions[0];
|
|
654
|
+
validated = !handler || parameterCount(handler) === 0 ? null : validatesDirectly(handler);
|
|
655
|
+
}
|
|
656
|
+
if (!reason && root) {
|
|
657
|
+
hint = isClient ? `If "${root}" has auth middleware, add it to "authClients".` : `If ${root}() checks the session, add it to "authWrappers".`;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const markerComments = [
|
|
661
|
+
...candidate.anchors.flatMap((anchor) => leadingComments(anchor, comments, text)),
|
|
662
|
+
...handlers.flatMap((handler) => bodyHeadComments(handler, comments))
|
|
663
|
+
];
|
|
664
|
+
const markedPublic = markerComments.some((comment) => PUBLIC_MARKER.test(comment.value));
|
|
665
|
+
const listedPublic = context.options.publicActions.has(candidate.name);
|
|
666
|
+
const status = reason ? "protected" : markedPublic || listedPublic ? "public" : "unprotected";
|
|
667
|
+
if (status === "public") {
|
|
668
|
+
reason = markedPublic ? "@public-action" : "publicActions";
|
|
669
|
+
hint = null;
|
|
670
|
+
}
|
|
671
|
+
const { line, column } = position(candidate.anchors[0] ?? target);
|
|
672
|
+
return { name: candidate.name, kind: candidate.kind, line, column, status, reason, validated, hint, node: candidate.anchors[0] ?? target };
|
|
673
|
+
}
|
|
674
|
+
function analyzeModule(input, options, resolver) {
|
|
675
|
+
if (!input.text.includes("use server")) return [];
|
|
676
|
+
const context = { options, resolver, scopes: /* @__PURE__ */ new Map() };
|
|
677
|
+
const scope = scopeOf({ ast: input.ast, filename: input.filename }, context);
|
|
678
|
+
const comments = input.comments ?? input.ast.comments ?? [];
|
|
679
|
+
const candidates = [];
|
|
680
|
+
if (directives(input.ast.body).includes("use server")) candidates.push(...moduleCandidates(scope));
|
|
681
|
+
const covered = new Set(candidates.map((candidate) => candidate.target));
|
|
682
|
+
candidates.push(...inlineCandidates(input.ast, covered));
|
|
683
|
+
return candidates.map((candidate) => analyzeCandidate(candidate, scope, context, comments, input.text)).sort((a, b) => a.line - b.line || a.column - b.column);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// src/resolve.ts
|
|
687
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
688
|
+
import { dirname, join, resolve } from "path";
|
|
689
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
690
|
+
var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
|
|
691
|
+
function parseModule(text, filename) {
|
|
692
|
+
return parse(text, {
|
|
693
|
+
jsx: !/\.[cm]?ts$/.test(filename),
|
|
694
|
+
loc: true,
|
|
695
|
+
range: true,
|
|
696
|
+
comment: true
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
function isFile(path) {
|
|
700
|
+
try {
|
|
701
|
+
return statSync(path).isFile();
|
|
702
|
+
} catch {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
var TS_SOURCES = { ".js": [".ts", ".tsx"], ".jsx": [".tsx"], ".mjs": [".mts"], ".cjs": [".cts"] };
|
|
707
|
+
function tryFile(base) {
|
|
708
|
+
if (isFile(base)) return base;
|
|
709
|
+
const jsExtension = base.match(/\.(js|jsx|mjs|cjs)$/)?.[0];
|
|
710
|
+
if (jsExtension) {
|
|
711
|
+
for (const extension of TS_SOURCES[jsExtension] ?? []) {
|
|
712
|
+
const candidate = base.slice(0, -jsExtension.length) + extension;
|
|
713
|
+
if (isFile(candidate)) return candidate;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
for (const extension of EXTENSIONS) if (isFile(base + extension)) return base + extension;
|
|
717
|
+
for (const extension of EXTENSIONS) if (isFile(join(base, `index${extension}`))) return join(base, `index${extension}`);
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
function parseJsonc(text) {
|
|
721
|
+
let out = "";
|
|
722
|
+
let inString = false;
|
|
723
|
+
for (let i = 0; i < text.length; i++) {
|
|
724
|
+
const char = text[i];
|
|
725
|
+
if (inString) {
|
|
726
|
+
out += char;
|
|
727
|
+
if (char === "\\") out += text[++i] ?? "";
|
|
728
|
+
else if (char === '"') inString = false;
|
|
729
|
+
} else if (char === '"') {
|
|
730
|
+
inString = true;
|
|
731
|
+
out += char;
|
|
732
|
+
} else if (char === "/" && text[i + 1] === "/") {
|
|
733
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
734
|
+
out += "\n";
|
|
735
|
+
} else if (char === "/" && text[i + 1] === "*") {
|
|
736
|
+
i = text.indexOf("*/", i + 2);
|
|
737
|
+
if (i === -1) break;
|
|
738
|
+
i++;
|
|
739
|
+
} else {
|
|
740
|
+
out += char;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
744
|
+
}
|
|
745
|
+
var mappings = /* @__PURE__ */ new Map();
|
|
746
|
+
function pathMapping(fromDir) {
|
|
747
|
+
if (mappings.has(fromDir)) return mappings.get(fromDir);
|
|
748
|
+
let mapping = null;
|
|
749
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
750
|
+
const file = join(fromDir, name);
|
|
751
|
+
if (!existsSync(file)) continue;
|
|
752
|
+
try {
|
|
753
|
+
const config = parseJsonc(readFileSync(file, "utf8"));
|
|
754
|
+
const options = config.compilerOptions ?? {};
|
|
755
|
+
if (options.paths) mapping = { base: resolve(fromDir, options.baseUrl ?? "."), paths: options.paths };
|
|
756
|
+
} catch {
|
|
757
|
+
mapping = null;
|
|
758
|
+
}
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
if (!mapping) {
|
|
762
|
+
const parent = dirname(fromDir);
|
|
763
|
+
mapping = parent !== fromDir && !existsSync(join(fromDir, "package.json")) ? pathMapping(parent) : null;
|
|
764
|
+
}
|
|
765
|
+
mappings.set(fromDir, mapping);
|
|
766
|
+
return mapping;
|
|
767
|
+
}
|
|
768
|
+
function resolveAlias(specifier, fromFile) {
|
|
769
|
+
const mapping = pathMapping(dirname(fromFile));
|
|
770
|
+
if (!mapping) return null;
|
|
771
|
+
for (const [pattern, targets] of Object.entries(mapping.paths)) {
|
|
772
|
+
const star = pattern.indexOf("*");
|
|
773
|
+
let captured = null;
|
|
774
|
+
if (star === -1) captured = specifier === pattern ? "" : null;
|
|
775
|
+
else {
|
|
776
|
+
const prefix = pattern.slice(0, star);
|
|
777
|
+
const suffix = pattern.slice(star + 1);
|
|
778
|
+
if (specifier.startsWith(prefix) && specifier.endsWith(suffix) && specifier.length >= prefix.length + suffix.length) {
|
|
779
|
+
captured = specifier.slice(prefix.length, specifier.length - suffix.length);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (captured === null) continue;
|
|
783
|
+
for (const target of targets) {
|
|
784
|
+
const file = tryFile(resolve(mapping.base, target.replace("*", captured)));
|
|
785
|
+
if (file) return file;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
function resolveImportPath(specifier, fromFile) {
|
|
791
|
+
if (specifier.startsWith(".")) return tryFile(resolve(dirname(fromFile), specifier));
|
|
792
|
+
if (specifier.startsWith("/")) return tryFile(specifier);
|
|
793
|
+
return resolveAlias(specifier, fromFile);
|
|
794
|
+
}
|
|
795
|
+
function createNodeResolver() {
|
|
796
|
+
const cache = /* @__PURE__ */ new Map();
|
|
797
|
+
return (specifier, fromFile) => {
|
|
798
|
+
const file = resolveImportPath(specifier, fromFile);
|
|
799
|
+
if (!file) return null;
|
|
800
|
+
let mtime = 0;
|
|
801
|
+
try {
|
|
802
|
+
mtime = statSync(file).mtimeMs;
|
|
803
|
+
} catch {
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
const cached = cache.get(file);
|
|
807
|
+
if (cached && cached.mtime === mtime) return cached.module;
|
|
808
|
+
let module = null;
|
|
809
|
+
try {
|
|
810
|
+
module = { ast: parseModule(readFileSync(file, "utf8"), file), filename: file };
|
|
811
|
+
} catch {
|
|
812
|
+
module = null;
|
|
813
|
+
}
|
|
814
|
+
cache.set(file, { mtime, module });
|
|
815
|
+
return module;
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/scan.ts
|
|
820
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", "dist", "build", "out", "coverage", ".turbo", ".vercel", ".output", ".svelte-kit", ".cache"]);
|
|
821
|
+
var SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
822
|
+
function globToRegExp(glob) {
|
|
823
|
+
let pattern = "";
|
|
824
|
+
for (let i = 0; i < glob.length; i++) {
|
|
825
|
+
const char = glob[i];
|
|
826
|
+
if (char === "*" && glob[i + 1] === "*") {
|
|
827
|
+
pattern += glob[i + 2] === "/" ? "(?:.*/)?" : ".*";
|
|
828
|
+
i += glob[i + 2] === "/" ? 2 : 1;
|
|
829
|
+
} else if (char === "*") pattern += "[^/]*";
|
|
830
|
+
else if (char === "?") pattern += "[^/]";
|
|
831
|
+
else pattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
832
|
+
}
|
|
833
|
+
return new RegExp(`^${pattern}$`);
|
|
834
|
+
}
|
|
835
|
+
function* sourceFiles(dir) {
|
|
836
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
837
|
+
if (entry.isDirectory()) {
|
|
838
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) yield* sourceFiles(join2(dir, entry.name));
|
|
839
|
+
} else if (entry.isFile() && SOURCE.test(entry.name) && !entry.name.endsWith(".d.ts")) {
|
|
840
|
+
yield join2(dir, entry.name);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function loadConfig(root, configPath) {
|
|
845
|
+
if (configPath) return JSON.parse(readFileSync2(configPath, "utf8"));
|
|
846
|
+
const file = join2(root, "server-action-audit.config.json");
|
|
847
|
+
if (existsSync2(file)) return JSON.parse(readFileSync2(file, "utf8"));
|
|
848
|
+
const pkg = join2(root, "package.json");
|
|
849
|
+
if (existsSync2(pkg)) {
|
|
850
|
+
const config = JSON.parse(readFileSync2(pkg, "utf8")).serverActionAudit;
|
|
851
|
+
if (config) return config;
|
|
852
|
+
}
|
|
853
|
+
return {};
|
|
854
|
+
}
|
|
855
|
+
function scanProject(root, options = {}) {
|
|
856
|
+
const resolved = resolveOptions(options);
|
|
857
|
+
const ignore = resolved.ignore.map(globToRegExp);
|
|
858
|
+
const resolver = createNodeResolver();
|
|
859
|
+
const report = { root, files: [], errors: [], summary: { actions: 0, protected: 0, unprotected: 0, public: 0, unvalidated: 0 } };
|
|
860
|
+
for (const path of sourceFiles(root)) {
|
|
861
|
+
const file = relative(root, path).split(sep).join("/");
|
|
862
|
+
if (ignore.some((pattern) => pattern.test(file))) continue;
|
|
863
|
+
const text = readFileSync2(path, "utf8");
|
|
864
|
+
if (!text.includes("use server")) continue;
|
|
865
|
+
let actions;
|
|
866
|
+
try {
|
|
867
|
+
actions = analyzeModule({ ast: parseModule(text, path), text, filename: path }, resolved, resolver);
|
|
868
|
+
} catch (error) {
|
|
869
|
+
report.errors.push({ file, message: error.message });
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (!actions.length) continue;
|
|
873
|
+
report.files.push({ file, actions });
|
|
874
|
+
for (const action of actions) {
|
|
875
|
+
report.summary.actions++;
|
|
876
|
+
report.summary[action.status]++;
|
|
877
|
+
if (action.validated === false) report.summary.unvalidated++;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
report.files.sort((a, b) => a.file.localeCompare(b.file));
|
|
881
|
+
return report;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/version.ts
|
|
885
|
+
var VERSION = "0.1.0";
|
|
886
|
+
|
|
887
|
+
// src/cli.ts
|
|
888
|
+
var HELP = `Usage: server-action-audit [dir] [options]
|
|
889
|
+
|
|
890
|
+
Lists every Next.js Server Action in [dir] (default: current directory) and
|
|
891
|
+
flags the ones without an authorization check.
|
|
892
|
+
|
|
893
|
+
Options
|
|
894
|
+
--fail-on <level> unprotected (default) | unvalidated | never
|
|
895
|
+
--format <format> pretty (default) | json | github | sarif
|
|
896
|
+
github: annotations on pull requests (GitHub Actions)
|
|
897
|
+
sarif: for GitHub code scanning
|
|
898
|
+
--json Same as --format json
|
|
899
|
+
--quiet Only show problems (pretty format)
|
|
900
|
+
--config <file> Options file (default: server-action-audit.config.json,
|
|
901
|
+
or "serverActionAudit" in package.json)
|
|
902
|
+
-h, --help Show this help
|
|
903
|
+
-v, --version Show the version
|
|
904
|
+
|
|
905
|
+
Exit codes: 0 ok, 1 problems found, 2 usage error.
|
|
906
|
+
`;
|
|
907
|
+
function main(argv) {
|
|
908
|
+
let dir = ".";
|
|
909
|
+
let format = "pretty";
|
|
910
|
+
let quiet = false;
|
|
911
|
+
let failOn = "unprotected";
|
|
912
|
+
let configPath;
|
|
913
|
+
for (let i = 0; i < argv.length; i++) {
|
|
914
|
+
const arg = argv[i];
|
|
915
|
+
if (arg === "-h" || arg === "--help") {
|
|
916
|
+
process.stdout.write(HELP);
|
|
917
|
+
return 0;
|
|
918
|
+
}
|
|
919
|
+
if (arg === "-v" || arg === "--version") {
|
|
920
|
+
process.stdout.write(`${VERSION}
|
|
921
|
+
`);
|
|
922
|
+
return 0;
|
|
923
|
+
}
|
|
924
|
+
if (arg === "--json") format = "json";
|
|
925
|
+
else if (arg === "--format" || arg.startsWith("--format=")) format = arg.includes("=") ? arg.split("=")[1] : argv[++i] ?? "";
|
|
926
|
+
else if (arg === "--quiet") quiet = true;
|
|
927
|
+
else if (arg === "--fail-on" || arg.startsWith("--fail-on=")) failOn = arg.includes("=") ? arg.split("=")[1] : argv[++i] ?? "";
|
|
928
|
+
else if (arg === "--config" || arg.startsWith("--config=")) configPath = arg.includes("=") ? arg.split("=")[1] : argv[++i];
|
|
929
|
+
else if (arg.startsWith("-")) {
|
|
930
|
+
process.stderr.write(`Unknown option: ${arg}
|
|
931
|
+
|
|
932
|
+
${HELP}`);
|
|
933
|
+
return 2;
|
|
934
|
+
} else dir = arg;
|
|
935
|
+
}
|
|
936
|
+
if (!["pretty", "json", "github", "sarif"].includes(format)) {
|
|
937
|
+
process.stderr.write(`--format must be pretty, json, github or sarif (got "${format}").
|
|
938
|
+
`);
|
|
939
|
+
return 2;
|
|
940
|
+
}
|
|
941
|
+
if (!["unprotected", "unvalidated", "never"].includes(failOn)) {
|
|
942
|
+
process.stderr.write(`--fail-on must be unprotected, unvalidated or never (got "${failOn}").
|
|
943
|
+
`);
|
|
944
|
+
return 2;
|
|
945
|
+
}
|
|
946
|
+
const root = resolve2(dir);
|
|
947
|
+
let report;
|
|
948
|
+
try {
|
|
949
|
+
report = scanProject(root, loadConfig(root, configPath && resolve2(configPath)));
|
|
950
|
+
} catch (error) {
|
|
951
|
+
process.stderr.write(`server-action-audit: ${error.message}
|
|
952
|
+
`);
|
|
953
|
+
return 2;
|
|
954
|
+
}
|
|
955
|
+
const output = format === "json" ? formatJson(report) : format === "github" ? formatGithub(report) : format === "sarif" ? formatSarif(report, VERSION) : formatPretty(report, { quiet });
|
|
956
|
+
process.stdout.write(`${output}
|
|
957
|
+
`);
|
|
958
|
+
const { unprotected, unvalidated } = report.summary;
|
|
959
|
+
if (failOn === "unprotected" && unprotected) return 1;
|
|
960
|
+
if (failOn === "unvalidated" && (unprotected || unvalidated)) return 1;
|
|
961
|
+
return 0;
|
|
962
|
+
}
|
|
963
|
+
process.exitCode = main(process.argv.slice(2));
|
|
964
|
+
//# sourceMappingURL=cli.js.map
|