@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 +101 -2
- package/dist/mcp-reporter.d.ts +2 -0
- package/dist/mcp-reporter.js +59 -0
- package/dist/mcp-scanner.d.ts +23 -0
- package/dist/mcp-scanner.js +165 -0
- package/package.json +5 -3
- package/src/cli.ts +113 -2
- package/src/mcp-reporter.ts +78 -0
- package/src/mcp-scanner.ts +186 -0
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
|
-
|
|
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,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}}-${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.
|
|
4
|
-
"description": "Security scanner for AI agents.
|
|
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
|
-
|
|
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}}-${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
|
+
}
|