@sms-florin/mcp-ssrf-scan 0.1.0 → 0.3.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 +48 -0
- package/dist/cli.js +112 -15
- package/dist/sarif.d.ts +44 -0
- package/dist/sarif.js +55 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,6 +57,54 @@ 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
|
+
`--sarif` prints SARIF 2.1.0 for code-scanning UIs. Secret findings are tagged
|
|
63
|
+
OWASP MCP01 (Token Mismanagement & Secret Exposure) and exec findings MCP05
|
|
64
|
+
(Command Injection & Execution). The OWASP MCP Top 10 has no SSRF entry, so
|
|
65
|
+
the SSRF rule carries no OWASP tag.
|
|
66
|
+
|
|
67
|
+
### GitLab CI component
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
include:
|
|
71
|
+
- component: gitlab.com/florin-builds/mcp-ssrf-scan/scan@~latest
|
|
72
|
+
inputs:
|
|
73
|
+
path: ./src
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Inputs: `stage` (default `test`), `path` (default `.`), `node_image` (default
|
|
77
|
+
`node:22-alpine`). The job fails on any HIGH finding and keeps
|
|
78
|
+
`mcp-ssrf-scan.sarif` as an artifact.
|
|
79
|
+
|
|
80
|
+
## mcp-ssrf-scan Cloud (optional, paid)
|
|
81
|
+
|
|
82
|
+
The CLI above is free and stays free. [mcp-ssrf-scan Cloud](https://mcpscan.flo-voice1.com)
|
|
83
|
+
($15/month, up to 10 repos) adds what a CI gate alone can't: every run is
|
|
84
|
+
uploaded and diffed against the previous scan of that repo, so the CLI
|
|
85
|
+
prints exactly what's **new since the last scan** and what got **fixed**,
|
|
86
|
+
and every scan stays browsable on a dashboard for 90 days.
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
# GitHub Actions
|
|
90
|
+
- run: npx @sms-florin/mcp-ssrf-scan@latest ./src --upload
|
|
91
|
+
env:
|
|
92
|
+
MCP_SSRF_SCAN_KEY: ${{ secrets.MCP_SSRF_SCAN_KEY }}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
# GitLab CI (MCP_SSRF_SCAN_KEY as a masked CI/CD variable)
|
|
97
|
+
mcp-ssrf-scan:
|
|
98
|
+
image: node:22
|
|
99
|
+
script:
|
|
100
|
+
- npx @sms-florin/mcp-ssrf-scan@latest ./src --upload
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Repo, branch and commit come from the CI environment (or `--repo owner/name`).
|
|
104
|
+
Only findings are uploaded — path, line, rule, message and the flagged line
|
|
105
|
+
— never your source. A failed upload only prints a warning; the exit code is
|
|
106
|
+
always decided by the findings alone.
|
|
107
|
+
|
|
60
108
|
## License
|
|
61
109
|
|
|
62
110
|
MIT
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { toSarif } from "./sarif.js";
|
|
2
5
|
import { scanDirectory } from "./scan.js";
|
|
3
6
|
const COLOR = {
|
|
4
7
|
high: "\x1b[31m", // red
|
|
@@ -29,33 +32,127 @@ This is a heuristic, regex-based scanner, not a full AST analyzer — it can
|
|
|
29
32
|
miss real issues and flag things that are actually fine. Treat every HIGH
|
|
30
33
|
finding as "go read this function," not as a confirmed bug.
|
|
31
34
|
|
|
35
|
+
Options:
|
|
36
|
+
--json Print findings as JSON instead of the human-readable list
|
|
37
|
+
--sarif Print findings as SARIF 2.1.0 (GitHub/GitLab code scanning,
|
|
38
|
+
VS Code); rules carry OWASP MCP Top 10 tags where one fits
|
|
39
|
+
--upload Upload this scan to mcp-ssrf-scan Cloud (needs MCP_SSRF_SCAN_KEY)
|
|
40
|
+
and print what's new/fixed since the last scan of this repo
|
|
41
|
+
--repo owner/name Repo name for --upload (auto-detected in GitHub Actions,
|
|
42
|
+
GitLab CI, or from the git remote)
|
|
43
|
+
|
|
32
44
|
Exit code is 1 if any HIGH finding is reported, 0 otherwise (so it's usable
|
|
33
45
|
as a CI gate: fail the build on HIGH, let MEDIUM/INFO through as warnings).
|
|
46
|
+
A failed --upload only prints a warning — it never changes the exit code.
|
|
34
47
|
`);
|
|
35
48
|
}
|
|
36
|
-
function
|
|
49
|
+
function flagValue(args, name) {
|
|
50
|
+
const i = args.indexOf(name);
|
|
51
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
52
|
+
}
|
|
53
|
+
function git(...cmd) {
|
|
54
|
+
try {
|
|
55
|
+
return execFileSync("git", cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// owner/name from GitHub Actions, GitLab CI, or the local git remote.
|
|
62
|
+
function detectRepo() {
|
|
63
|
+
const env = process.env;
|
|
64
|
+
if (env.GITHUB_REPOSITORY)
|
|
65
|
+
return { repo: env.GITHUB_REPOSITORY, ref: env.GITHUB_REF_NAME, commit: env.GITHUB_SHA };
|
|
66
|
+
if (env.CI_PROJECT_PATH)
|
|
67
|
+
return { repo: env.CI_PROJECT_PATH, ref: env.CI_COMMIT_REF_NAME, commit: env.CI_COMMIT_SHA };
|
|
68
|
+
const remote = git("config", "--get", "remote.origin.url");
|
|
69
|
+
const repo = remote?.replace(/\.git$/, "").match(/[:/]([^/:]+\/[^/]+)$/)?.[1];
|
|
70
|
+
return { repo, ref: git("rev-parse", "--abbrev-ref", "HEAD"), commit: git("rev-parse", "HEAD") };
|
|
71
|
+
}
|
|
72
|
+
// Uploads to mcp-ssrf-scan Cloud (paid, optional). A failed upload is a
|
|
73
|
+
// warning, never a build failure — the exit code stays purely about findings,
|
|
74
|
+
// so an outage on our side can't break someone's pipeline.
|
|
75
|
+
async function upload(findings, args) {
|
|
76
|
+
const key = process.env.MCP_SSRF_SCAN_KEY;
|
|
77
|
+
if (!key) {
|
|
78
|
+
console.error(`${COLOR.high}--upload needs MCP_SSRF_SCAN_KEY${COLOR.reset} (get one at https://mcpscan.flo-voice1.com) — skipping upload.`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const detected = detectRepo();
|
|
82
|
+
const repo = flagValue(args, "--repo") ?? detected.repo;
|
|
83
|
+
if (!repo) {
|
|
84
|
+
console.error("Could not detect the repo name — pass --repo owner/name. Skipping upload.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const base = process.env.MCP_SSRF_SCAN_URL ?? "https://mcpscan.flo-voice1.com";
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${base}/api/scans`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
92
|
+
body: JSON.stringify({ repo, ref: detected.ref, commit: detected.commit, findings }),
|
|
93
|
+
signal: AbortSignal.timeout(20_000),
|
|
94
|
+
});
|
|
95
|
+
const data = (await res.json());
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
console.error(`${COLOR.high}Upload failed:${COLOR.reset} ${data.error ?? `HTTP ${res.status}`}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
console.log(`\n${COLOR.bold}Uploaded to mcp-ssrf-scan Cloud${COLOR.reset} — ${repo}`);
|
|
101
|
+
if (data.baseline) {
|
|
102
|
+
console.log("First scan of this repo: saved as the baseline, future runs show what changed.");
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
console.log(`${data.newFindings.length} new since the last scan, ${data.fixedFindings.length} fixed.`);
|
|
106
|
+
for (const f of data.newFindings) {
|
|
107
|
+
console.log(` ${COLOR.bold}NEW${COLOR.reset} ${severityLabel(f.severity)} ${f.file}:${f.line} ${COLOR.dim}[${f.rule}]${COLOR.reset}`);
|
|
108
|
+
}
|
|
109
|
+
for (const f of data.fixedFindings) {
|
|
110
|
+
console.log(` ${COLOR.dim}FIXED ${f.severity.toUpperCase()} ${f.file}:${f.line} [${f.rule}]${COLOR.reset}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
console.log(`${COLOR.dim}${data.dashboardUrl}${COLOR.reset}`);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
console.error(`${COLOR.high}Upload failed:${COLOR.reset} ${error instanceof Error ? error.message : String(error)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function main() {
|
|
37
120
|
const args = process.argv.slice(2);
|
|
38
121
|
if (args.includes("--help") || args.includes("-h")) {
|
|
39
122
|
printHelp();
|
|
40
123
|
process.exit(0);
|
|
41
124
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
125
|
+
const valueFlags = new Set(["--repo"]);
|
|
126
|
+
const target = args.find((a, i) => !a.startsWith("-") && !valueFlags.has(args[i - 1])) ?? ".";
|
|
127
|
+
const asJson = args.includes("--json");
|
|
128
|
+
const asSarif = args.includes("--sarif");
|
|
44
129
|
const findings = scanDirectory(target);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
130
|
+
const hasHigh = findings.some((f) => f.severity === "high");
|
|
131
|
+
if (asSarif) {
|
|
132
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
133
|
+
console.log(JSON.stringify(toSarif(findings, pkg.version), null, 2));
|
|
48
134
|
}
|
|
49
|
-
|
|
50
|
-
|
|
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`);
|
|
135
|
+
else if (asJson) {
|
|
136
|
+
console.log(JSON.stringify({ target, findings }, null, 2));
|
|
55
137
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
138
|
+
else {
|
|
139
|
+
console.log(`${COLOR.bold}mcp-ssrf-scan${COLOR.reset} — scanning ${target}\n`);
|
|
140
|
+
if (findings.length === 0) {
|
|
141
|
+
console.log("No findings. (Heuristic scan — this is not a guarantee, see --help.)");
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const order = ["high", "medium", "info"];
|
|
145
|
+
for (const f of order.flatMap((sev) => findings.filter((x) => x.severity === sev))) {
|
|
146
|
+
console.log(`${severityLabel(f.severity)} ${f.file}:${f.line} ${COLOR.dim}[${f.rule}]${COLOR.reset}`);
|
|
147
|
+
console.log(` ${f.message}`);
|
|
148
|
+
console.log(` ${COLOR.dim}${f.snippet}${COLOR.reset}\n`);
|
|
149
|
+
}
|
|
150
|
+
const counts = order.map((sev) => `${findings.filter((f) => f.severity === sev).length} ${sev}`);
|
|
151
|
+
console.log(`${COLOR.bold}${findings.length} finding(s)${COLOR.reset} — ${counts.join(", ")}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (args.includes("--upload"))
|
|
155
|
+
await upload(findings, args);
|
|
59
156
|
process.exit(hasHigh ? 1 : 0);
|
|
60
157
|
}
|
|
61
158
|
main();
|
package/dist/sarif.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Finding } from "./scan.js";
|
|
2
|
+
export declare function toSarif(findings: Finding[], version: string): {
|
|
3
|
+
$schema: string;
|
|
4
|
+
version: string;
|
|
5
|
+
runs: {
|
|
6
|
+
tool: {
|
|
7
|
+
driver: {
|
|
8
|
+
name: string;
|
|
9
|
+
version: string;
|
|
10
|
+
informationUri: string;
|
|
11
|
+
rules: {
|
|
12
|
+
id: "ssrf" | "secret" | "exec-injection";
|
|
13
|
+
name: string;
|
|
14
|
+
shortDescription: {
|
|
15
|
+
text: string;
|
|
16
|
+
};
|
|
17
|
+
properties: {
|
|
18
|
+
tags: string[];
|
|
19
|
+
};
|
|
20
|
+
}[];
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
results: {
|
|
24
|
+
ruleId: "ssrf" | "secret" | "exec-injection";
|
|
25
|
+
level: string;
|
|
26
|
+
message: {
|
|
27
|
+
text: string;
|
|
28
|
+
};
|
|
29
|
+
locations: {
|
|
30
|
+
physicalLocation: {
|
|
31
|
+
artifactLocation: {
|
|
32
|
+
uri: string;
|
|
33
|
+
};
|
|
34
|
+
region: {
|
|
35
|
+
startLine: number;
|
|
36
|
+
snippet: {
|
|
37
|
+
text: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
}[];
|
|
42
|
+
}[];
|
|
43
|
+
}[];
|
|
44
|
+
};
|
package/dist/sarif.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// OWASP MCP Top 10 (2025) has no SSRF entry, so that rule is left unmapped
|
|
2
|
+
// rather than forced into a category it doesn't belong to.
|
|
3
|
+
const RULES = {
|
|
4
|
+
ssrf: {
|
|
5
|
+
name: "McpToolSsrf",
|
|
6
|
+
description: "Outbound request in a tool handler uses a caller-controlled URL with no host/IP validation.",
|
|
7
|
+
},
|
|
8
|
+
secret: {
|
|
9
|
+
name: "HardcodedSecret",
|
|
10
|
+
description: "Hardcoded credential or API key in source.",
|
|
11
|
+
owasp: "MCP01:2025 Token Mismanagement & Secret Exposure",
|
|
12
|
+
},
|
|
13
|
+
"exec-injection": {
|
|
14
|
+
name: "ExecInjection",
|
|
15
|
+
description: "exec()/Function() call next to a template literal, possible command injection.",
|
|
16
|
+
owasp: "MCP05:2025 Command Injection & Execution",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const LEVEL = { high: "error", medium: "warning", info: "note" };
|
|
20
|
+
export function toSarif(findings, version) {
|
|
21
|
+
return {
|
|
22
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
23
|
+
version: "2.1.0",
|
|
24
|
+
runs: [
|
|
25
|
+
{
|
|
26
|
+
tool: {
|
|
27
|
+
driver: {
|
|
28
|
+
name: "mcp-ssrf-scan",
|
|
29
|
+
version,
|
|
30
|
+
informationUri: "https://gitlab.com/florin-builds/mcp-ssrf-scan",
|
|
31
|
+
rules: Object.keys(RULES).map((id) => ({
|
|
32
|
+
id,
|
|
33
|
+
name: RULES[id].name,
|
|
34
|
+
shortDescription: { text: RULES[id].description },
|
|
35
|
+
properties: RULES[id].owasp ? { tags: ["security", RULES[id].owasp] } : { tags: ["security"] },
|
|
36
|
+
})),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
results: findings.map((f) => ({
|
|
40
|
+
ruleId: f.rule,
|
|
41
|
+
level: LEVEL[f.severity],
|
|
42
|
+
message: { text: f.message },
|
|
43
|
+
locations: [
|
|
44
|
+
{
|
|
45
|
+
physicalLocation: {
|
|
46
|
+
artifactLocation: { uri: f.file },
|
|
47
|
+
region: { startLine: f.line, snippet: { text: f.snippet } },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
})),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sms-florin/mcp-ssrf-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|