@authora/agent-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,11 +1,101 @@
1
1
  #!/usr/bin/env node
2
2
  import { scanDirectory } from "./scanner.js";
3
3
  import { formatReport } from "./reporter.js";
4
+ import { scanMcpServer } from "./mcp-scanner.js";
5
+ import { formatMcpReport } from "./mcp-reporter.js";
4
6
  const args = process.argv.slice(2);
5
- const targetDir = args[0] ?? ".";
6
7
  const jsonOutput = args.includes("--json");
7
8
  const badgeOutput = args.includes("--badge");
8
- async function main() {
9
+ function getFlag(flag) {
10
+ const idx = args.indexOf(flag);
11
+ return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined;
12
+ }
13
+ // -- MCP subcommand -----------------------------------------------------------
14
+ async function mcpMain() {
15
+ const positionals = args.filter((a) => !a.startsWith("--") && a !== "mcp");
16
+ // Also grab positional after a --flag value
17
+ let url = positionals[0];
18
+ if (!url || args.includes("--help")) {
19
+ console.log(`
20
+ \x1b[1m@authora/agent-audit mcp\x1b[0m -- MCP Server Security Scanner
21
+
22
+ \x1b[1mUsage:\x1b[0m
23
+ npx @authora/agent-audit mcp <url> [options]
24
+
25
+ \x1b[1mOptions:\x1b[0m
26
+ --api-key <key> API key (sent as api-key header)
27
+ --bearer <token> Bearer token (sent as Authorization header)
28
+ --json Output JSON for CI pipelines
29
+ --fail-below <grade> Exit 1 if grade is below threshold (A+, A, B+, B, C, D)
30
+ --help Show this help
31
+
32
+ \x1b[1mExamples:\x1b[0m
33
+ npx @authora/agent-audit mcp https://mcp.authora.dev --api-key authora_live_xxx
34
+ npx @authora/agent-audit mcp https://my-server.com --bearer sk-xxx --json
35
+ npx @authora/agent-audit mcp https://my-server.com --fail-below B
36
+
37
+ \x1b[90mBy Authora -- https://authora.dev\x1b[0m
38
+ `);
39
+ process.exit(0);
40
+ }
41
+ if (!url.startsWith("http"))
42
+ url = `https://${url}`;
43
+ const headers = {};
44
+ const apiKey = getFlag("--api-key");
45
+ const bearer = getFlag("--bearer");
46
+ const authenticated = !!(apiKey || bearer);
47
+ if (apiKey)
48
+ headers["api-key"] = apiKey;
49
+ if (bearer)
50
+ headers["Authorization"] = `Bearer ${bearer}`;
51
+ if (!jsonOutput) {
52
+ process.stdout.write(` \x1b[90mScanning ${url} ...\x1b[0m`);
53
+ }
54
+ const result = await scanMcpServer(url, headers, authenticated);
55
+ if (jsonOutput) {
56
+ process.stdout.write("\r" + " ".repeat(60) + "\r");
57
+ console.log(JSON.stringify(result, null, 2));
58
+ }
59
+ else {
60
+ process.stdout.write("\r" + " ".repeat(60) + "\r");
61
+ console.log(formatMcpReport(result));
62
+ }
63
+ // CI gate
64
+ const failBelow = getFlag("--fail-below");
65
+ if (failBelow) {
66
+ const threshold = failBelow.toUpperCase();
67
+ const order = ["F", "D", "C", "B", "B+", "A", "A+"];
68
+ const resultIdx = order.indexOf(result.letter);
69
+ const thresholdIdx = order.indexOf(threshold);
70
+ if (resultIdx >= 0 && thresholdIdx >= 0 && resultIdx < thresholdIdx) {
71
+ if (!jsonOutput) {
72
+ console.log(` \x1b[31m\x1b[1mFAILED:\x1b[0m Grade ${result.letter} is below threshold ${threshold}`);
73
+ console.log();
74
+ }
75
+ process.exit(1);
76
+ }
77
+ }
78
+ }
79
+ // -- Local scan (original) ----------------------------------------------------
80
+ async function localMain() {
81
+ const targetDir = args.filter((a) => !a.startsWith("--"))[0] ?? ".";
82
+ if (args.includes("--help")) {
83
+ console.log(`
84
+ \x1b[1m@authora/agent-audit\x1b[0m -- Agent Security Scanner
85
+
86
+ \x1b[1mUsage:\x1b[0m
87
+ npx @authora/agent-audit [directory] Scan local codebase
88
+ npx @authora/agent-audit mcp <url> Scan remote MCP server
89
+
90
+ \x1b[1mOptions:\x1b[0m
91
+ --json Output JSON
92
+ --badge Show README badge
93
+ --help Show this help
94
+
95
+ \x1b[90mBy Authora -- https://authora.dev\x1b[0m
96
+ `);
97
+ process.exit(0);
98
+ }
9
99
  if (!jsonOutput) {
10
100
  console.log("");
11
101
  console.log(" \x1b[1m\x1b[36mAgent Security Audit\x1b[0m");
@@ -31,6 +121,15 @@ async function main() {
31
121
  }
32
122
  process.exit(findings.score >= 6 ? 0 : 1);
33
123
  }
124
+ // -- Router -------------------------------------------------------------------
125
+ async function main() {
126
+ if (args[0] === "mcp") {
127
+ await mcpMain();
128
+ }
129
+ else {
130
+ await localMain();
131
+ }
132
+ }
34
133
  main().catch((err) => {
35
134
  console.error(" \x1b[31mError:\x1b[0m", err.message);
36
135
  process.exit(2);
@@ -0,0 +1,2 @@
1
+ import type { McpAuditResult } from "./mcp-scanner.js";
2
+ export declare function formatMcpReport(result: McpAuditResult): string;
@@ -0,0 +1,59 @@
1
+ const c = {
2
+ reset: "\x1b[0m",
3
+ bold: "\x1b[1m",
4
+ dim: "\x1b[90m",
5
+ red: "\x1b[31m",
6
+ green: "\x1b[32m",
7
+ yellow: "\x1b[33m",
8
+ cyan: "\x1b[36m",
9
+ };
10
+ const gradeColors = {
11
+ "A+": c.green, A: c.green,
12
+ "B+": c.green, B: c.green,
13
+ C: c.yellow,
14
+ D: c.red,
15
+ F: c.red,
16
+ };
17
+ export function formatMcpReport(result) {
18
+ const gc = gradeColors[result.letter] ?? c.reset;
19
+ const lines = [];
20
+ lines.push("");
21
+ lines.push(` ${c.bold}MCP Security Audit${c.reset} ${c.dim}via ${result.method}${c.reset}`);
22
+ lines.push(` ${c.dim}${"─".repeat(50)}${c.reset}`);
23
+ lines.push("");
24
+ lines.push(` ${gc}${c.bold} ${result.letter} ${c.reset} ${c.dim}Grade${c.reset} ${c.bold}${result.score}${c.reset}${c.dim}/100${c.reset} ${result.authenticated ? `${c.green}AUTH${c.reset}` : `${c.red}NO AUTH${c.reset}`}`);
25
+ lines.push("");
26
+ lines.push(` ${c.bold}${result.total}${c.reset} tools ${c.green}${result.safe}${c.reset} safe ${c.yellow}${result.review}${c.reset} review ${c.red}${result.dangerous}${c.reset} dangerous`);
27
+ lines.push("");
28
+ // Dangerous tools
29
+ const dangerTools = result.tools.filter((t) => t.level === "danger");
30
+ if (dangerTools.length > 0) {
31
+ lines.push(` ${c.red}${c.bold}Dangerous tools:${c.reset}`);
32
+ for (const t of dangerTools) {
33
+ lines.push(` ${c.red}*${c.reset} ${c.bold}${t.name}${c.reset}${t.description ? c.dim + " -- " + t.description.slice(0, 60) + c.reset : ""}`);
34
+ }
35
+ lines.push("");
36
+ }
37
+ // Review tools (collapsed)
38
+ const reviewTools = result.tools.filter((t) => t.level === "warn");
39
+ if (reviewTools.length > 0) {
40
+ const show = reviewTools.slice(0, 5);
41
+ const remaining = reviewTools.length - show.length;
42
+ lines.push(` ${c.yellow}${c.bold}Needs review:${c.reset}`);
43
+ for (const t of show) {
44
+ lines.push(` ${c.yellow}*${c.reset} ${t.name}${t.description ? c.dim + " -- " + t.description.slice(0, 60) + c.reset : ""}`);
45
+ }
46
+ if (remaining > 0) {
47
+ lines.push(` ${c.dim} ... and ${remaining} more${c.reset}`);
48
+ }
49
+ lines.push("");
50
+ }
51
+ // Badge
52
+ const badgeColor = result.score >= 70 ? "brightgreen" : result.score >= 50 ? "yellow" : "red";
53
+ lines.push(` ${c.dim}README badge:${c.reset}`);
54
+ lines.push(` ${c.dim}![MCP Security: ${result.letter}](https://img.shields.io/badge/MCP_Security-${encodeURIComponent(result.letter)}-${badgeColor})${c.reset}`);
55
+ lines.push("");
56
+ lines.push(` ${c.dim}Web inspector: https://mcp.authora.dev/inspect${c.reset}`);
57
+ lines.push("");
58
+ return lines.join("\n");
59
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * MCP Server Security Scanner
3
+ *
4
+ * Connects to a remote MCP server, discovers tools,
5
+ * classifies them by risk level, and produces a security grade.
6
+ */
7
+ export interface McpTool {
8
+ name: string;
9
+ description: string;
10
+ level: "safe" | "warn" | "danger";
11
+ }
12
+ export interface McpAuditResult {
13
+ tools: McpTool[];
14
+ total: number;
15
+ safe: number;
16
+ review: number;
17
+ dangerous: number;
18
+ score: number;
19
+ letter: string;
20
+ method: string;
21
+ authenticated: boolean;
22
+ }
23
+ export declare function scanMcpServer(url: string, headers: Record<string, string>, authenticated: boolean): Promise<McpAuditResult>;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * MCP Server Security Scanner
3
+ *
4
+ * Connects to a remote MCP server, discovers tools,
5
+ * classifies them by risk level, and produces a security grade.
6
+ */
7
+ // Dangerous: destructive or privilege-escalating operations
8
+ const DANGEROUS = [
9
+ "delete", "remove", "drop", "exec", "execute", "shell", "command",
10
+ "run_command", "kill", "terminate", "destroy", "purge", "wipe", "force",
11
+ "override", "revoke", "suspend", "deactivate", "disable", "deny", "reject",
12
+ "reset", "unregister", "detach", "disconnect",
13
+ ];
14
+ // Safe: read-only, observability, and query operations
15
+ const SAFE = [
16
+ "read", "get", "list", "search", "query", "fetch", "check", "verify",
17
+ "status", "health", "describe", "count", "view", "show", "inspect", "find",
18
+ "lookup", "validate", "export", "audit", "log", "monitor", "history",
19
+ "report", "resolve", "whoami", "me", "info", "version", "ping", "echo",
20
+ "help", "capabilities", "schema", "metadata", "stats", "summary",
21
+ "discover", "enumerate", "batch_get", "batch_list",
22
+ ];
23
+ // Review: mutations that change state but are not destructive
24
+ const REVIEW = [
25
+ "create", "write", "modify", "send", "deploy", "publish", "update",
26
+ "assign", "grant", "approve", "set", "add", "configure", "delegate",
27
+ "escalate", "notify", "alert", "rotate", "renew", "generate", "import",
28
+ "register", "trigger", "enable", "activate", "reactivate", "connect",
29
+ "attach", "invite", "enroll", "submit", "request", "initiate", "provision",
30
+ "emit", "push", "post", "put", "patch", "insert", "upsert", "merge",
31
+ "sync", "transfer", "move", "copy", "clone", "fork", "sign", "issue",
32
+ "mint", "encode", "encrypt", "authorize", "consent", "accept",
33
+ "acknowledge", "confirm", "start", "stop", "pause", "resume", "schedule",
34
+ "cancel", "retry", "replay", "release", "promote", "demote", "tag",
35
+ "label", "annotate", "comment", "flag", "pin", "bookmark", "subscribe",
36
+ "unsubscribe", "follow", "unfollow", "mute", "unmute", "archive", "restore",
37
+ ];
38
+ function wordBoundaryMatch(text, word) {
39
+ return new RegExp(`(^|[_\\-\\s.])${word}([_\\-\\s.]|$)`).test(text);
40
+ }
41
+ function classifyTool(name, description) {
42
+ const text = `${name} ${description ?? ""}`.toLowerCase();
43
+ for (const kw of DANGEROUS) {
44
+ if (wordBoundaryMatch(text, kw))
45
+ return "danger";
46
+ }
47
+ for (const kw of SAFE) {
48
+ if (wordBoundaryMatch(text, kw))
49
+ return "safe";
50
+ }
51
+ for (const kw of REVIEW) {
52
+ if (wordBoundaryMatch(text, kw))
53
+ return "warn";
54
+ }
55
+ return "warn";
56
+ }
57
+ function gradeLetter(score) {
58
+ if (score >= 90)
59
+ return "A+";
60
+ if (score >= 80)
61
+ return "A";
62
+ if (score >= 70)
63
+ return "B+";
64
+ if (score >= 60)
65
+ return "B";
66
+ if (score >= 50)
67
+ return "C";
68
+ if (score >= 30)
69
+ return "D";
70
+ return "F";
71
+ }
72
+ export async function scanMcpServer(url, headers, authenticated) {
73
+ const base = url.replace(/\/sse\/?$/, "").replace(/\/$/, "");
74
+ let rawTools = [];
75
+ let method = "";
76
+ // Try REST /tools
77
+ try {
78
+ const res = await fetch(`${base}/tools`, {
79
+ headers,
80
+ signal: AbortSignal.timeout(15000),
81
+ });
82
+ if (res.ok) {
83
+ const data = (await res.json());
84
+ rawTools = data.tools ?? [];
85
+ method = "REST /tools";
86
+ }
87
+ else if (res.status === 401) {
88
+ throw new Error("Server returned 401 Unauthorized. Provide --api-key or --bearer.");
89
+ }
90
+ }
91
+ catch (e) {
92
+ if (e.message.includes("401"))
93
+ throw e;
94
+ }
95
+ // Fallback: JSON-RPC tools/list
96
+ if (rawTools.length === 0) {
97
+ try {
98
+ const res = await fetch(url, {
99
+ method: "POST",
100
+ headers: { ...headers, "Content-Type": "application/json" },
101
+ body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }),
102
+ signal: AbortSignal.timeout(15000),
103
+ });
104
+ if (res.ok) {
105
+ const data = (await res.json());
106
+ rawTools = data.result?.tools ?? data.tools ?? [];
107
+ method = "JSON-RPC";
108
+ }
109
+ }
110
+ catch {
111
+ // fall through
112
+ }
113
+ }
114
+ if (rawTools.length === 0) {
115
+ throw new Error("Could not retrieve tools. Check the URL and authentication.");
116
+ }
117
+ // Deduplicate by name
118
+ const seen = new Set();
119
+ const tools = [];
120
+ let safe = 0, review = 0, dangerous = 0;
121
+ for (const t of rawTools) {
122
+ if (seen.has(t.name))
123
+ continue;
124
+ seen.add(t.name);
125
+ const level = classifyTool(t.name, t.description);
126
+ if (level === "safe")
127
+ safe++;
128
+ else if (level === "warn")
129
+ review++;
130
+ else
131
+ dangerous++;
132
+ tools.push({ name: t.name, description: t.description ?? "", level });
133
+ }
134
+ // Sort: dangerous first, then review, then safe
135
+ tools.sort((a, b) => {
136
+ const order = { danger: 0, warn: 1, safe: 2 };
137
+ return order[a.level] - order[b.level];
138
+ });
139
+ // Score: ratio-based
140
+ const total = tools.length;
141
+ const safeRatio = safe / total;
142
+ const dangerRatio = dangerous / total;
143
+ let score = Math.round(safeRatio * 60 + (1 - dangerRatio) * 30);
144
+ if (authenticated)
145
+ score += 10;
146
+ if (dangerRatio > 0.3)
147
+ score -= 15;
148
+ else if (dangerRatio > 0.2)
149
+ score -= 5;
150
+ const undocDanger = tools.filter((t) => t.level === "danger" && !t.description).length;
151
+ if (dangerous > 0 && undocDanger / dangerous > 0.5)
152
+ score -= 10;
153
+ score = Math.max(0, Math.min(100, score));
154
+ return {
155
+ tools,
156
+ total,
157
+ safe,
158
+ review,
159
+ dangerous,
160
+ score,
161
+ letter: gradeLetter(score),
162
+ method,
163
+ authenticated,
164
+ };
165
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@authora/agent-audit",
3
- "version": "0.1.0",
4
- "description": "Security scanner for AI agents. Find vulnerabilities in your agent setup in 30 seconds.",
3
+ "version": "0.2.0",
4
+ "description": "Security scanner for AI agents and MCP servers. Scan local codebases or remote MCP servers in seconds.",
5
5
  "bin": {
6
6
  "agent-audit": "./dist/cli.js"
7
7
  },
@@ -15,9 +15,11 @@
15
15
  "security",
16
16
  "audit",
17
17
  "mcp",
18
+ "model-context-protocol",
18
19
  "agent-identity",
19
20
  "llm-security",
20
- "ai-security"
21
+ "ai-security",
22
+ "mcp-security"
21
23
  ],
22
24
  "author": "Authora <admin@authora.dev> (https://authora.dev)",
23
25
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -2,13 +2,114 @@
2
2
 
3
3
  import { scanDirectory } from "./scanner.js";
4
4
  import { formatReport } from "./reporter.js";
5
+ import { scanMcpServer } from "./mcp-scanner.js";
6
+ import { formatMcpReport } from "./mcp-reporter.js";
5
7
 
6
8
  const args = process.argv.slice(2);
7
- const targetDir = args[0] ?? ".";
8
9
  const jsonOutput = args.includes("--json");
9
10
  const badgeOutput = args.includes("--badge");
10
11
 
11
- async function main() {
12
+ function getFlag(flag: string): string | undefined {
13
+ const idx = args.indexOf(flag);
14
+ return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined;
15
+ }
16
+
17
+ // -- MCP subcommand -----------------------------------------------------------
18
+
19
+ async function mcpMain() {
20
+ const positionals = args.filter((a) => !a.startsWith("--") && a !== "mcp");
21
+ // Also grab positional after a --flag value
22
+ let url = positionals[0];
23
+
24
+ if (!url || args.includes("--help")) {
25
+ console.log(`
26
+ \x1b[1m@authora/agent-audit mcp\x1b[0m -- MCP Server Security Scanner
27
+
28
+ \x1b[1mUsage:\x1b[0m
29
+ npx @authora/agent-audit mcp <url> [options]
30
+
31
+ \x1b[1mOptions:\x1b[0m
32
+ --api-key <key> API key (sent as api-key header)
33
+ --bearer <token> Bearer token (sent as Authorization header)
34
+ --json Output JSON for CI pipelines
35
+ --fail-below <grade> Exit 1 if grade is below threshold (A+, A, B+, B, C, D)
36
+ --help Show this help
37
+
38
+ \x1b[1mExamples:\x1b[0m
39
+ npx @authora/agent-audit mcp https://mcp.authora.dev --api-key authora_live_xxx
40
+ npx @authora/agent-audit mcp https://my-server.com --bearer sk-xxx --json
41
+ npx @authora/agent-audit mcp https://my-server.com --fail-below B
42
+
43
+ \x1b[90mBy Authora -- https://authora.dev\x1b[0m
44
+ `);
45
+ process.exit(0);
46
+ }
47
+
48
+ if (!url.startsWith("http")) url = `https://${url}`;
49
+
50
+ const headers: Record<string, string> = {};
51
+ const apiKey = getFlag("--api-key");
52
+ const bearer = getFlag("--bearer");
53
+ const authenticated = !!(apiKey || bearer);
54
+ if (apiKey) headers["api-key"] = apiKey;
55
+ if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
56
+
57
+ if (!jsonOutput) {
58
+ process.stdout.write(` \x1b[90mScanning ${url} ...\x1b[0m`);
59
+ }
60
+
61
+ const result = await scanMcpServer(url, headers, authenticated);
62
+
63
+ if (jsonOutput) {
64
+ process.stdout.write("\r" + " ".repeat(60) + "\r");
65
+ console.log(JSON.stringify(result, null, 2));
66
+ } else {
67
+ process.stdout.write("\r" + " ".repeat(60) + "\r");
68
+ console.log(formatMcpReport(result));
69
+ }
70
+
71
+ // CI gate
72
+ const failBelow = getFlag("--fail-below");
73
+ if (failBelow) {
74
+ const threshold = failBelow.toUpperCase();
75
+ const order = ["F", "D", "C", "B", "B+", "A", "A+"];
76
+ const resultIdx = order.indexOf(result.letter);
77
+ const thresholdIdx = order.indexOf(threshold);
78
+ if (resultIdx >= 0 && thresholdIdx >= 0 && resultIdx < thresholdIdx) {
79
+ if (!jsonOutput) {
80
+ console.log(
81
+ ` \x1b[31m\x1b[1mFAILED:\x1b[0m Grade ${result.letter} is below threshold ${threshold}`,
82
+ );
83
+ console.log();
84
+ }
85
+ process.exit(1);
86
+ }
87
+ }
88
+ }
89
+
90
+ // -- Local scan (original) ----------------------------------------------------
91
+
92
+ async function localMain() {
93
+ const targetDir = args.filter((a) => !a.startsWith("--"))[0] ?? ".";
94
+
95
+ if (args.includes("--help")) {
96
+ console.log(`
97
+ \x1b[1m@authora/agent-audit\x1b[0m -- Agent Security Scanner
98
+
99
+ \x1b[1mUsage:\x1b[0m
100
+ npx @authora/agent-audit [directory] Scan local codebase
101
+ npx @authora/agent-audit mcp <url> Scan remote MCP server
102
+
103
+ \x1b[1mOptions:\x1b[0m
104
+ --json Output JSON
105
+ --badge Show README badge
106
+ --help Show this help
107
+
108
+ \x1b[90mBy Authora -- https://authora.dev\x1b[0m
109
+ `);
110
+ process.exit(0);
111
+ }
112
+
12
113
  if (!jsonOutput) {
13
114
  console.log("");
14
115
  console.log(" \x1b[1m\x1b[36mAgent Security Audit\x1b[0m");
@@ -40,6 +141,16 @@ async function main() {
40
141
  process.exit(findings.score >= 6 ? 0 : 1);
41
142
  }
42
143
 
144
+ // -- Router -------------------------------------------------------------------
145
+
146
+ async function main() {
147
+ if (args[0] === "mcp") {
148
+ await mcpMain();
149
+ } else {
150
+ await localMain();
151
+ }
152
+ }
153
+
43
154
  main().catch((err) => {
44
155
  console.error(" \x1b[31mError:\x1b[0m", (err as Error).message);
45
156
  process.exit(2);
@@ -0,0 +1,78 @@
1
+ import type { McpAuditResult } from "./mcp-scanner.js";
2
+
3
+ const c = {
4
+ reset: "\x1b[0m",
5
+ bold: "\x1b[1m",
6
+ dim: "\x1b[90m",
7
+ red: "\x1b[31m",
8
+ green: "\x1b[32m",
9
+ yellow: "\x1b[33m",
10
+ cyan: "\x1b[36m",
11
+ };
12
+
13
+ const gradeColors: Record<string, string> = {
14
+ "A+": c.green, A: c.green,
15
+ "B+": c.green, B: c.green,
16
+ C: c.yellow,
17
+ D: c.red,
18
+ F: c.red,
19
+ };
20
+
21
+ export function formatMcpReport(result: McpAuditResult): string {
22
+ const gc = gradeColors[result.letter] ?? c.reset;
23
+ const lines: string[] = [];
24
+
25
+ lines.push("");
26
+ lines.push(` ${c.bold}MCP Security Audit${c.reset} ${c.dim}via ${result.method}${c.reset}`);
27
+ lines.push(` ${c.dim}${"─".repeat(50)}${c.reset}`);
28
+ lines.push("");
29
+ lines.push(
30
+ ` ${gc}${c.bold} ${result.letter} ${c.reset} ${c.dim}Grade${c.reset} ${c.bold}${result.score}${c.reset}${c.dim}/100${c.reset} ${result.authenticated ? `${c.green}AUTH${c.reset}` : `${c.red}NO AUTH${c.reset}`}`,
31
+ );
32
+ lines.push("");
33
+ lines.push(
34
+ ` ${c.bold}${result.total}${c.reset} tools ${c.green}${result.safe}${c.reset} safe ${c.yellow}${result.review}${c.reset} review ${c.red}${result.dangerous}${c.reset} dangerous`,
35
+ );
36
+ lines.push("");
37
+
38
+ // Dangerous tools
39
+ const dangerTools = result.tools.filter((t) => t.level === "danger");
40
+ if (dangerTools.length > 0) {
41
+ lines.push(` ${c.red}${c.bold}Dangerous tools:${c.reset}`);
42
+ for (const t of dangerTools) {
43
+ lines.push(
44
+ ` ${c.red}*${c.reset} ${c.bold}${t.name}${c.reset}${t.description ? c.dim + " -- " + t.description.slice(0, 60) + c.reset : ""}`,
45
+ );
46
+ }
47
+ lines.push("");
48
+ }
49
+
50
+ // Review tools (collapsed)
51
+ const reviewTools = result.tools.filter((t) => t.level === "warn");
52
+ if (reviewTools.length > 0) {
53
+ const show = reviewTools.slice(0, 5);
54
+ const remaining = reviewTools.length - show.length;
55
+ lines.push(` ${c.yellow}${c.bold}Needs review:${c.reset}`);
56
+ for (const t of show) {
57
+ lines.push(
58
+ ` ${c.yellow}*${c.reset} ${t.name}${t.description ? c.dim + " -- " + t.description.slice(0, 60) + c.reset : ""}`,
59
+ );
60
+ }
61
+ if (remaining > 0) {
62
+ lines.push(` ${c.dim} ... and ${remaining} more${c.reset}`);
63
+ }
64
+ lines.push("");
65
+ }
66
+
67
+ // Badge
68
+ const badgeColor = result.score >= 70 ? "brightgreen" : result.score >= 50 ? "yellow" : "red";
69
+ lines.push(` ${c.dim}README badge:${c.reset}`);
70
+ lines.push(
71
+ ` ${c.dim}![MCP Security: ${result.letter}](https://img.shields.io/badge/MCP_Security-${encodeURIComponent(result.letter)}-${badgeColor})${c.reset}`,
72
+ );
73
+ lines.push("");
74
+ lines.push(` ${c.dim}Web inspector: https://mcp.authora.dev/inspect${c.reset}`);
75
+ lines.push("");
76
+
77
+ return lines.join("\n");
78
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * MCP Server Security Scanner
3
+ *
4
+ * Connects to a remote MCP server, discovers tools,
5
+ * classifies them by risk level, and produces a security grade.
6
+ */
7
+
8
+ export interface McpTool {
9
+ name: string;
10
+ description: string;
11
+ level: "safe" | "warn" | "danger";
12
+ }
13
+
14
+ export interface McpAuditResult {
15
+ tools: McpTool[];
16
+ total: number;
17
+ safe: number;
18
+ review: number;
19
+ dangerous: number;
20
+ score: number;
21
+ letter: string;
22
+ method: string;
23
+ authenticated: boolean;
24
+ }
25
+
26
+ // Dangerous: destructive or privilege-escalating operations
27
+ const DANGEROUS = [
28
+ "delete", "remove", "drop", "exec", "execute", "shell", "command",
29
+ "run_command", "kill", "terminate", "destroy", "purge", "wipe", "force",
30
+ "override", "revoke", "suspend", "deactivate", "disable", "deny", "reject",
31
+ "reset", "unregister", "detach", "disconnect",
32
+ ];
33
+
34
+ // Safe: read-only, observability, and query operations
35
+ const SAFE = [
36
+ "read", "get", "list", "search", "query", "fetch", "check", "verify",
37
+ "status", "health", "describe", "count", "view", "show", "inspect", "find",
38
+ "lookup", "validate", "export", "audit", "log", "monitor", "history",
39
+ "report", "resolve", "whoami", "me", "info", "version", "ping", "echo",
40
+ "help", "capabilities", "schema", "metadata", "stats", "summary",
41
+ "discover", "enumerate", "batch_get", "batch_list",
42
+ ];
43
+
44
+ // Review: mutations that change state but are not destructive
45
+ const REVIEW = [
46
+ "create", "write", "modify", "send", "deploy", "publish", "update",
47
+ "assign", "grant", "approve", "set", "add", "configure", "delegate",
48
+ "escalate", "notify", "alert", "rotate", "renew", "generate", "import",
49
+ "register", "trigger", "enable", "activate", "reactivate", "connect",
50
+ "attach", "invite", "enroll", "submit", "request", "initiate", "provision",
51
+ "emit", "push", "post", "put", "patch", "insert", "upsert", "merge",
52
+ "sync", "transfer", "move", "copy", "clone", "fork", "sign", "issue",
53
+ "mint", "encode", "encrypt", "authorize", "consent", "accept",
54
+ "acknowledge", "confirm", "start", "stop", "pause", "resume", "schedule",
55
+ "cancel", "retry", "replay", "release", "promote", "demote", "tag",
56
+ "label", "annotate", "comment", "flag", "pin", "bookmark", "subscribe",
57
+ "unsubscribe", "follow", "unfollow", "mute", "unmute", "archive", "restore",
58
+ ];
59
+
60
+ function wordBoundaryMatch(text: string, word: string): boolean {
61
+ return new RegExp(`(^|[_\\-\\s.])${word}([_\\-\\s.]|$)`).test(text);
62
+ }
63
+
64
+ function classifyTool(name: string, description?: string): "safe" | "warn" | "danger" {
65
+ const text = `${name} ${description ?? ""}`.toLowerCase();
66
+
67
+ for (const kw of DANGEROUS) {
68
+ if (wordBoundaryMatch(text, kw)) return "danger";
69
+ }
70
+ for (const kw of SAFE) {
71
+ if (wordBoundaryMatch(text, kw)) return "safe";
72
+ }
73
+ for (const kw of REVIEW) {
74
+ if (wordBoundaryMatch(text, kw)) return "warn";
75
+ }
76
+ return "warn";
77
+ }
78
+
79
+ function gradeLetter(score: number): string {
80
+ if (score >= 90) return "A+";
81
+ if (score >= 80) return "A";
82
+ if (score >= 70) return "B+";
83
+ if (score >= 60) return "B";
84
+ if (score >= 50) return "C";
85
+ if (score >= 30) return "D";
86
+ return "F";
87
+ }
88
+
89
+ export async function scanMcpServer(
90
+ url: string,
91
+ headers: Record<string, string>,
92
+ authenticated: boolean,
93
+ ): Promise<McpAuditResult> {
94
+ const base = url.replace(/\/sse\/?$/, "").replace(/\/$/, "");
95
+ let rawTools: Array<{ name: string; description?: string }> = [];
96
+ let method = "";
97
+
98
+ // Try REST /tools
99
+ try {
100
+ const res = await fetch(`${base}/tools`, {
101
+ headers,
102
+ signal: AbortSignal.timeout(15000),
103
+ });
104
+ if (res.ok) {
105
+ const data = (await res.json()) as any;
106
+ rawTools = data.tools ?? [];
107
+ method = "REST /tools";
108
+ } else if (res.status === 401) {
109
+ throw new Error(
110
+ "Server returned 401 Unauthorized. Provide --api-key or --bearer.",
111
+ );
112
+ }
113
+ } catch (e: any) {
114
+ if (e.message.includes("401")) throw e;
115
+ }
116
+
117
+ // Fallback: JSON-RPC tools/list
118
+ if (rawTools.length === 0) {
119
+ try {
120
+ const res = await fetch(url, {
121
+ method: "POST",
122
+ headers: { ...headers, "Content-Type": "application/json" },
123
+ body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }),
124
+ signal: AbortSignal.timeout(15000),
125
+ });
126
+ if (res.ok) {
127
+ const data = (await res.json()) as any;
128
+ rawTools = data.result?.tools ?? data.tools ?? [];
129
+ method = "JSON-RPC";
130
+ }
131
+ } catch {
132
+ // fall through
133
+ }
134
+ }
135
+
136
+ if (rawTools.length === 0) {
137
+ throw new Error(
138
+ "Could not retrieve tools. Check the URL and authentication.",
139
+ );
140
+ }
141
+
142
+ // Deduplicate by name
143
+ const seen = new Set<string>();
144
+ const tools: McpTool[] = [];
145
+ let safe = 0, review = 0, dangerous = 0;
146
+
147
+ for (const t of rawTools) {
148
+ if (seen.has(t.name)) continue;
149
+ seen.add(t.name);
150
+ const level = classifyTool(t.name, t.description);
151
+ if (level === "safe") safe++;
152
+ else if (level === "warn") review++;
153
+ else dangerous++;
154
+ tools.push({ name: t.name, description: t.description ?? "", level });
155
+ }
156
+
157
+ // Sort: dangerous first, then review, then safe
158
+ tools.sort((a, b) => {
159
+ const order = { danger: 0, warn: 1, safe: 2 };
160
+ return order[a.level] - order[b.level];
161
+ });
162
+
163
+ // Score: ratio-based
164
+ const total = tools.length;
165
+ const safeRatio = safe / total;
166
+ const dangerRatio = dangerous / total;
167
+ let score = Math.round(safeRatio * 60 + (1 - dangerRatio) * 30);
168
+ if (authenticated) score += 10;
169
+ if (dangerRatio > 0.3) score -= 15;
170
+ else if (dangerRatio > 0.2) score -= 5;
171
+ const undocDanger = tools.filter((t) => t.level === "danger" && !t.description).length;
172
+ if (dangerous > 0 && undocDanger / dangerous > 0.5) score -= 10;
173
+ score = Math.max(0, Math.min(100, score));
174
+
175
+ return {
176
+ tools,
177
+ total,
178
+ safe,
179
+ review,
180
+ dangerous,
181
+ score,
182
+ letter: gradeLetter(score),
183
+ method,
184
+ authenticated,
185
+ };
186
+ }