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/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 readFileSync2, readdirSync } from "fs";
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(readFileSync(file, "utf8"));
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(readFileSync(file, "utf8"), file), filename: file };
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(readFileSync2(configPath, "utf8"));
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(readFileSync2(file, "utf8"));
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(readFileSync2(pkg, "utf8")).serverActionAudit;
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 = readFileSync2(path, "utf8");
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 main(argv) {
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 sarif (got "${format}").
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