@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.
- package/README.md +30 -0
- package/dist/cli.js +103 -15
- 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
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
125
|
+
const hasHigh = findings.some((f) => f.severity === "high");
|
|
126
|
+
if (asJson) {
|
|
127
|
+
console.log(JSON.stringify({ target, findings }, null, 2));
|
|
48
128
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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.
|
|
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": {
|