@authora/agent-audit 0.1.1 → 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/dist/scanner.js CHANGED
@@ -54,33 +54,18 @@ export async function scanDirectory(dir) {
54
54
  let hasAuditLog = false;
55
55
  let hasApprovals = false;
56
56
  const files = walkDir(dir);
57
- // Skip non-backend files (frontend components, tests, docs, configs)
58
- const SKIP_PATTERNS = [
59
- /\.(test|spec|stories)\.[tj]sx?$/,
60
- /\/__(tests|mocks|fixtures)__\//,
61
- /\/(web|ui|frontend|app)\/src\/(components|pages|hooks|stores|lib)\//,
62
- /\.d\.ts$/,
63
- /\.(md|mdx|txt|svg|css|scss|html)$/,
64
- /package\.json$/,
65
- /package-lock\.json$/,
66
- /tsconfig.*\.json$/,
67
- ];
68
- const backendFiles = files.filter((f) => {
69
- const rel = f.replace(dir + "/", "");
70
- return !SKIP_PATTERNS.some((p) => p.test(rel));
71
- });
72
- for (const file of backendFiles) {
57
+ for (const file of files) {
73
58
  const content = readFile(file);
74
59
  if (!content)
75
60
  continue;
76
61
  const lower = content.toLowerCase();
77
62
  const relPath = file.replace(dir + "/", "");
78
- // Detect agents -- only in backend code with real agent patterns
79
- if (/\bagent\b/i.test(content) && (/\bcreateAgent|AgentConfig|new.*Agent\(|runAgent|agentWorkflow\b/i.test(content))) {
63
+ // Detect agents
64
+ if (/\bagent\b/i.test(content) && (/\bcreate.*agent|agent.*config|new.*agent|agent.*class\b/i.test(content))) {
80
65
  agents++;
81
66
  }
82
- // Detect MCP servers -- only files that actually create/configure MCP servers
83
- if (/new.*Server|createServer|McpServer|startServer/i.test(content) && /mcp|model.*context.*protocol/i.test(lower)) {
67
+ // Detect MCP servers
68
+ if (/mcp.*server|mcpserver|model.*context.*protocol/i.test(lower)) {
84
69
  mcpServers++;
85
70
  }
86
71
  // Check for identity layer
@@ -135,30 +120,27 @@ export async function scanDirectory(dir) {
135
120
  }
136
121
  }
137
122
  // --- WARNING: MCP server without auth ---
138
- // Only flag files that actually create/start MCP servers, not type defs or configs
139
- if (/new.*Server|createServer|McpServer/i.test(content) && /mcp/i.test(lower) && !/auth|authentication|authorization|token|apiKey|signature/i.test(content)) {
123
+ if (/mcp.*server|createServer/i.test(content) && !/auth|authentication|authorization|token|apiKey/i.test(content)) {
140
124
  findings.push({
141
125
  severity: "warning",
142
126
  category: "mcp",
143
- message: "MCP server created without visible auth configuration",
127
+ message: "MCP server detected without visible auth configuration",
144
128
  file: relPath,
145
129
  fix: "Add authentication to your MCP server (API key, JWT, or Ed25519 signature verification)",
146
130
  });
147
131
  }
148
132
  // --- WARNING: Broad agent permissions ---
149
- // Only flag if it's clearly giving an agent admin/wildcard access
150
- if (/permissions.*\[.*["']\*["']|role.*["']admin["']|full.*access.*agent/i.test(content)) {
133
+ if (/\*.*permission|admin.*role|full.*access|sudo|root/i.test(content) && /agent/i.test(content)) {
151
134
  findings.push({
152
135
  severity: "warning",
153
136
  category: "permissions",
154
- message: "Agent configured with overly broad permissions",
137
+ message: "Agent may have overly broad permissions",
155
138
  file: relPath,
156
139
  fix: "Apply least-privilege: give agents only the permissions they need for their specific task",
157
140
  });
158
141
  }
159
142
  // --- WARNING: No error handling on tool calls ---
160
- // Only flag actual tool execution code, not type definitions or prompts
161
- if (/callTool\(|executeTool\(|tool\.execute\(/i.test(content) && !/try\s*\{|\.catch\(/i.test(content)) {
143
+ if (/tool.*call|callTool|execute.*tool/i.test(content) && !/try|catch|error/i.test(content)) {
162
144
  findings.push({
163
145
  severity: "warning",
164
146
  category: "resilience",
@@ -168,12 +150,11 @@ export async function scanDirectory(dir) {
168
150
  });
169
151
  }
170
152
  // --- INFO: Agent without timeout ---
171
- // Only flag files with actual agent execution logic (not frontend, not configs)
172
- if (/runAgent|agentWorkflow|executeAgent|startAgent/i.test(content) && /async|await/i.test(content) && !/timeout|AbortSignal|signal/i.test(content)) {
153
+ if (/agent/i.test(content) && /async|await|fetch|request/i.test(content) && !/timeout|AbortSignal|signal/i.test(content)) {
173
154
  findings.push({
174
155
  severity: "info",
175
156
  category: "resilience",
176
- message: "Agent execution without timeout -- could run indefinitely",
157
+ message: "Agent operations without timeout -- could run indefinitely",
177
158
  file: relPath,
178
159
  fix: "Add timeouts to agent operations to prevent runaway processes",
179
160
  });
@@ -248,6 +229,6 @@ export async function scanDirectory(dir) {
248
229
  hasDelegation,
249
230
  hasAuditLog,
250
231
  hasApprovals,
251
- scannedFiles: backendFiles.length,
232
+ scannedFiles: files.length,
252
233
  };
253
234
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@authora/agent-audit",
3
- "version": "0.1.1",
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
+ }
package/src/scanner.ts CHANGED
@@ -79,36 +79,19 @@ export async function scanDirectory(dir: string): Promise<ScanResult> {
79
79
 
80
80
  const files = walkDir(dir);
81
81
 
82
- // Skip non-backend files (frontend components, tests, docs, configs)
83
- const SKIP_PATTERNS = [
84
- /\.(test|spec|stories)\.[tj]sx?$/,
85
- /\/__(tests|mocks|fixtures)__\//,
86
- /\/(web|ui|frontend|app)\/src\/(components|pages|hooks|stores|lib)\//,
87
- /\.d\.ts$/,
88
- /\.(md|mdx|txt|svg|css|scss|html)$/,
89
- /package\.json$/,
90
- /package-lock\.json$/,
91
- /tsconfig.*\.json$/,
92
- ];
93
-
94
- const backendFiles = files.filter((f) => {
95
- const rel = f.replace(dir + "/", "");
96
- return !SKIP_PATTERNS.some((p) => p.test(rel));
97
- });
98
-
99
- for (const file of backendFiles) {
82
+ for (const file of files) {
100
83
  const content = readFile(file);
101
84
  if (!content) continue;
102
85
  const lower = content.toLowerCase();
103
86
  const relPath = file.replace(dir + "/", "");
104
87
 
105
- // Detect agents -- only in backend code with real agent patterns
106
- if (/\bagent\b/i.test(content) && (/\bcreateAgent|AgentConfig|new.*Agent\(|runAgent|agentWorkflow\b/i.test(content))) {
88
+ // Detect agents
89
+ if (/\bagent\b/i.test(content) && (/\bcreate.*agent|agent.*config|new.*agent|agent.*class\b/i.test(content))) {
107
90
  agents++;
108
91
  }
109
92
 
110
- // Detect MCP servers -- only files that actually create/configure MCP servers
111
- if (/new.*Server|createServer|McpServer|startServer/i.test(content) && /mcp|model.*context.*protocol/i.test(lower)) {
93
+ // Detect MCP servers
94
+ if (/mcp.*server|mcpserver|model.*context.*protocol/i.test(lower)) {
112
95
  mcpServers++;
113
96
  }
114
97
 
@@ -170,32 +153,29 @@ export async function scanDirectory(dir: string): Promise<ScanResult> {
170
153
  }
171
154
 
172
155
  // --- WARNING: MCP server without auth ---
173
- // Only flag files that actually create/start MCP servers, not type defs or configs
174
- if (/new.*Server|createServer|McpServer/i.test(content) && /mcp/i.test(lower) && !/auth|authentication|authorization|token|apiKey|signature/i.test(content)) {
156
+ if (/mcp.*server|createServer/i.test(content) && !/auth|authentication|authorization|token|apiKey/i.test(content)) {
175
157
  findings.push({
176
158
  severity: "warning",
177
159
  category: "mcp",
178
- message: "MCP server created without visible auth configuration",
160
+ message: "MCP server detected without visible auth configuration",
179
161
  file: relPath,
180
162
  fix: "Add authentication to your MCP server (API key, JWT, or Ed25519 signature verification)",
181
163
  });
182
164
  }
183
165
 
184
166
  // --- WARNING: Broad agent permissions ---
185
- // Only flag if it's clearly giving an agent admin/wildcard access
186
- if (/permissions.*\[.*["']\*["']|role.*["']admin["']|full.*access.*agent/i.test(content)) {
167
+ if (/\*.*permission|admin.*role|full.*access|sudo|root/i.test(content) && /agent/i.test(content)) {
187
168
  findings.push({
188
169
  severity: "warning",
189
170
  category: "permissions",
190
- message: "Agent configured with overly broad permissions",
171
+ message: "Agent may have overly broad permissions",
191
172
  file: relPath,
192
173
  fix: "Apply least-privilege: give agents only the permissions they need for their specific task",
193
174
  });
194
175
  }
195
176
 
196
177
  // --- WARNING: No error handling on tool calls ---
197
- // Only flag actual tool execution code, not type definitions or prompts
198
- if (/callTool\(|executeTool\(|tool\.execute\(/i.test(content) && !/try\s*\{|\.catch\(/i.test(content)) {
178
+ if (/tool.*call|callTool|execute.*tool/i.test(content) && !/try|catch|error/i.test(content)) {
199
179
  findings.push({
200
180
  severity: "warning",
201
181
  category: "resilience",
@@ -206,12 +186,11 @@ export async function scanDirectory(dir: string): Promise<ScanResult> {
206
186
  }
207
187
 
208
188
  // --- INFO: Agent without timeout ---
209
- // Only flag files with actual agent execution logic (not frontend, not configs)
210
- if (/runAgent|agentWorkflow|executeAgent|startAgent/i.test(content) && /async|await/i.test(content) && !/timeout|AbortSignal|signal/i.test(content)) {
189
+ if (/agent/i.test(content) && /async|await|fetch|request/i.test(content) && !/timeout|AbortSignal|signal/i.test(content)) {
211
190
  findings.push({
212
191
  severity: "info",
213
192
  category: "resilience",
214
- message: "Agent execution without timeout -- could run indefinitely",
193
+ message: "Agent operations without timeout -- could run indefinitely",
215
194
  file: relPath,
216
195
  fix: "Add timeouts to agent operations to prevent runaway processes",
217
196
  });
@@ -292,6 +271,6 @@ export async function scanDirectory(dir: string): Promise<ScanResult> {
292
271
  hasDelegation,
293
272
  hasAuditLog,
294
273
  hasApprovals,
295
- scannedFiles: backendFiles.length,
274
+ scannedFiles: files.length,
296
275
  };
297
276
  }