@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 +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/dist/scanner.js +13 -32
- 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/src/scanner.ts +13 -34
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/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
|
-
|
|
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
|
|
79
|
-
if (/\bagent\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
|
|
83
|
-
if (/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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
|
+
}
|
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
|
-
|
|
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
|
|
106
|
-
if (/\bagent\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
|
|
111
|
-
if (/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
274
|
+
scannedFiles: files.length,
|
|
296
275
|
};
|
|
297
276
|
}
|