server-action-audit 0.1.0 → 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/README.fr.md +2 -1
- package/README.md +19 -0
- package/dist/{chunk-GQSNZNKN.js → chunk-RCEEE6TI.js} +2 -2
- package/dist/{chunk-GQSNZNKN.js.map → chunk-RCEEE6TI.js.map} +1 -1
- package/dist/cli.js +160 -18
- package/dist/cli.js.map +1 -1
- package/dist/eslint.cjs +1 -1
- package/dist/eslint.cjs.map +1 -1
- package/dist/eslint.js +1 -1
- package/dist/index.cjs +115 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -2
- package/dist/index.d.ts +32 -2
- package/dist/index.js +108 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { resolve as resolve2 } from "path";
|
|
4
|
+
import { isAbsolute, relative as relative2, resolve as resolve2, sep as sep2 } from "path";
|
|
5
5
|
|
|
6
6
|
// src/format.ts
|
|
7
7
|
var useColor = process.env.FORCE_COLOR ? true : !process.env.NO_COLOR && !!process.stdout.isTTY;
|
|
@@ -107,6 +107,51 @@ function formatGithub(report) {
|
|
|
107
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
108
|
return lines.join("\n");
|
|
109
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
|
+
}
|
|
110
155
|
function formatSarif(report, version) {
|
|
111
156
|
const rules = [
|
|
112
157
|
{
|
|
@@ -147,8 +192,70 @@ function formatSarif(report, version) {
|
|
|
147
192
|
);
|
|
148
193
|
}
|
|
149
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
|
+
|
|
150
257
|
// src/scan.ts
|
|
151
|
-
import { existsSync as existsSync2, readFileSync as
|
|
258
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync } from "fs";
|
|
152
259
|
import { join as join2, relative, sep } from "path";
|
|
153
260
|
|
|
154
261
|
// src/ast.ts
|
|
@@ -684,7 +791,7 @@ function analyzeModule(input, options, resolver) {
|
|
|
684
791
|
}
|
|
685
792
|
|
|
686
793
|
// src/resolve.ts
|
|
687
|
-
import { existsSync, readFileSync, statSync } from "fs";
|
|
794
|
+
import { existsSync, readFileSync as readFileSync2, statSync } from "fs";
|
|
688
795
|
import { dirname, join, resolve } from "path";
|
|
689
796
|
import { parse } from "@typescript-eslint/typescript-estree";
|
|
690
797
|
var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
|
|
@@ -750,7 +857,7 @@ function pathMapping(fromDir) {
|
|
|
750
857
|
const file = join(fromDir, name);
|
|
751
858
|
if (!existsSync(file)) continue;
|
|
752
859
|
try {
|
|
753
|
-
const config = parseJsonc(
|
|
860
|
+
const config = parseJsonc(readFileSync2(file, "utf8"));
|
|
754
861
|
const options = config.compilerOptions ?? {};
|
|
755
862
|
if (options.paths) mapping = { base: resolve(fromDir, options.baseUrl ?? "."), paths: options.paths };
|
|
756
863
|
} catch {
|
|
@@ -807,7 +914,7 @@ function createNodeResolver() {
|
|
|
807
914
|
if (cached && cached.mtime === mtime) return cached.module;
|
|
808
915
|
let module = null;
|
|
809
916
|
try {
|
|
810
|
-
module = { ast: parseModule(
|
|
917
|
+
module = { ast: parseModule(readFileSync2(file, "utf8"), file), filename: file };
|
|
811
918
|
} catch {
|
|
812
919
|
module = null;
|
|
813
920
|
}
|
|
@@ -842,12 +949,12 @@ function* sourceFiles(dir) {
|
|
|
842
949
|
}
|
|
843
950
|
}
|
|
844
951
|
function loadConfig(root, configPath) {
|
|
845
|
-
if (configPath) return JSON.parse(
|
|
952
|
+
if (configPath) return JSON.parse(readFileSync3(configPath, "utf8"));
|
|
846
953
|
const file = join2(root, "server-action-audit.config.json");
|
|
847
|
-
if (existsSync2(file)) return JSON.parse(
|
|
954
|
+
if (existsSync2(file)) return JSON.parse(readFileSync3(file, "utf8"));
|
|
848
955
|
const pkg = join2(root, "package.json");
|
|
849
956
|
if (existsSync2(pkg)) {
|
|
850
|
-
const config = JSON.parse(
|
|
957
|
+
const config = JSON.parse(readFileSync3(pkg, "utf8")).serverActionAudit;
|
|
851
958
|
if (config) return config;
|
|
852
959
|
}
|
|
853
960
|
return {};
|
|
@@ -860,7 +967,7 @@ function scanProject(root, options = {}) {
|
|
|
860
967
|
for (const path of sourceFiles(root)) {
|
|
861
968
|
const file = relative(root, path).split(sep).join("/");
|
|
862
969
|
if (ignore.some((pattern) => pattern.test(file))) continue;
|
|
863
|
-
const text =
|
|
970
|
+
const text = readFileSync3(path, "utf8");
|
|
864
971
|
if (!text.includes("use server")) continue;
|
|
865
972
|
let actions;
|
|
866
973
|
try {
|
|
@@ -881,9 +988,6 @@ function scanProject(root, options = {}) {
|
|
|
881
988
|
return report;
|
|
882
989
|
}
|
|
883
990
|
|
|
884
|
-
// src/version.ts
|
|
885
|
-
var VERSION = "0.1.0";
|
|
886
|
-
|
|
887
991
|
// src/cli.ts
|
|
888
992
|
var HELP = `Usage: server-action-audit [dir] [options]
|
|
889
993
|
|
|
@@ -892,9 +996,13 @@ flags the ones without an authorization check.
|
|
|
892
996
|
|
|
893
997
|
Options
|
|
894
998
|
--fail-on <level> unprotected (default) | unvalidated | never
|
|
895
|
-
--format <format> pretty (default) | json | github | sarif
|
|
999
|
+
--format <format> pretty (default) | json | github | sarif | markdown
|
|
896
1000
|
github: annotations on pull requests (GitHub Actions)
|
|
897
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
|
|
898
1006
|
--json Same as --format json
|
|
899
1007
|
--quiet Only show problems (pretty format)
|
|
900
1008
|
--config <file> Options file (default: server-action-audit.config.json,
|
|
@@ -904,10 +1012,42 @@ Options
|
|
|
904
1012
|
|
|
905
1013
|
Exit codes: 0 ok, 1 problems found, 2 usage error.
|
|
906
1014
|
`;
|
|
907
|
-
function
|
|
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) {
|
|
908
1047
|
let dir = ".";
|
|
909
1048
|
let format = "pretty";
|
|
910
1049
|
let quiet = false;
|
|
1050
|
+
let prComment = false;
|
|
911
1051
|
let failOn = "unprotected";
|
|
912
1052
|
let configPath;
|
|
913
1053
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -924,6 +1064,7 @@ function main(argv) {
|
|
|
924
1064
|
if (arg === "--json") format = "json";
|
|
925
1065
|
else if (arg === "--format" || arg.startsWith("--format=")) format = arg.includes("=") ? arg.split("=")[1] : argv[++i] ?? "";
|
|
926
1066
|
else if (arg === "--quiet") quiet = true;
|
|
1067
|
+
else if (arg === "--pr-comment") prComment = true;
|
|
927
1068
|
else if (arg === "--fail-on" || arg.startsWith("--fail-on=")) failOn = arg.includes("=") ? arg.split("=")[1] : argv[++i] ?? "";
|
|
928
1069
|
else if (arg === "--config" || arg.startsWith("--config=")) configPath = arg.includes("=") ? arg.split("=")[1] : argv[++i];
|
|
929
1070
|
else if (arg.startsWith("-")) {
|
|
@@ -933,8 +1074,8 @@ ${HELP}`);
|
|
|
933
1074
|
return 2;
|
|
934
1075
|
} else dir = arg;
|
|
935
1076
|
}
|
|
936
|
-
if (!["pretty", "json", "github", "sarif"].includes(format)) {
|
|
937
|
-
process.stderr.write(`--format must be pretty, json, github or
|
|
1077
|
+
if (!["pretty", "json", "github", "sarif", "markdown"].includes(format)) {
|
|
1078
|
+
process.stderr.write(`--format must be pretty, json, github, sarif or markdown (got "${format}").
|
|
938
1079
|
`);
|
|
939
1080
|
return 2;
|
|
940
1081
|
}
|
|
@@ -952,13 +1093,14 @@ ${HELP}`);
|
|
|
952
1093
|
`);
|
|
953
1094
|
return 2;
|
|
954
1095
|
}
|
|
955
|
-
const output = format === "json" ? formatJson(report) : format === "github" ? formatGithub(report) : format === "sarif" ? formatSarif(report, VERSION) : formatPretty(report, { quiet });
|
|
1096
|
+
const output = format === "json" ? formatJson(report) : format === "github" ? formatGithub(report) : format === "sarif" ? formatSarif(report, VERSION) : format === "markdown" ? formatMarkdown(report) : formatPretty(report, { quiet });
|
|
956
1097
|
process.stdout.write(`${output}
|
|
957
1098
|
`);
|
|
1099
|
+
if (prComment) await commentOnPullRequest(report, root);
|
|
958
1100
|
const { unprotected, unvalidated } = report.summary;
|
|
959
1101
|
if (failOn === "unprotected" && unprotected) return 1;
|
|
960
1102
|
if (failOn === "unvalidated" && (unprotected || unvalidated)) return 1;
|
|
961
1103
|
return 0;
|
|
962
1104
|
}
|
|
963
|
-
process.exitCode = main(process.argv.slice(2));
|
|
1105
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
964
1106
|
//# sourceMappingURL=cli.js.map
|