@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 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 main() {
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 target = args.find((a) => !a.startsWith("-")) ?? ".";
43
- console.log(`${COLOR.bold}mcp-ssrf-scan${COLOR.reset} — scanning ${target}\n`);
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
- if (findings.length === 0) {
46
- console.log("No findings. (Heuristic scan — this is not a guarantee, see --help.)");
47
- process.exit(0);
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
- 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`);
135
+ else if (asJson) {
136
+ console.log(JSON.stringify({ target, findings }, null, 2));
55
137
  }
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");
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();
@@ -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.1.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": {