server-action-audit 0.0.0-stage → 0.2.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 +55 -0
- package/README.md +182 -2
- package/dist/chunk-RCEEE6TI.js +677 -0
- package/dist/chunk-RCEEE6TI.js.map +1 -0
- package/dist/cli.js +1106 -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 +1045 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +153 -0
- package/dist/index.d.ts +153 -0
- package/dist/index.js +340 -0
- package/dist/index.js.map +1 -0
- package/package.json +89 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { isAbsolute, relative as relative2, resolve as resolve2, sep as sep2 } 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 formatMarkdown(report, options = {}) {
|
|
111
|
+
const { summary } = report;
|
|
112
|
+
const fileCount = report.files.length;
|
|
113
|
+
const total = `${summary.actions} Server Action${summary.actions === 1 ? "" : "s"} in ${fileCount} file${fileCount === 1 ? "" : "s"}`;
|
|
114
|
+
const lines = ["### server-action-audit", ""];
|
|
115
|
+
const found = report.files.flatMap(
|
|
116
|
+
({ file, actions }) => actions.filter((action) => action.status === "unprotected" || action.validated === false).map((action) => ({ file, action }))
|
|
117
|
+
);
|
|
118
|
+
if (!summary.actions) lines.push("No Server Actions found.");
|
|
119
|
+
else if (!found.length) lines.push(`\u2705 ${total}, no problems found.`);
|
|
120
|
+
else {
|
|
121
|
+
lines.push(
|
|
122
|
+
[
|
|
123
|
+
summary.unprotected ? `**\u274C ${summary.unprotected} unprotected**` : "0 unprotected",
|
|
124
|
+
`${summary.protected} protected`,
|
|
125
|
+
`${summary.public} public`,
|
|
126
|
+
summary.unvalidated ? `**\u26A0\uFE0F ${summary.unvalidated} with unvalidated input**` : "0 with unvalidated input"
|
|
127
|
+
].join(" \xB7 ") + ` (${total})`,
|
|
128
|
+
"",
|
|
129
|
+
"| | Action | Where | Problem |",
|
|
130
|
+
"| --- | --- | --- | --- |"
|
|
131
|
+
);
|
|
132
|
+
const cell = (text) => text.replace(/\|/g, "\\|");
|
|
133
|
+
const segment = (part) => encodeURIComponent(part).replace(/[()!'*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
134
|
+
const limit = options.limit ?? 50;
|
|
135
|
+
for (const { file, action } of found.slice(0, limit)) {
|
|
136
|
+
const where = `${file}:${action.line}`;
|
|
137
|
+
const link = options.sourceUrl ? `[${cell(where)}](${options.sourceUrl}${file.split("/").map(segment).join("/")}#L${action.line})` : `\`${cell(where)}\``;
|
|
138
|
+
const unprotected = action.status === "unprotected";
|
|
139
|
+
const problem = [unprotected && "No authorization check", action.validated === false && (unprotected ? "input not validated" : "Input not validated")].filter(Boolean).join(", ");
|
|
140
|
+
lines.push(`| ${unprotected ? "\u274C" : "\u26A0\uFE0F"} | \`${cell(action.name)}\` | ${link} | ${problem} |`);
|
|
141
|
+
}
|
|
142
|
+
if (found.length > limit) lines.push("", `\u2026and ${found.length - limit} more. Run \`npx server-action-audit\` to see them all.`);
|
|
143
|
+
if (summary.unprotected) {
|
|
144
|
+
lines.push(
|
|
145
|
+
"",
|
|
146
|
+
"Every Server Action is a public endpoint: anyone can call it with a POST request. Check the session inside each action, or mark intentional ones with `// @public-action`. See the [Next.js data security guide](https://nextjs.org/docs/app/guides/data-security)."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (report.errors.length) {
|
|
151
|
+
lines.push("", `Skipped ${report.errors.length} file${report.errors.length === 1 ? "" : "s"} that could not be parsed: ${report.errors.map((error) => `\`${error.file}\``).join(", ")}.`);
|
|
152
|
+
}
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
function formatSarif(report, version) {
|
|
156
|
+
const rules = [
|
|
157
|
+
{
|
|
158
|
+
id: "require-auth",
|
|
159
|
+
name: "RequireAuth",
|
|
160
|
+
shortDescription: { text: "Server Action without an authorization check" },
|
|
161
|
+
fullDescription: { text: "Every Next.js Server Action is a public endpoint. This one does not check who is calling it." },
|
|
162
|
+
helpUri: "https://github.com/i6moons/server-action-audit#readme",
|
|
163
|
+
defaultConfiguration: { level: "error" },
|
|
164
|
+
properties: { tags: ["security", "nextjs"], "security-severity": "7.5", precision: "medium" }
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "require-input-validation",
|
|
168
|
+
name: "RequireInputValidation",
|
|
169
|
+
shortDescription: { text: "Server Action without input validation" },
|
|
170
|
+
fullDescription: { text: "The action uses arguments sent by the client without parsing them with a schema." },
|
|
171
|
+
helpUri: "https://github.com/i6moons/server-action-audit#readme",
|
|
172
|
+
defaultConfiguration: { level: "warning" },
|
|
173
|
+
properties: { tags: ["security", "nextjs"], "security-severity": "4.0", precision: "medium" }
|
|
174
|
+
}
|
|
175
|
+
];
|
|
176
|
+
const results = findings(report).map(({ rule, level, file, action, message }) => ({
|
|
177
|
+
ruleId: rule,
|
|
178
|
+
ruleIndex: rule === "require-auth" ? 0 : 1,
|
|
179
|
+
level,
|
|
180
|
+
message: { text: message },
|
|
181
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: file }, region: { startLine: action.line, startColumn: action.column } } }],
|
|
182
|
+
partialFingerprints: { actionName: `${file}#${action.name}` }
|
|
183
|
+
}));
|
|
184
|
+
return JSON.stringify(
|
|
185
|
+
{
|
|
186
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
187
|
+
version: "2.1.0",
|
|
188
|
+
runs: [{ tool: { driver: { name: "server-action-audit", version, informationUri: "https://github.com/i6moons/server-action-audit", rules } }, results }]
|
|
189
|
+
},
|
|
190
|
+
null,
|
|
191
|
+
2
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/github.ts
|
|
196
|
+
import { readFileSync } from "fs";
|
|
197
|
+
|
|
198
|
+
// src/version.ts
|
|
199
|
+
var VERSION = "0.2.0";
|
|
200
|
+
|
|
201
|
+
// src/github.ts
|
|
202
|
+
var COMMENT_MARKER = "<!-- server-action-audit -->";
|
|
203
|
+
function pullRequestFromEnv(env = process.env) {
|
|
204
|
+
if (!env.GITHUB_REPOSITORY || !env.GITHUB_EVENT_PATH) return "not running in GitHub Actions";
|
|
205
|
+
if (!env.GITHUB_TOKEN) return "GITHUB_TOKEN is not set";
|
|
206
|
+
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8"));
|
|
207
|
+
if (!event.pull_request) return `the "${env.GITHUB_EVENT_NAME ?? "unknown"}" event is not about a pull request`;
|
|
208
|
+
return {
|
|
209
|
+
apiUrl: env.GITHUB_API_URL || "https://api.github.com",
|
|
210
|
+
serverUrl: env.GITHUB_SERVER_URL || "https://github.com",
|
|
211
|
+
repository: env.GITHUB_REPOSITORY,
|
|
212
|
+
number: event.pull_request.number,
|
|
213
|
+
headSha: event.pull_request.head?.sha,
|
|
214
|
+
token: env.GITHUB_TOKEN
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
async function upsertComment(pr, body, options) {
|
|
218
|
+
const request = async (method, path, payload) => {
|
|
219
|
+
const response = await (options.fetch ?? fetch)(`${pr.apiUrl}${path}`, {
|
|
220
|
+
method,
|
|
221
|
+
headers: {
|
|
222
|
+
accept: "application/vnd.github+json",
|
|
223
|
+
authorization: `Bearer ${pr.token}`,
|
|
224
|
+
"user-agent": `server-action-audit/${VERSION}`,
|
|
225
|
+
"x-github-api-version": "2022-11-28",
|
|
226
|
+
...payload ? { "content-type": "application/json" } : {}
|
|
227
|
+
},
|
|
228
|
+
body: payload ? JSON.stringify(payload) : void 0
|
|
229
|
+
});
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
const message = await response.json().then(
|
|
232
|
+
(error) => error.message,
|
|
233
|
+
() => void 0
|
|
234
|
+
);
|
|
235
|
+
throw Object.assign(new Error(`${method} ${path} answered ${response.status}${message ? ` ${message}` : ""}`), { status: response.status });
|
|
236
|
+
}
|
|
237
|
+
return await response.json();
|
|
238
|
+
};
|
|
239
|
+
let previous;
|
|
240
|
+
for (let page = 1; !previous; page++) {
|
|
241
|
+
const comments = await request("GET", `/repos/${pr.repository}/issues/${pr.number}/comments?per_page=100&page=${page}`);
|
|
242
|
+
previous = comments.find((comment) => comment.body?.startsWith(COMMENT_MARKER));
|
|
243
|
+
if (comments.length < 100) break;
|
|
244
|
+
}
|
|
245
|
+
const text = `${COMMENT_MARKER}
|
|
246
|
+
${body}`;
|
|
247
|
+
if (previous) {
|
|
248
|
+
if (previous.body === text) return "skipped";
|
|
249
|
+
await request("PATCH", `/repos/${pr.repository}/issues/comments/${previous.id}`, { body: text });
|
|
250
|
+
return "updated";
|
|
251
|
+
}
|
|
252
|
+
if (options.clean) return "skipped";
|
|
253
|
+
await request("POST", `/repos/${pr.repository}/issues/${pr.number}/comments`, { body: text });
|
|
254
|
+
return "created";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/scan.ts
|
|
258
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync } from "fs";
|
|
259
|
+
import { join as join2, relative, sep } from "path";
|
|
260
|
+
|
|
261
|
+
// src/ast.ts
|
|
262
|
+
var SKIP = /* @__PURE__ */ new Set(["parent", "loc", "range", "tokens", "comments", "typeAnnotation", "returnType", "typeParameters"]);
|
|
263
|
+
function walk(root, visit) {
|
|
264
|
+
if (!root || typeof root.type !== "string") return;
|
|
265
|
+
if (visit(root) === false) return;
|
|
266
|
+
for (const key of Object.keys(root)) {
|
|
267
|
+
if (SKIP.has(key)) continue;
|
|
268
|
+
const value = root[key];
|
|
269
|
+
if (Array.isArray(value)) {
|
|
270
|
+
for (const child of value) if (child && typeof child.type === "string") walk(child, visit);
|
|
271
|
+
} else if (value && typeof value.type === "string") {
|
|
272
|
+
walk(value, visit);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function isFunction(node) {
|
|
277
|
+
return !!node && (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression");
|
|
278
|
+
}
|
|
279
|
+
function directives(body) {
|
|
280
|
+
const found = [];
|
|
281
|
+
for (const statement of body ?? []) {
|
|
282
|
+
if (statement.type !== "ExpressionStatement") break;
|
|
283
|
+
if (typeof statement.directive === "string") found.push(statement.directive);
|
|
284
|
+
else if (statement.expression?.type === "Literal" && typeof statement.expression.value === "string") found.push(statement.expression.value);
|
|
285
|
+
else break;
|
|
286
|
+
}
|
|
287
|
+
return found;
|
|
288
|
+
}
|
|
289
|
+
function functionDirectives(fn) {
|
|
290
|
+
return fn.body?.type === "BlockStatement" ? directives(fn.body.body) : [];
|
|
291
|
+
}
|
|
292
|
+
function unwrap(node) {
|
|
293
|
+
let current = node;
|
|
294
|
+
while (current.type === "ChainExpression" || current.type === "TSNonNullExpression" || current.type === "TSAsExpression" || current.type === "ParenthesizedExpression") {
|
|
295
|
+
current = current.expression;
|
|
296
|
+
}
|
|
297
|
+
return current;
|
|
298
|
+
}
|
|
299
|
+
function calleePath(callee) {
|
|
300
|
+
const node = unwrap(callee);
|
|
301
|
+
if (node.type === "Identifier") return node.name;
|
|
302
|
+
if (node.type === "MemberExpression") {
|
|
303
|
+
const property = !node.computed && node.property?.type === "Identifier" ? node.property.name : "*";
|
|
304
|
+
return `${calleePath(node.object)}.${property}`;
|
|
305
|
+
}
|
|
306
|
+
if (node.type === "Super") return "super";
|
|
307
|
+
if (node.type === "ThisExpression") return "this";
|
|
308
|
+
return "*";
|
|
309
|
+
}
|
|
310
|
+
function lastSegment(path) {
|
|
311
|
+
const index = path.lastIndexOf(".");
|
|
312
|
+
return index === -1 ? path : path.slice(index + 1);
|
|
313
|
+
}
|
|
314
|
+
function callsIn(root) {
|
|
315
|
+
const calls = [];
|
|
316
|
+
walk(root, (node) => {
|
|
317
|
+
if (node.type === "CallExpression") calls.push(node);
|
|
318
|
+
});
|
|
319
|
+
return calls;
|
|
320
|
+
}
|
|
321
|
+
function unconditionalCalls(fn, followReturned = false) {
|
|
322
|
+
const calls = [];
|
|
323
|
+
const expression = (node) => {
|
|
324
|
+
if (!node || typeof node.type !== "string" || isFunction(node)) return;
|
|
325
|
+
if (node.type === "CallExpression") calls.push(node);
|
|
326
|
+
for (const key of Object.keys(node)) {
|
|
327
|
+
if (SKIP.has(key)) continue;
|
|
328
|
+
const value = node[key];
|
|
329
|
+
if (Array.isArray(value)) value.forEach((child) => child && expression(child));
|
|
330
|
+
else if (value && typeof value.type === "string") expression(value);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const returned = (node) => {
|
|
334
|
+
if (followReturned && node && isFunction(node)) calls.push(...unconditionalCalls(node));
|
|
335
|
+
else expression(node);
|
|
336
|
+
};
|
|
337
|
+
const statement = (node) => {
|
|
338
|
+
if (!node) return;
|
|
339
|
+
switch (node.type) {
|
|
340
|
+
case "BlockStatement":
|
|
341
|
+
for (const child of node.body ?? []) statement(child);
|
|
342
|
+
break;
|
|
343
|
+
case "ExpressionStatement":
|
|
344
|
+
expression(node.expression);
|
|
345
|
+
break;
|
|
346
|
+
case "VariableDeclaration":
|
|
347
|
+
for (const declarator of node.declarations ?? []) expression(declarator.init);
|
|
348
|
+
break;
|
|
349
|
+
case "ReturnStatement":
|
|
350
|
+
returned(node.argument);
|
|
351
|
+
break;
|
|
352
|
+
case "ThrowStatement":
|
|
353
|
+
expression(node.argument);
|
|
354
|
+
break;
|
|
355
|
+
case "IfStatement":
|
|
356
|
+
expression(node.test);
|
|
357
|
+
break;
|
|
358
|
+
case "TryStatement":
|
|
359
|
+
statement(node.block);
|
|
360
|
+
break;
|
|
361
|
+
case "SwitchStatement":
|
|
362
|
+
expression(node.discriminant);
|
|
363
|
+
break;
|
|
364
|
+
case "DoWhileStatement":
|
|
365
|
+
statement(node.body);
|
|
366
|
+
break;
|
|
367
|
+
case "LabeledStatement":
|
|
368
|
+
statement(node.body);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
if (fn.body?.type === "BlockStatement") statement(fn.body);
|
|
373
|
+
else returned(fn.body);
|
|
374
|
+
return calls;
|
|
375
|
+
}
|
|
376
|
+
function callChain(call) {
|
|
377
|
+
const methods = [];
|
|
378
|
+
const functions = [];
|
|
379
|
+
const identifiers = [];
|
|
380
|
+
let current = unwrap(call);
|
|
381
|
+
for (; ; ) {
|
|
382
|
+
if (current.type === "CallExpression") {
|
|
383
|
+
for (const argument of current.arguments ?? []) {
|
|
384
|
+
if (isFunction(argument)) functions.unshift(argument);
|
|
385
|
+
else if (argument.type === "Identifier") identifiers.unshift(argument.name);
|
|
386
|
+
}
|
|
387
|
+
const callee = unwrap(current.callee);
|
|
388
|
+
if (callee.type === "MemberExpression") {
|
|
389
|
+
methods.unshift(!callee.computed && callee.property?.type === "Identifier" ? callee.property.name : "*");
|
|
390
|
+
current = unwrap(callee.object);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (callee.type === "Identifier") return { root: callee.name, methods, functions, identifiers };
|
|
394
|
+
return { root: null, methods, functions, identifiers };
|
|
395
|
+
}
|
|
396
|
+
if (current.type === "MemberExpression") {
|
|
397
|
+
methods.unshift(!current.computed && current.property?.type === "Identifier" ? current.property.name : "*");
|
|
398
|
+
current = unwrap(current.object);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (current.type === "AwaitExpression") {
|
|
402
|
+
current = unwrap(current.argument);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
return { root: current.type === "Identifier" ? current.name : null, methods, functions, identifiers };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function position(node) {
|
|
409
|
+
return node.loc ? { line: node.loc.start.line, column: node.loc.start.column + 1 } : { line: 0, column: 0 };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/options.ts
|
|
413
|
+
var EXACT_PATHS = ["auth"];
|
|
414
|
+
var NAMES = [
|
|
415
|
+
"currentUser",
|
|
416
|
+
// Clerk
|
|
417
|
+
"getServerSession",
|
|
418
|
+
// NextAuth v4
|
|
419
|
+
"getKindeServerSession",
|
|
420
|
+
// Kinde
|
|
421
|
+
"validateRequest",
|
|
422
|
+
// Lucia
|
|
423
|
+
"getIronSession",
|
|
424
|
+
// iron-session
|
|
425
|
+
"withAuth",
|
|
426
|
+
// WorkOS AuthKit
|
|
427
|
+
"verifyIdToken",
|
|
428
|
+
// Firebase Admin
|
|
429
|
+
"verifySessionCookie",
|
|
430
|
+
// Firebase Admin
|
|
431
|
+
"jwtVerify",
|
|
432
|
+
// jose
|
|
433
|
+
"verifyToken",
|
|
434
|
+
"verifyJwt",
|
|
435
|
+
"verifyAccessToken",
|
|
436
|
+
"getSession",
|
|
437
|
+
"verifySession",
|
|
438
|
+
"getCurrentUser",
|
|
439
|
+
"getAuthUser",
|
|
440
|
+
"getAuthenticatedUser",
|
|
441
|
+
"getSessionUser",
|
|
442
|
+
"getUserOrThrow",
|
|
443
|
+
"requireAuth",
|
|
444
|
+
"requireUser",
|
|
445
|
+
"requireSession",
|
|
446
|
+
"requireAdmin",
|
|
447
|
+
"requireRole",
|
|
448
|
+
"requirePermission",
|
|
449
|
+
"checkAuth",
|
|
450
|
+
"ensureAuth",
|
|
451
|
+
"ensureUser",
|
|
452
|
+
"assertAuth",
|
|
453
|
+
"assertUser",
|
|
454
|
+
"isAuthenticated",
|
|
455
|
+
"authenticate",
|
|
456
|
+
"authorize"
|
|
457
|
+
];
|
|
458
|
+
var SUFFIXES = [
|
|
459
|
+
"auth.getUser",
|
|
460
|
+
// Supabase
|
|
461
|
+
"auth.getClaims",
|
|
462
|
+
// Supabase
|
|
463
|
+
"auth.getSession",
|
|
464
|
+
// Supabase
|
|
465
|
+
"auth.protect",
|
|
466
|
+
// Clerk
|
|
467
|
+
"api.getSession"
|
|
468
|
+
// Better Auth
|
|
469
|
+
];
|
|
470
|
+
var AUTH_NAME = /auth|protect|guard|session|user|admin|private|secure|logged/i;
|
|
471
|
+
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;
|
|
472
|
+
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;
|
|
473
|
+
function resolveOptions(options = {}) {
|
|
474
|
+
const extra = options.authFunctions ?? [];
|
|
475
|
+
return {
|
|
476
|
+
exactPaths: new Set(EXACT_PATHS),
|
|
477
|
+
names: /* @__PURE__ */ new Set([...NAMES, ...extra.filter((name) => !name.includes("."))]),
|
|
478
|
+
suffixes: [...SUFFIXES, ...extra.filter((name) => name.includes("."))],
|
|
479
|
+
authWrappers: new Set(options.authWrappers ?? []),
|
|
480
|
+
authClients: new Set(options.authClients ?? []),
|
|
481
|
+
publicActions: new Set(options.publicActions ?? []),
|
|
482
|
+
ignore: options.ignore ?? []
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function isAuthCall(path, options) {
|
|
486
|
+
if (options.exactPaths.has(path)) return true;
|
|
487
|
+
if (options.names.has(lastSegment(path))) return true;
|
|
488
|
+
return options.suffixes.some((suffix) => path === suffix || path.endsWith(`.${suffix}`));
|
|
489
|
+
}
|
|
490
|
+
var VALIDATION_METHODS = /* @__PURE__ */ new Set(["parse", "safeParse", "parseAsync", "safeParseAsync", "validate", "validateSync", "validateAsync", "assert"]);
|
|
491
|
+
var NOT_VALIDATION = /* @__PURE__ */ new Set(["JSON", "Date", "Number", "URL", "path", "Math", "BigInt", "console", "assert"]);
|
|
492
|
+
function isValidationCall(path) {
|
|
493
|
+
const method = lastSegment(path);
|
|
494
|
+
if (!VALIDATION_METHODS.has(method)) return false;
|
|
495
|
+
const owner = path.includes(".") ? path.slice(0, path.lastIndexOf(".")) : "";
|
|
496
|
+
return !NOT_VALIDATION.has(lastSegment(owner) || owner);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/analyze.ts
|
|
500
|
+
var PUBLIC_MARKER = /@public-action\b/;
|
|
501
|
+
var CLIENT_METHODS = /* @__PURE__ */ new Set(["action", "stateAction", "handler", "createServerAction", "createAction"]);
|
|
502
|
+
var SCHEMA_METHODS = /* @__PURE__ */ new Set(["schema", "inputSchema", "input", "bindArgsSchemas"]);
|
|
503
|
+
var MAX_DEPTH = 4;
|
|
504
|
+
var ModuleScope = class {
|
|
505
|
+
constructor(ast, filename) {
|
|
506
|
+
this.ast = ast;
|
|
507
|
+
this.filename = filename;
|
|
508
|
+
for (const statement of ast.body ?? []) this.index(statement);
|
|
509
|
+
}
|
|
510
|
+
ast;
|
|
511
|
+
filename;
|
|
512
|
+
functions = /* @__PURE__ */ new Map();
|
|
513
|
+
imports = /* @__PURE__ */ new Map();
|
|
514
|
+
exports = /* @__PURE__ */ new Map();
|
|
515
|
+
/** Top-level declarations by name, with the statement that holds them (for comments). */
|
|
516
|
+
declarations = /* @__PURE__ */ new Map();
|
|
517
|
+
declare(name, init, statement) {
|
|
518
|
+
this.declarations.set(name, { init, statement });
|
|
519
|
+
if (isFunction(init)) this.functions.set(name, init);
|
|
520
|
+
else if (init.type === "CallExpression") {
|
|
521
|
+
const wrapped = (init.arguments ?? []).find((argument) => isFunction(argument));
|
|
522
|
+
if (wrapped) this.functions.set(name, wrapped);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
index(statement) {
|
|
526
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
527
|
+
if (statement.type === "ImportDeclaration" && statement.importKind !== "type") {
|
|
528
|
+
for (const specifier of statement.specifiers ?? []) {
|
|
529
|
+
const imported = specifier.type === "ImportDefaultSpecifier" ? "default" : specifier.type === "ImportNamespaceSpecifier" ? "*" : specifier.imported?.name ?? specifier.imported?.value;
|
|
530
|
+
this.imports.set(specifier.local.name, { source: statement.source.value, imported });
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (declaration?.type === "FunctionDeclaration" && declaration.id) {
|
|
535
|
+
this.declare(declaration.id.name, declaration, statement);
|
|
536
|
+
if (statement.type === "ExportNamedDeclaration") this.exports.set(declaration.id.name, declaration.id.name);
|
|
537
|
+
}
|
|
538
|
+
if (declaration?.type === "VariableDeclaration") {
|
|
539
|
+
for (const declarator of declaration.declarations ?? []) {
|
|
540
|
+
if (declarator.id?.type !== "Identifier" || !declarator.init) continue;
|
|
541
|
+
this.declare(declarator.id.name, declarator.init, statement);
|
|
542
|
+
if (statement.type === "ExportNamedDeclaration") this.exports.set(declarator.id.name, declarator.id.name);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (statement.type === "ExportNamedDeclaration" && !statement.declaration) {
|
|
546
|
+
for (const specifier of statement.specifiers ?? []) {
|
|
547
|
+
const exported = specifier.exported?.name ?? specifier.exported?.value;
|
|
548
|
+
const local = specifier.local?.name ?? specifier.local?.value;
|
|
549
|
+
this.exports.set(exported, statement.source ? { source: statement.source.value, imported: local } : local);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (statement.type === "ExportDefaultDeclaration") {
|
|
553
|
+
const value = statement.declaration;
|
|
554
|
+
if (value?.type === "Identifier") this.exports.set("default", value.name);
|
|
555
|
+
else if (value) {
|
|
556
|
+
this.declare("default", value, statement);
|
|
557
|
+
this.exports.set("default", "default");
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
function scopeOf(module, context) {
|
|
563
|
+
let scope = context.scopes.get(module.filename);
|
|
564
|
+
if (!scope) {
|
|
565
|
+
scope = new ModuleScope(module.ast, module.filename);
|
|
566
|
+
context.scopes.set(module.filename, scope);
|
|
567
|
+
}
|
|
568
|
+
return scope;
|
|
569
|
+
}
|
|
570
|
+
function resolveFunction(name, scope, context, depth) {
|
|
571
|
+
const local = scope.functions.get(name);
|
|
572
|
+
if (local) return { fn: local, scope };
|
|
573
|
+
const binding = scope.imports.get(name);
|
|
574
|
+
if (!binding || !context.resolver || depth > MAX_DEPTH) return null;
|
|
575
|
+
return exportedFunction(binding, scope, context, depth + 1);
|
|
576
|
+
}
|
|
577
|
+
function exportedFunction(binding, from, context, depth) {
|
|
578
|
+
if (depth > MAX_DEPTH || binding.imported === "*") return null;
|
|
579
|
+
const module = context.resolver?.(binding.source, from.filename);
|
|
580
|
+
if (!module) return null;
|
|
581
|
+
const scope = scopeOf(module, context);
|
|
582
|
+
const entry = scope.exports.get(binding.imported);
|
|
583
|
+
if (entry === void 0) return null;
|
|
584
|
+
if (typeof entry === "string") {
|
|
585
|
+
const fn = scope.functions.get(entry);
|
|
586
|
+
return fn ? { fn, scope } : resolveFunction(entry, scope, context, depth);
|
|
587
|
+
}
|
|
588
|
+
return exportedFunction(entry, scope, context, depth + 1);
|
|
589
|
+
}
|
|
590
|
+
function authEvidence(fn, scope, context, depth = 0, seen = /* @__PURE__ */ new Set(), followReturned = false) {
|
|
591
|
+
if (depth > MAX_DEPTH || seen.has(fn)) return null;
|
|
592
|
+
seen.add(fn);
|
|
593
|
+
const calls = unconditionalCalls(fn, followReturned);
|
|
594
|
+
for (const call of calls) {
|
|
595
|
+
const path = calleePath(call.callee);
|
|
596
|
+
if (isAuthCall(path, context.options)) return `${path}()`;
|
|
597
|
+
}
|
|
598
|
+
for (const call of calls) {
|
|
599
|
+
const path = calleePath(call.callee);
|
|
600
|
+
if (path.includes(".") || path === "*" || GUEST_GUARD.test(path) || !HELPER_NAME.test(path)) continue;
|
|
601
|
+
const helper = resolveFunction(path, scope, context, depth);
|
|
602
|
+
if (!helper) continue;
|
|
603
|
+
const evidence = authEvidence(helper.fn, helper.scope, context, depth + 1, seen);
|
|
604
|
+
if (evidence) return `${path}() \u2192 ${evidence}`;
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
function boundNames(pattern, names = []) {
|
|
609
|
+
if (!pattern) return names;
|
|
610
|
+
if (pattern.type === "Identifier") names.push(pattern.name);
|
|
611
|
+
else if (pattern.type === "ObjectPattern") for (const property of pattern.properties ?? []) boundNames(property.type === "RestElement" ? property.argument : property.value, names);
|
|
612
|
+
else if (pattern.type === "ArrayPattern") for (const element of pattern.elements ?? []) boundNames(element, names);
|
|
613
|
+
else if (pattern.type === "AssignmentPattern") boundNames(pattern.left, names);
|
|
614
|
+
else if (pattern.type === "RestElement") boundNames(pattern.argument, names);
|
|
615
|
+
return names;
|
|
616
|
+
}
|
|
617
|
+
function closureEvidence(action, enclosing, scope, context) {
|
|
618
|
+
if (enclosing.body?.type !== "BlockStatement") return null;
|
|
619
|
+
const fromAuth = /* @__PURE__ */ new Map();
|
|
620
|
+
for (const statement of enclosing.body.body ?? []) {
|
|
621
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
622
|
+
for (const declarator of statement.declarations ?? []) {
|
|
623
|
+
const evidence = authEvidence({ type: "ArrowFunctionExpression", params: [], body: declarator.init }, scope, context);
|
|
624
|
+
if (evidence) for (const name of boundNames(declarator.id)) fromAuth.set(name, evidence);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (!fromAuth.size) return null;
|
|
628
|
+
let found = null;
|
|
629
|
+
walk(action.body, (node) => {
|
|
630
|
+
if (found) return false;
|
|
631
|
+
if (node.type === "Identifier" && fromAuth.has(node.name)) found = `${node.name} from ${fromAuth.get(node.name)} in the enclosing component`;
|
|
632
|
+
});
|
|
633
|
+
return found;
|
|
634
|
+
}
|
|
635
|
+
function declarationFrom(binding, from, context, depth) {
|
|
636
|
+
if (depth > MAX_DEPTH || binding.imported === "*" || !context.resolver) return null;
|
|
637
|
+
const module = context.resolver(binding.source, from.filename);
|
|
638
|
+
if (!module) return null;
|
|
639
|
+
const scope = scopeOf(module, context);
|
|
640
|
+
const entry = scope.exports.get(binding.imported);
|
|
641
|
+
if (typeof entry === "string") return resolveDeclaration(entry, scope, context, depth + 1);
|
|
642
|
+
return entry ? declarationFrom(entry, scope, context, depth + 1) : null;
|
|
643
|
+
}
|
|
644
|
+
function resolveDeclaration(name, scope, context, depth) {
|
|
645
|
+
const local = scope.declarations.get(name);
|
|
646
|
+
if (local) return { init: local.init, scope };
|
|
647
|
+
const binding = scope.imports.get(name);
|
|
648
|
+
return binding ? declarationFrom(binding, scope, context, depth) : null;
|
|
649
|
+
}
|
|
650
|
+
function definitionEvidence(name, scope, context, depth = 0) {
|
|
651
|
+
if (depth > MAX_DEPTH || GUEST_GUARD.test(name)) return null;
|
|
652
|
+
if (depth > 0 && (context.options.authClients.has(name) || context.options.authWrappers.has(name) || AUTH_NAME.test(name))) return name;
|
|
653
|
+
const definition = resolveFunction(name, scope, context, 0);
|
|
654
|
+
const evidence = definition && authEvidence(definition.fn, definition.scope, context, 1, /* @__PURE__ */ new Set(), true);
|
|
655
|
+
if (evidence) return `${name} \u2192 ${evidence}`;
|
|
656
|
+
const declaration = resolveDeclaration(name, scope, context, 0);
|
|
657
|
+
if (declaration?.init.type === "CallExpression") {
|
|
658
|
+
const chain = callChain(declaration.init);
|
|
659
|
+
for (const base of [chain.root, ...chain.identifiers]) {
|
|
660
|
+
if (!base || base === name) continue;
|
|
661
|
+
const inherited = definitionEvidence(base, declaration.scope, context, depth + 1);
|
|
662
|
+
if (inherited) return `${name} \u2192 ${inherited}`;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
function validatesDirectly(fn) {
|
|
668
|
+
return callsIn(fn.body).some((call) => isValidationCall(calleePath(call.callee)));
|
|
669
|
+
}
|
|
670
|
+
function parameterCount(fn) {
|
|
671
|
+
return (fn.params ?? []).length;
|
|
672
|
+
}
|
|
673
|
+
function leadingComments(node, comments, text) {
|
|
674
|
+
const start = node.range?.[0];
|
|
675
|
+
if (start === void 0) return [];
|
|
676
|
+
const found = [];
|
|
677
|
+
let cursor = start;
|
|
678
|
+
for (const comment of [...comments].reverse()) {
|
|
679
|
+
const [from, to] = comment.range ?? [Infinity, Infinity];
|
|
680
|
+
if (to > cursor) continue;
|
|
681
|
+
if (text.slice(to, cursor).trim() !== "") break;
|
|
682
|
+
found.push(comment);
|
|
683
|
+
cursor = from;
|
|
684
|
+
}
|
|
685
|
+
return found;
|
|
686
|
+
}
|
|
687
|
+
function bodyHeadComments(fn, comments) {
|
|
688
|
+
if (fn.body?.type !== "BlockStatement" || !fn.body.range) return [];
|
|
689
|
+
const [bodyStart, bodyEnd] = fn.body.range;
|
|
690
|
+
const statements = fn.body.body ?? [];
|
|
691
|
+
const firstReal = statements.find((statement) => !(statement.type === "ExpressionStatement" && typeof statement.directive === "string"));
|
|
692
|
+
const limit = firstReal?.range?.[0] ?? bodyEnd;
|
|
693
|
+
return comments.filter((comment) => comment.range && comment.range[0] > bodyStart && comment.range[1] <= limit);
|
|
694
|
+
}
|
|
695
|
+
function moduleCandidates(scope) {
|
|
696
|
+
const candidates = [];
|
|
697
|
+
for (const [exported, entry] of scope.exports) {
|
|
698
|
+
if (typeof entry !== "string") continue;
|
|
699
|
+
const declaration = scope.declarations.get(entry);
|
|
700
|
+
if (!declaration) continue;
|
|
701
|
+
const { init, statement } = declaration;
|
|
702
|
+
const target = init.type === "AwaitExpression" ? init.argument : init;
|
|
703
|
+
if (!isFunction(target) && target.type !== "CallExpression") continue;
|
|
704
|
+
const name = exported === "default" && target.id?.type === "Identifier" ? target.id.name : exported;
|
|
705
|
+
candidates.push({ name, kind: "module", target, anchors: [statement, init] });
|
|
706
|
+
}
|
|
707
|
+
return candidates;
|
|
708
|
+
}
|
|
709
|
+
function inlineCandidates(ast, skip) {
|
|
710
|
+
const candidates = [];
|
|
711
|
+
const visit = (node, parent, grandparent, enclosing) => {
|
|
712
|
+
if (isFunction(node) && !skip.has(node) && functionDirectives(node).includes("use server")) {
|
|
713
|
+
let name = node.id?.name;
|
|
714
|
+
if (!name && parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") name = parent.id.name;
|
|
715
|
+
if (!name && (parent?.type === "Property" || parent?.type === "MethodDefinition") && parent.key?.type === "Identifier") name = parent.key.name;
|
|
716
|
+
if (!name && parent?.type === "JSXExpressionContainer" && grandparent?.type === "JSXAttribute") name = `(inline ${grandparent.name?.name ?? "action"})`;
|
|
717
|
+
candidates.push({ name: name ?? "(inline)", kind: "inline", target: node, anchors: [parent?.type === "VariableDeclarator" ? grandparent ?? node : node], enclosing });
|
|
718
|
+
}
|
|
719
|
+
const inner = isFunction(node) ? node : enclosing;
|
|
720
|
+
for (const key of Object.keys(node)) {
|
|
721
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
722
|
+
const value = node[key];
|
|
723
|
+
if (Array.isArray(value)) {
|
|
724
|
+
for (const child of value) if (child && typeof child.type === "string") visit(child, node, parent, inner);
|
|
725
|
+
} else if (value && typeof value.type === "string") {
|
|
726
|
+
visit(value, node, parent, inner);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
visit(ast, null, null, null);
|
|
731
|
+
return candidates;
|
|
732
|
+
}
|
|
733
|
+
function analyzeCandidate(candidate, scope, context, comments, text) {
|
|
734
|
+
const { target } = candidate;
|
|
735
|
+
let reason = null;
|
|
736
|
+
let validated = null;
|
|
737
|
+
let hint = null;
|
|
738
|
+
const handlers = [];
|
|
739
|
+
if (isFunction(target)) {
|
|
740
|
+
handlers.push(target);
|
|
741
|
+
reason = authEvidence(target, scope, context);
|
|
742
|
+
if (!reason && candidate.enclosing) reason = closureEvidence(target, candidate.enclosing, scope, context);
|
|
743
|
+
validated = parameterCount(target) === 0 ? null : validatesDirectly(target);
|
|
744
|
+
} else {
|
|
745
|
+
const chain = callChain(target);
|
|
746
|
+
handlers.push(...chain.functions);
|
|
747
|
+
const isClient = chain.methods.some((method) => CLIENT_METHODS.has(method));
|
|
748
|
+
const root = chain.root;
|
|
749
|
+
const trusted = root !== null && (context.options.authClients.has(root) || context.options.authWrappers.has(root) || AUTH_NAME.test(root) && !GUEST_GUARD.test(root));
|
|
750
|
+
if (trusted) reason = isClient ? `${root} client` : `${root}() wrapper`;
|
|
751
|
+
for (const handler of chain.functions) reason ??= authEvidence(handler, scope, context);
|
|
752
|
+
for (const name of [root, ...chain.identifiers]) {
|
|
753
|
+
if (!reason && name) reason = definitionEvidence(name, scope, context);
|
|
754
|
+
}
|
|
755
|
+
if (isClient) {
|
|
756
|
+
validated = chain.methods.some((method) => SCHEMA_METHODS.has(method)) ? true : null;
|
|
757
|
+
} else if (root && /valid|schema|zod|parse/i.test(root)) {
|
|
758
|
+
validated = true;
|
|
759
|
+
} else {
|
|
760
|
+
const handler = chain.functions[0];
|
|
761
|
+
validated = !handler || parameterCount(handler) === 0 ? null : validatesDirectly(handler);
|
|
762
|
+
}
|
|
763
|
+
if (!reason && root) {
|
|
764
|
+
hint = isClient ? `If "${root}" has auth middleware, add it to "authClients".` : `If ${root}() checks the session, add it to "authWrappers".`;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const markerComments = [
|
|
768
|
+
...candidate.anchors.flatMap((anchor) => leadingComments(anchor, comments, text)),
|
|
769
|
+
...handlers.flatMap((handler) => bodyHeadComments(handler, comments))
|
|
770
|
+
];
|
|
771
|
+
const markedPublic = markerComments.some((comment) => PUBLIC_MARKER.test(comment.value));
|
|
772
|
+
const listedPublic = context.options.publicActions.has(candidate.name);
|
|
773
|
+
const status = reason ? "protected" : markedPublic || listedPublic ? "public" : "unprotected";
|
|
774
|
+
if (status === "public") {
|
|
775
|
+
reason = markedPublic ? "@public-action" : "publicActions";
|
|
776
|
+
hint = null;
|
|
777
|
+
}
|
|
778
|
+
const { line, column } = position(candidate.anchors[0] ?? target);
|
|
779
|
+
return { name: candidate.name, kind: candidate.kind, line, column, status, reason, validated, hint, node: candidate.anchors[0] ?? target };
|
|
780
|
+
}
|
|
781
|
+
function analyzeModule(input, options, resolver) {
|
|
782
|
+
if (!input.text.includes("use server")) return [];
|
|
783
|
+
const context = { options, resolver, scopes: /* @__PURE__ */ new Map() };
|
|
784
|
+
const scope = scopeOf({ ast: input.ast, filename: input.filename }, context);
|
|
785
|
+
const comments = input.comments ?? input.ast.comments ?? [];
|
|
786
|
+
const candidates = [];
|
|
787
|
+
if (directives(input.ast.body).includes("use server")) candidates.push(...moduleCandidates(scope));
|
|
788
|
+
const covered = new Set(candidates.map((candidate) => candidate.target));
|
|
789
|
+
candidates.push(...inlineCandidates(input.ast, covered));
|
|
790
|
+
return candidates.map((candidate) => analyzeCandidate(candidate, scope, context, comments, input.text)).sort((a, b) => a.line - b.line || a.column - b.column);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/resolve.ts
|
|
794
|
+
import { existsSync, readFileSync as readFileSync2, statSync } from "fs";
|
|
795
|
+
import { dirname, join, resolve } from "path";
|
|
796
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
797
|
+
var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
|
|
798
|
+
function parseModule(text, filename) {
|
|
799
|
+
return parse(text, {
|
|
800
|
+
jsx: !/\.[cm]?ts$/.test(filename),
|
|
801
|
+
loc: true,
|
|
802
|
+
range: true,
|
|
803
|
+
comment: true
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
function isFile(path) {
|
|
807
|
+
try {
|
|
808
|
+
return statSync(path).isFile();
|
|
809
|
+
} catch {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
var TS_SOURCES = { ".js": [".ts", ".tsx"], ".jsx": [".tsx"], ".mjs": [".mts"], ".cjs": [".cts"] };
|
|
814
|
+
function tryFile(base) {
|
|
815
|
+
if (isFile(base)) return base;
|
|
816
|
+
const jsExtension = base.match(/\.(js|jsx|mjs|cjs)$/)?.[0];
|
|
817
|
+
if (jsExtension) {
|
|
818
|
+
for (const extension of TS_SOURCES[jsExtension] ?? []) {
|
|
819
|
+
const candidate = base.slice(0, -jsExtension.length) + extension;
|
|
820
|
+
if (isFile(candidate)) return candidate;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
for (const extension of EXTENSIONS) if (isFile(base + extension)) return base + extension;
|
|
824
|
+
for (const extension of EXTENSIONS) if (isFile(join(base, `index${extension}`))) return join(base, `index${extension}`);
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
function parseJsonc(text) {
|
|
828
|
+
let out = "";
|
|
829
|
+
let inString = false;
|
|
830
|
+
for (let i = 0; i < text.length; i++) {
|
|
831
|
+
const char = text[i];
|
|
832
|
+
if (inString) {
|
|
833
|
+
out += char;
|
|
834
|
+
if (char === "\\") out += text[++i] ?? "";
|
|
835
|
+
else if (char === '"') inString = false;
|
|
836
|
+
} else if (char === '"') {
|
|
837
|
+
inString = true;
|
|
838
|
+
out += char;
|
|
839
|
+
} else if (char === "/" && text[i + 1] === "/") {
|
|
840
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
841
|
+
out += "\n";
|
|
842
|
+
} else if (char === "/" && text[i + 1] === "*") {
|
|
843
|
+
i = text.indexOf("*/", i + 2);
|
|
844
|
+
if (i === -1) break;
|
|
845
|
+
i++;
|
|
846
|
+
} else {
|
|
847
|
+
out += char;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
851
|
+
}
|
|
852
|
+
var mappings = /* @__PURE__ */ new Map();
|
|
853
|
+
function pathMapping(fromDir) {
|
|
854
|
+
if (mappings.has(fromDir)) return mappings.get(fromDir);
|
|
855
|
+
let mapping = null;
|
|
856
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
857
|
+
const file = join(fromDir, name);
|
|
858
|
+
if (!existsSync(file)) continue;
|
|
859
|
+
try {
|
|
860
|
+
const config = parseJsonc(readFileSync2(file, "utf8"));
|
|
861
|
+
const options = config.compilerOptions ?? {};
|
|
862
|
+
if (options.paths) mapping = { base: resolve(fromDir, options.baseUrl ?? "."), paths: options.paths };
|
|
863
|
+
} catch {
|
|
864
|
+
mapping = null;
|
|
865
|
+
}
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
if (!mapping) {
|
|
869
|
+
const parent = dirname(fromDir);
|
|
870
|
+
mapping = parent !== fromDir && !existsSync(join(fromDir, "package.json")) ? pathMapping(parent) : null;
|
|
871
|
+
}
|
|
872
|
+
mappings.set(fromDir, mapping);
|
|
873
|
+
return mapping;
|
|
874
|
+
}
|
|
875
|
+
function resolveAlias(specifier, fromFile) {
|
|
876
|
+
const mapping = pathMapping(dirname(fromFile));
|
|
877
|
+
if (!mapping) return null;
|
|
878
|
+
for (const [pattern, targets] of Object.entries(mapping.paths)) {
|
|
879
|
+
const star = pattern.indexOf("*");
|
|
880
|
+
let captured = null;
|
|
881
|
+
if (star === -1) captured = specifier === pattern ? "" : null;
|
|
882
|
+
else {
|
|
883
|
+
const prefix = pattern.slice(0, star);
|
|
884
|
+
const suffix = pattern.slice(star + 1);
|
|
885
|
+
if (specifier.startsWith(prefix) && specifier.endsWith(suffix) && specifier.length >= prefix.length + suffix.length) {
|
|
886
|
+
captured = specifier.slice(prefix.length, specifier.length - suffix.length);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (captured === null) continue;
|
|
890
|
+
for (const target of targets) {
|
|
891
|
+
const file = tryFile(resolve(mapping.base, target.replace("*", captured)));
|
|
892
|
+
if (file) return file;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
function resolveImportPath(specifier, fromFile) {
|
|
898
|
+
if (specifier.startsWith(".")) return tryFile(resolve(dirname(fromFile), specifier));
|
|
899
|
+
if (specifier.startsWith("/")) return tryFile(specifier);
|
|
900
|
+
return resolveAlias(specifier, fromFile);
|
|
901
|
+
}
|
|
902
|
+
function createNodeResolver() {
|
|
903
|
+
const cache = /* @__PURE__ */ new Map();
|
|
904
|
+
return (specifier, fromFile) => {
|
|
905
|
+
const file = resolveImportPath(specifier, fromFile);
|
|
906
|
+
if (!file) return null;
|
|
907
|
+
let mtime = 0;
|
|
908
|
+
try {
|
|
909
|
+
mtime = statSync(file).mtimeMs;
|
|
910
|
+
} catch {
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
const cached = cache.get(file);
|
|
914
|
+
if (cached && cached.mtime === mtime) return cached.module;
|
|
915
|
+
let module = null;
|
|
916
|
+
try {
|
|
917
|
+
module = { ast: parseModule(readFileSync2(file, "utf8"), file), filename: file };
|
|
918
|
+
} catch {
|
|
919
|
+
module = null;
|
|
920
|
+
}
|
|
921
|
+
cache.set(file, { mtime, module });
|
|
922
|
+
return module;
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// src/scan.ts
|
|
927
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", "dist", "build", "out", "coverage", ".turbo", ".vercel", ".output", ".svelte-kit", ".cache"]);
|
|
928
|
+
var SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
929
|
+
function globToRegExp(glob) {
|
|
930
|
+
let pattern = "";
|
|
931
|
+
for (let i = 0; i < glob.length; i++) {
|
|
932
|
+
const char = glob[i];
|
|
933
|
+
if (char === "*" && glob[i + 1] === "*") {
|
|
934
|
+
pattern += glob[i + 2] === "/" ? "(?:.*/)?" : ".*";
|
|
935
|
+
i += glob[i + 2] === "/" ? 2 : 1;
|
|
936
|
+
} else if (char === "*") pattern += "[^/]*";
|
|
937
|
+
else if (char === "?") pattern += "[^/]";
|
|
938
|
+
else pattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
939
|
+
}
|
|
940
|
+
return new RegExp(`^${pattern}$`);
|
|
941
|
+
}
|
|
942
|
+
function* sourceFiles(dir) {
|
|
943
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
944
|
+
if (entry.isDirectory()) {
|
|
945
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) yield* sourceFiles(join2(dir, entry.name));
|
|
946
|
+
} else if (entry.isFile() && SOURCE.test(entry.name) && !entry.name.endsWith(".d.ts")) {
|
|
947
|
+
yield join2(dir, entry.name);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
function loadConfig(root, configPath) {
|
|
952
|
+
if (configPath) return JSON.parse(readFileSync3(configPath, "utf8"));
|
|
953
|
+
const file = join2(root, "server-action-audit.config.json");
|
|
954
|
+
if (existsSync2(file)) return JSON.parse(readFileSync3(file, "utf8"));
|
|
955
|
+
const pkg = join2(root, "package.json");
|
|
956
|
+
if (existsSync2(pkg)) {
|
|
957
|
+
const config = JSON.parse(readFileSync3(pkg, "utf8")).serverActionAudit;
|
|
958
|
+
if (config) return config;
|
|
959
|
+
}
|
|
960
|
+
return {};
|
|
961
|
+
}
|
|
962
|
+
function scanProject(root, options = {}) {
|
|
963
|
+
const resolved = resolveOptions(options);
|
|
964
|
+
const ignore = resolved.ignore.map(globToRegExp);
|
|
965
|
+
const resolver = createNodeResolver();
|
|
966
|
+
const report = { root, files: [], errors: [], summary: { actions: 0, protected: 0, unprotected: 0, public: 0, unvalidated: 0 } };
|
|
967
|
+
for (const path of sourceFiles(root)) {
|
|
968
|
+
const file = relative(root, path).split(sep).join("/");
|
|
969
|
+
if (ignore.some((pattern) => pattern.test(file))) continue;
|
|
970
|
+
const text = readFileSync3(path, "utf8");
|
|
971
|
+
if (!text.includes("use server")) continue;
|
|
972
|
+
let actions;
|
|
973
|
+
try {
|
|
974
|
+
actions = analyzeModule({ ast: parseModule(text, path), text, filename: path }, resolved, resolver);
|
|
975
|
+
} catch (error) {
|
|
976
|
+
report.errors.push({ file, message: error.message });
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (!actions.length) continue;
|
|
980
|
+
report.files.push({ file, actions });
|
|
981
|
+
for (const action of actions) {
|
|
982
|
+
report.summary.actions++;
|
|
983
|
+
report.summary[action.status]++;
|
|
984
|
+
if (action.validated === false) report.summary.unvalidated++;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
report.files.sort((a, b) => a.file.localeCompare(b.file));
|
|
988
|
+
return report;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// src/cli.ts
|
|
992
|
+
var HELP = `Usage: server-action-audit [dir] [options]
|
|
993
|
+
|
|
994
|
+
Lists every Next.js Server Action in [dir] (default: current directory) and
|
|
995
|
+
flags the ones without an authorization check.
|
|
996
|
+
|
|
997
|
+
Options
|
|
998
|
+
--fail-on <level> unprotected (default) | unvalidated | never
|
|
999
|
+
--format <format> pretty (default) | json | github | sarif | markdown
|
|
1000
|
+
github: annotations on pull requests (GitHub Actions)
|
|
1001
|
+
sarif: for GitHub code scanning
|
|
1002
|
+
markdown: for a job summary
|
|
1003
|
+
--pr-comment In GitHub Actions, also post the report as a comment
|
|
1004
|
+
on the pull request, updated on every push. Uses
|
|
1005
|
+
GITHUB_TOKEN, which needs pull-requests: write
|
|
1006
|
+
--json Same as --format json
|
|
1007
|
+
--quiet Only show problems (pretty format)
|
|
1008
|
+
--config <file> Options file (default: server-action-audit.config.json,
|
|
1009
|
+
or "serverActionAudit" in package.json)
|
|
1010
|
+
-h, --help Show this help
|
|
1011
|
+
-v, --version Show the version
|
|
1012
|
+
|
|
1013
|
+
Exit codes: 0 ok, 1 problems found, 2 usage error.
|
|
1014
|
+
`;
|
|
1015
|
+
function sourceUrl(root, serverUrl, repository, sha) {
|
|
1016
|
+
const path = relative2(process.env.GITHUB_WORKSPACE || process.cwd(), root);
|
|
1017
|
+
if (!sha || path.startsWith("..") || isAbsolute(path)) return void 0;
|
|
1018
|
+
return `${serverUrl}/${repository}/blob/${sha}/${path ? `${path.split(sep2).join("/")}/` : ""}`;
|
|
1019
|
+
}
|
|
1020
|
+
async function commentOnPullRequest(report, root) {
|
|
1021
|
+
let pr;
|
|
1022
|
+
try {
|
|
1023
|
+
pr = pullRequestFromEnv();
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
pr = `cannot read the event (${error.message})`;
|
|
1026
|
+
}
|
|
1027
|
+
if (typeof pr === "string") {
|
|
1028
|
+
process.stderr.write(`--pr-comment: no comment, ${pr}.
|
|
1029
|
+
`);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const { unprotected, unvalidated } = report.summary;
|
|
1033
|
+
try {
|
|
1034
|
+
const body = formatMarkdown(report, { sourceUrl: sourceUrl(root, pr.serverUrl, pr.repository, pr.headSha) });
|
|
1035
|
+
const result = await upsertComment(pr, body, { clean: !unprotected && !unvalidated });
|
|
1036
|
+
const done = { created: "commented on", updated: "updated its comment on", skipped: "nothing new to post on" }[result];
|
|
1037
|
+
process.stderr.write(`--pr-comment: ${done} pull request #${pr.number}.
|
|
1038
|
+
`);
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
const { message, status } = error;
|
|
1041
|
+
const hint = status === 403 || status === 404 ? ' The job needs "permissions: pull-requests: write" (pull requests from forks only get a read-only token).' : "";
|
|
1042
|
+
process.stderr.write(`::warning title=server-action-audit::Could not comment on pull request #${pr.number}: ${message}.${hint}
|
|
1043
|
+
`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
async function main(argv) {
|
|
1047
|
+
let dir = ".";
|
|
1048
|
+
let format = "pretty";
|
|
1049
|
+
let quiet = false;
|
|
1050
|
+
let prComment = false;
|
|
1051
|
+
let failOn = "unprotected";
|
|
1052
|
+
let configPath;
|
|
1053
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1054
|
+
const arg = argv[i];
|
|
1055
|
+
if (arg === "-h" || arg === "--help") {
|
|
1056
|
+
process.stdout.write(HELP);
|
|
1057
|
+
return 0;
|
|
1058
|
+
}
|
|
1059
|
+
if (arg === "-v" || arg === "--version") {
|
|
1060
|
+
process.stdout.write(`${VERSION}
|
|
1061
|
+
`);
|
|
1062
|
+
return 0;
|
|
1063
|
+
}
|
|
1064
|
+
if (arg === "--json") format = "json";
|
|
1065
|
+
else if (arg === "--format" || arg.startsWith("--format=")) format = arg.includes("=") ? arg.split("=")[1] : argv[++i] ?? "";
|
|
1066
|
+
else if (arg === "--quiet") quiet = true;
|
|
1067
|
+
else if (arg === "--pr-comment") prComment = true;
|
|
1068
|
+
else if (arg === "--fail-on" || arg.startsWith("--fail-on=")) failOn = arg.includes("=") ? arg.split("=")[1] : argv[++i] ?? "";
|
|
1069
|
+
else if (arg === "--config" || arg.startsWith("--config=")) configPath = arg.includes("=") ? arg.split("=")[1] : argv[++i];
|
|
1070
|
+
else if (arg.startsWith("-")) {
|
|
1071
|
+
process.stderr.write(`Unknown option: ${arg}
|
|
1072
|
+
|
|
1073
|
+
${HELP}`);
|
|
1074
|
+
return 2;
|
|
1075
|
+
} else dir = arg;
|
|
1076
|
+
}
|
|
1077
|
+
if (!["pretty", "json", "github", "sarif", "markdown"].includes(format)) {
|
|
1078
|
+
process.stderr.write(`--format must be pretty, json, github, sarif or markdown (got "${format}").
|
|
1079
|
+
`);
|
|
1080
|
+
return 2;
|
|
1081
|
+
}
|
|
1082
|
+
if (!["unprotected", "unvalidated", "never"].includes(failOn)) {
|
|
1083
|
+
process.stderr.write(`--fail-on must be unprotected, unvalidated or never (got "${failOn}").
|
|
1084
|
+
`);
|
|
1085
|
+
return 2;
|
|
1086
|
+
}
|
|
1087
|
+
const root = resolve2(dir);
|
|
1088
|
+
let report;
|
|
1089
|
+
try {
|
|
1090
|
+
report = scanProject(root, loadConfig(root, configPath && resolve2(configPath)));
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
process.stderr.write(`server-action-audit: ${error.message}
|
|
1093
|
+
`);
|
|
1094
|
+
return 2;
|
|
1095
|
+
}
|
|
1096
|
+
const output = format === "json" ? formatJson(report) : format === "github" ? formatGithub(report) : format === "sarif" ? formatSarif(report, VERSION) : format === "markdown" ? formatMarkdown(report) : formatPretty(report, { quiet });
|
|
1097
|
+
process.stdout.write(`${output}
|
|
1098
|
+
`);
|
|
1099
|
+
if (prComment) await commentOnPullRequest(report, root);
|
|
1100
|
+
const { unprotected, unvalidated } = report.summary;
|
|
1101
|
+
if (failOn === "unprotected" && unprotected) return 1;
|
|
1102
|
+
if (failOn === "unvalidated" && (unprotected || unvalidated)) return 1;
|
|
1103
|
+
return 0;
|
|
1104
|
+
}
|
|
1105
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
1106
|
+
//# sourceMappingURL=cli.js.map
|