@sms-florin/mcp-ssrf-scan 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.
Files changed (3) hide show
  1. package/README.md +30 -0
  2. package/dist/cli.js +103 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -57,6 +57,36 @@ Exit code is `1` if any `HIGH` finding is reported, `0` otherwise:
57
57
  - run: npx @sms-florin/mcp-ssrf-scan ./src
58
58
  ```
59
59
 
60
+ `--json` prints findings as JSON instead of the human-readable list.
61
+
62
+ ## mcp-ssrf-scan Cloud (optional, paid)
63
+
64
+ The CLI above is free and stays free. [mcp-ssrf-scan Cloud](https://mcpscan.flo-voice1.com)
65
+ ($15/month, up to 10 repos) adds what a CI gate alone can't: every run is
66
+ uploaded and diffed against the previous scan of that repo, so the CLI
67
+ prints exactly what's **new since the last scan** and what got **fixed**,
68
+ and every scan stays browsable on a dashboard for 90 days.
69
+
70
+ ```yaml
71
+ # GitHub Actions
72
+ - run: npx @sms-florin/mcp-ssrf-scan@latest ./src --upload
73
+ env:
74
+ MCP_SSRF_SCAN_KEY: ${{ secrets.MCP_SSRF_SCAN_KEY }}
75
+ ```
76
+
77
+ ```yaml
78
+ # GitLab CI (MCP_SSRF_SCAN_KEY as a masked CI/CD variable)
79
+ mcp-ssrf-scan:
80
+ image: node:22
81
+ script:
82
+ - npx @sms-florin/mcp-ssrf-scan@latest ./src --upload
83
+ ```
84
+
85
+ Repo, branch and commit come from the CI environment (or `--repo owner/name`).
86
+ Only findings are uploaded — path, line, rule, message and the flagged line
87
+ — never your source. A failed upload only prints a warning; the exit code is
88
+ always decided by the findings alone.
89
+
60
90
  ## License
61
91
 
62
92
  MIT
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
2
3
  import { scanDirectory } from "./scan.js";
3
4
  const COLOR = {
4
5
  high: "\x1b[31m", // red
@@ -29,33 +30,120 @@ This is a heuristic, regex-based scanner, not a full AST analyzer — it can
29
30
  miss real issues and flag things that are actually fine. Treat every HIGH
30
31
  finding as "go read this function," not as a confirmed bug.
31
32
 
33
+ Options:
34
+ --json Print findings as JSON instead of the human-readable list
35
+ --upload Upload this scan to mcp-ssrf-scan Cloud (needs MCP_SSRF_SCAN_KEY)
36
+ and print what's new/fixed since the last scan of this repo
37
+ --repo owner/name Repo name for --upload (auto-detected in GitHub Actions,
38
+ GitLab CI, or from the git remote)
39
+
32
40
  Exit code is 1 if any HIGH finding is reported, 0 otherwise (so it's usable
33
41
  as a CI gate: fail the build on HIGH, let MEDIUM/INFO through as warnings).
42
+ A failed --upload only prints a warning — it never changes the exit code.
34
43
  `);
35
44
  }
36
- function main() {
45
+ function flagValue(args, name) {
46
+ const i = args.indexOf(name);
47
+ return i >= 0 ? args[i + 1] : undefined;
48
+ }
49
+ function git(...cmd) {
50
+ try {
51
+ return execFileSync("git", cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ // owner/name from GitHub Actions, GitLab CI, or the local git remote.
58
+ function detectRepo() {
59
+ const env = process.env;
60
+ if (env.GITHUB_REPOSITORY)
61
+ return { repo: env.GITHUB_REPOSITORY, ref: env.GITHUB_REF_NAME, commit: env.GITHUB_SHA };
62
+ if (env.CI_PROJECT_PATH)
63
+ return { repo: env.CI_PROJECT_PATH, ref: env.CI_COMMIT_REF_NAME, commit: env.CI_COMMIT_SHA };
64
+ const remote = git("config", "--get", "remote.origin.url");
65
+ const repo = remote?.replace(/\.git$/, "").match(/[:/]([^/:]+\/[^/]+)$/)?.[1];
66
+ return { repo, ref: git("rev-parse", "--abbrev-ref", "HEAD"), commit: git("rev-parse", "HEAD") };
67
+ }
68
+ // Uploads to mcp-ssrf-scan Cloud (paid, optional). A failed upload is a
69
+ // warning, never a build failure — the exit code stays purely about findings,
70
+ // so an outage on our side can't break someone's pipeline.
71
+ async function upload(findings, args) {
72
+ const key = process.env.MCP_SSRF_SCAN_KEY;
73
+ if (!key) {
74
+ console.error(`${COLOR.high}--upload needs MCP_SSRF_SCAN_KEY${COLOR.reset} (get one at https://mcpscan.flo-voice1.com) — skipping upload.`);
75
+ return;
76
+ }
77
+ const detected = detectRepo();
78
+ const repo = flagValue(args, "--repo") ?? detected.repo;
79
+ if (!repo) {
80
+ console.error("Could not detect the repo name — pass --repo owner/name. Skipping upload.");
81
+ return;
82
+ }
83
+ const base = process.env.MCP_SSRF_SCAN_URL ?? "https://mcpscan.flo-voice1.com";
84
+ try {
85
+ const res = await fetch(`${base}/api/scans`, {
86
+ method: "POST",
87
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
88
+ body: JSON.stringify({ repo, ref: detected.ref, commit: detected.commit, findings }),
89
+ signal: AbortSignal.timeout(20_000),
90
+ });
91
+ const data = (await res.json());
92
+ if (!res.ok) {
93
+ console.error(`${COLOR.high}Upload failed:${COLOR.reset} ${data.error ?? `HTTP ${res.status}`}`);
94
+ return;
95
+ }
96
+ console.log(`\n${COLOR.bold}Uploaded to mcp-ssrf-scan Cloud${COLOR.reset} — ${repo}`);
97
+ if (data.baseline) {
98
+ console.log("First scan of this repo: saved as the baseline, future runs show what changed.");
99
+ }
100
+ else {
101
+ console.log(`${data.newFindings.length} new since the last scan, ${data.fixedFindings.length} fixed.`);
102
+ for (const f of data.newFindings) {
103
+ console.log(` ${COLOR.bold}NEW${COLOR.reset} ${severityLabel(f.severity)} ${f.file}:${f.line} ${COLOR.dim}[${f.rule}]${COLOR.reset}`);
104
+ }
105
+ for (const f of data.fixedFindings) {
106
+ console.log(` ${COLOR.dim}FIXED ${f.severity.toUpperCase()} ${f.file}:${f.line} [${f.rule}]${COLOR.reset}`);
107
+ }
108
+ }
109
+ console.log(`${COLOR.dim}${data.dashboardUrl}${COLOR.reset}`);
110
+ }
111
+ catch (error) {
112
+ console.error(`${COLOR.high}Upload failed:${COLOR.reset} ${error instanceof Error ? error.message : String(error)}`);
113
+ }
114
+ }
115
+ async function main() {
37
116
  const args = process.argv.slice(2);
38
117
  if (args.includes("--help") || args.includes("-h")) {
39
118
  printHelp();
40
119
  process.exit(0);
41
120
  }
42
- const target = args.find((a) => !a.startsWith("-")) ?? ".";
43
- console.log(`${COLOR.bold}mcp-ssrf-scan${COLOR.reset} — scanning ${target}\n`);
121
+ const valueFlags = new Set(["--repo"]);
122
+ const target = args.find((a, i) => !a.startsWith("-") && !valueFlags.has(args[i - 1])) ?? ".";
123
+ const asJson = args.includes("--json");
44
124
  const findings = scanDirectory(target);
45
- if (findings.length === 0) {
46
- console.log("No findings. (Heuristic scan — this is not a guarantee, see --help.)");
47
- process.exit(0);
125
+ const hasHigh = findings.some((f) => f.severity === "high");
126
+ if (asJson) {
127
+ console.log(JSON.stringify({ target, findings }, null, 2));
48
128
  }
49
- const order = ["high", "medium", "info"];
50
- const grouped = order.flatMap((sev) => findings.filter((f) => f.severity === sev));
51
- for (const f of grouped) {
52
- console.log(`${severityLabel(f.severity)} ${f.file}:${f.line} ${COLOR.dim}[${f.rule}]${COLOR.reset}`);
53
- console.log(` ${f.message}`);
54
- console.log(` ${COLOR.dim}${f.snippet}${COLOR.reset}\n`);
129
+ else {
130
+ console.log(`${COLOR.bold}mcp-ssrf-scan${COLOR.reset} — scanning ${target}\n`);
131
+ if (findings.length === 0) {
132
+ console.log("No findings. (Heuristic scan — this is not a guarantee, see --help.)");
133
+ }
134
+ else {
135
+ const order = ["high", "medium", "info"];
136
+ for (const f of order.flatMap((sev) => findings.filter((x) => x.severity === sev))) {
137
+ console.log(`${severityLabel(f.severity)} ${f.file}:${f.line} ${COLOR.dim}[${f.rule}]${COLOR.reset}`);
138
+ console.log(` ${f.message}`);
139
+ console.log(` ${COLOR.dim}${f.snippet}${COLOR.reset}\n`);
140
+ }
141
+ const counts = order.map((sev) => `${findings.filter((f) => f.severity === sev).length} ${sev}`);
142
+ console.log(`${COLOR.bold}${findings.length} finding(s)${COLOR.reset} — ${counts.join(", ")}`);
143
+ }
55
144
  }
56
- const counts = order.map((sev) => `${findings.filter((f) => f.severity === sev).length} ${sev}`);
57
- console.log(`${COLOR.bold}${findings.length} finding(s)${COLOR.reset} — ${counts.join(", ")}`);
58
- const hasHigh = findings.some((f) => f.severity === "high");
145
+ if (args.includes("--upload"))
146
+ await upload(findings, args);
59
147
  process.exit(hasHigh ? 1 : 0);
60
148
  }
61
149
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sms-florin/mcp-ssrf-scan",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A static scanner for MCP servers that catches the exact bug class behind most real MCP SSRF reports: a tool handler making an outbound request to a caller-controlled URL with no host validation.",
5
5
  "type": "module",
6
6
  "bin": {