@baichen_yu/mcp-guard 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/RELEASE.md +21 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +257 -0
- package/dist/mcp/jsonrpc.d.ts +23 -0
- package/dist/mcp/jsonrpc.js +124 -0
- package/dist/mcp/transport_stdio.d.ts +10 -0
- package/dist/mcp/transport_stdio.js +69 -0
- package/dist/mcp/types.d.ts +75 -0
- package/dist/mcp/types.js +1 -0
- package/dist/registry/validator.d.ts +20 -0
- package/dist/registry/validator.js +98 -0
- package/dist/report/json.d.ts +1 -0
- package/dist/report/json.js +8 -0
- package/dist/report/markdown.d.ts +2 -0
- package/dist/report/markdown.js +61 -0
- package/dist/report/sarif.d.ts +2 -0
- package/dist/report/sarif.js +33 -0
- package/dist/scan/scanner.d.ts +16 -0
- package/dist/scan/scanner.js +86 -0
- package/dist/security/profiles.d.ts +6 -0
- package/dist/security/profiles.js +25 -0
- package/dist/security/rules/path_traversal.d.ts +2 -0
- package/dist/security/rules/path_traversal.js +24 -0
- package/dist/security/rules/raw_args.d.ts +2 -0
- package/dist/security/rules/raw_args.js +24 -0
- package/dist/security/rules/shell_injection.d.ts +2 -0
- package/dist/security/rules/shell_injection.js +23 -0
- package/dist/security/scorer.d.ts +5 -0
- package/dist/security/scorer.js +4 -0
- package/dist/tests/contract/call_tool.d.ts +2 -0
- package/dist/tests/contract/call_tool.js +14 -0
- package/dist/tests/contract/cancellation.d.ts +2 -0
- package/dist/tests/contract/cancellation.js +22 -0
- package/dist/tests/contract/error_shapes.d.ts +2 -0
- package/dist/tests/contract/error_shapes.js +25 -0
- package/dist/tests/contract/large_payload.d.ts +2 -0
- package/dist/tests/contract/large_payload.js +15 -0
- package/dist/tests/contract/list_tools.d.ts +5 -0
- package/dist/tests/contract/list_tools.js +14 -0
- package/dist/tests/contract/timeout.d.ts +2 -0
- package/dist/tests/contract/timeout.js +25 -0
- package/dist/validate/schema_lints.d.ts +2 -0
- package/dist/validate/schema_lints.js +65 -0
- package/docs/assets/demo.gif +1 -0
- package/docs/cli.md +38 -0
- package/docs/github-action.md +23 -0
- package/docs/index.md +75 -0
- package/docs/quickstart.md +34 -0
- package/docs/releasing.md +39 -0
- package/docs/rules.md +18 -0
- package/docs/security-model.md +14 -0
- package/package.json +64 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type JsonRpcId = number;
|
|
2
|
+
export interface JsonRpcRequest {
|
|
3
|
+
jsonrpc: '2.0';
|
|
4
|
+
id: JsonRpcId;
|
|
5
|
+
method: string;
|
|
6
|
+
params?: unknown;
|
|
7
|
+
}
|
|
8
|
+
export interface JsonRpcSuccess {
|
|
9
|
+
jsonrpc: '2.0';
|
|
10
|
+
id: JsonRpcId;
|
|
11
|
+
result: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface JsonRpcErrorShape {
|
|
14
|
+
code: number;
|
|
15
|
+
message: string;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface JsonRpcError {
|
|
19
|
+
jsonrpc: '2.0';
|
|
20
|
+
id: JsonRpcId;
|
|
21
|
+
error: JsonRpcErrorShape;
|
|
22
|
+
}
|
|
23
|
+
export type JsonRpcResponse = JsonRpcSuccess | JsonRpcError;
|
|
24
|
+
export interface ToolDescriptor {
|
|
25
|
+
name: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
inputSchema: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export interface ServerConfig {
|
|
30
|
+
stdioCommand?: string;
|
|
31
|
+
httpUrl?: string;
|
|
32
|
+
cwd?: string;
|
|
33
|
+
env?: Record<string, string>;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
silent?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export type Severity = 'low' | 'medium' | 'high';
|
|
38
|
+
export interface Finding {
|
|
39
|
+
severity: Severity;
|
|
40
|
+
ruleId: string;
|
|
41
|
+
message: string;
|
|
42
|
+
evidence: string;
|
|
43
|
+
remediation: string;
|
|
44
|
+
toolName?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface TestResult {
|
|
47
|
+
name: string;
|
|
48
|
+
passed: boolean;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
details?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface Report {
|
|
53
|
+
server: {
|
|
54
|
+
target: string;
|
|
55
|
+
transport: 'stdio' | 'http';
|
|
56
|
+
protocolVersion?: string;
|
|
57
|
+
};
|
|
58
|
+
tools: ToolDescriptor[];
|
|
59
|
+
findings: Finding[];
|
|
60
|
+
tests: TestResult[];
|
|
61
|
+
score: number;
|
|
62
|
+
scoreBreakdown: string[];
|
|
63
|
+
generatedAt: string;
|
|
64
|
+
}
|
|
65
|
+
export interface NormalizedError {
|
|
66
|
+
code: number;
|
|
67
|
+
message: string;
|
|
68
|
+
data?: unknown;
|
|
69
|
+
}
|
|
70
|
+
export interface RpcClient {
|
|
71
|
+
request<T>(method: string, params?: unknown, timeoutOverrideMs?: number): Promise<T>;
|
|
72
|
+
normalizeError(error: unknown): NormalizedError;
|
|
73
|
+
close(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
export type Profile = 'default' | 'strict' | 'paranoid';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface RegistryIssue {
|
|
2
|
+
severity: 'error' | 'warning';
|
|
3
|
+
message: string;
|
|
4
|
+
line: number;
|
|
5
|
+
}
|
|
6
|
+
export interface RegistryEntry {
|
|
7
|
+
name?: string;
|
|
8
|
+
repo?: string;
|
|
9
|
+
transport?: string;
|
|
10
|
+
install?: string;
|
|
11
|
+
run?: string;
|
|
12
|
+
tags?: string[];
|
|
13
|
+
permissions?: string[];
|
|
14
|
+
riskNotes?: string;
|
|
15
|
+
securityNotes?: string;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
export declare function parseRegistry(raw: string): RegistryEntry[];
|
|
19
|
+
export declare function lintRegistry(raw: string): RegistryIssue[];
|
|
20
|
+
export declare function verifyRegistry(raw: string, sample: number): RegistryIssue[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
|
+
function lineOf(raw, needle, fromLine = 1) {
|
|
3
|
+
const lines = raw.split('\n');
|
|
4
|
+
for (let i = Math.max(0, fromLine - 1); i < lines.length; i += 1) {
|
|
5
|
+
if (lines[i].includes(needle))
|
|
6
|
+
return i + 1;
|
|
7
|
+
}
|
|
8
|
+
return 1;
|
|
9
|
+
}
|
|
10
|
+
export function parseRegistry(raw) {
|
|
11
|
+
const parsed = yaml.load(raw);
|
|
12
|
+
return (parsed?.servers ?? []);
|
|
13
|
+
}
|
|
14
|
+
function validRepo(value) {
|
|
15
|
+
if (!value.includes(' ')) {
|
|
16
|
+
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
17
|
+
return /^https?:\/\/[^\s]+$/.test(value);
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
export function lintRegistry(raw) {
|
|
24
|
+
const entries = parseRegistry(raw);
|
|
25
|
+
const issues = [];
|
|
26
|
+
const seenNames = new Set();
|
|
27
|
+
const allowedFields = new Set(['name', 'repo', 'transport', 'install', 'run', 'tags', 'permissions', 'riskNotes', 'securityNotes']);
|
|
28
|
+
entries.forEach((entry, index) => {
|
|
29
|
+
const anchor = `- name: ${entry.name ?? ''}`;
|
|
30
|
+
const baseLine = lineOf(raw, anchor);
|
|
31
|
+
const required = ['name', 'repo', 'transport', 'install', 'run', 'tags', 'permissions'];
|
|
32
|
+
for (const field of required) {
|
|
33
|
+
if (!entry[field] || (Array.isArray(entry[field]) && entry[field].length === 0)) {
|
|
34
|
+
issues.push({
|
|
35
|
+
severity: 'error',
|
|
36
|
+
message: `Entry #${index + 1} missing required field: ${field}`,
|
|
37
|
+
line: lineOf(raw, `${String(field)}:`, baseLine)
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (typeof entry.name === 'string') {
|
|
42
|
+
if (seenNames.has(entry.name)) {
|
|
43
|
+
issues.push({
|
|
44
|
+
severity: 'error',
|
|
45
|
+
message: `Duplicate server name: ${entry.name}`,
|
|
46
|
+
line: baseLine
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
seenNames.add(entry.name);
|
|
50
|
+
}
|
|
51
|
+
if (typeof entry.repo === 'string' && !validRepo(entry.repo)) {
|
|
52
|
+
issues.push({
|
|
53
|
+
severity: 'error',
|
|
54
|
+
message: `Entry #${index + 1} has invalid repo value: ${entry.repo}`,
|
|
55
|
+
line: lineOf(raw, 'repo:', baseLine)
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
for (const key of Object.keys(entry)) {
|
|
59
|
+
if (!allowedFields.has(key)) {
|
|
60
|
+
issues.push({
|
|
61
|
+
severity: 'warning',
|
|
62
|
+
message: `Entry #${index + 1} has unknown field: ${key}`,
|
|
63
|
+
line: lineOf(raw, `${key}:`, baseLine)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (entry.run && /(rm -rf|curl\s+.*\|\s*sh)/i.test(String(entry.run))) {
|
|
68
|
+
issues.push({
|
|
69
|
+
severity: 'warning',
|
|
70
|
+
message: `Entry #${index + 1} run command appears unsafe`,
|
|
71
|
+
line: lineOf(raw, 'run:', baseLine)
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return issues;
|
|
76
|
+
}
|
|
77
|
+
export function verifyRegistry(raw, sample) {
|
|
78
|
+
const entries = parseRegistry(raw).slice(0, sample);
|
|
79
|
+
const issues = [];
|
|
80
|
+
entries.forEach((entry, index) => {
|
|
81
|
+
const baseLine = lineOf(raw, `- name: ${entry.name ?? ''}`);
|
|
82
|
+
if (!entry.riskNotes && !entry.securityNotes) {
|
|
83
|
+
issues.push({
|
|
84
|
+
severity: 'warning',
|
|
85
|
+
message: `Entry #${index + 1} should include riskNotes or securityNotes`,
|
|
86
|
+
line: baseLine
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (!entry.permissions || entry.permissions.length === 0) {
|
|
90
|
+
issues.push({
|
|
91
|
+
severity: 'warning',
|
|
92
|
+
message: `Entry #${index + 1} should declare permissions`,
|
|
93
|
+
line: lineOf(raw, 'permissions:', baseLine)
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
return issues;
|
|
98
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeJsonReport<T>(report: T, outDir: string, fileName?: string): Promise<string>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export async function writeJsonReport(report, outDir, fileName = 'report.json') {
|
|
4
|
+
await mkdir(outDir, { recursive: true });
|
|
5
|
+
const output = join(outDir, fileName);
|
|
6
|
+
await writeFile(output, JSON.stringify(report, null, 2), 'utf8');
|
|
7
|
+
return output;
|
|
8
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export async function writeMarkdownReport(report, outDir, fileName = 'report.md') {
|
|
4
|
+
await mkdir(outDir, { recursive: true });
|
|
5
|
+
const output = join(outDir, fileName);
|
|
6
|
+
const passedTests = report.tests.filter((t) => t.passed).length;
|
|
7
|
+
const passRate = report.tests.length === 0 ? 'n/a' : `${passedTests}/${report.tests.length}`;
|
|
8
|
+
const lines = [];
|
|
9
|
+
lines.push('# MCP Guard Report');
|
|
10
|
+
lines.push('');
|
|
11
|
+
lines.push('## Summary');
|
|
12
|
+
lines.push('');
|
|
13
|
+
lines.push(`- **Risk score:** ${report.score}/100`);
|
|
14
|
+
lines.push(`- **Key findings:** ${report.findings.length}`);
|
|
15
|
+
lines.push(`- **Contract tests:** ${passRate}`);
|
|
16
|
+
lines.push(`- **Generated:** ${report.generatedAt}`);
|
|
17
|
+
lines.push(`- **Target:** \`${report.server.target}\` (${report.server.transport})`);
|
|
18
|
+
lines.push('');
|
|
19
|
+
lines.push('## Tool Inventory');
|
|
20
|
+
lines.push('');
|
|
21
|
+
lines.push('| Name | Description |');
|
|
22
|
+
lines.push('| --- | --- |');
|
|
23
|
+
for (const tool of report.tools) {
|
|
24
|
+
lines.push(`| ${tool.name} | ${tool.description ?? ''} |`);
|
|
25
|
+
}
|
|
26
|
+
lines.push('');
|
|
27
|
+
lines.push('## Findings by Severity');
|
|
28
|
+
lines.push('');
|
|
29
|
+
for (const severity of ['high', 'medium', 'low']) {
|
|
30
|
+
const group = report.findings.filter((finding) => finding.severity === severity);
|
|
31
|
+
lines.push(`### ${severity.toUpperCase()} (${group.length})`);
|
|
32
|
+
if (group.length === 0) {
|
|
33
|
+
lines.push('- None');
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
for (const finding of group) {
|
|
37
|
+
lines.push(`- **${finding.ruleId}**: ${finding.message}`);
|
|
38
|
+
lines.push(` - Remediation: ${finding.remediation}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
lines.push('');
|
|
42
|
+
}
|
|
43
|
+
lines.push('## Contract Test Results');
|
|
44
|
+
lines.push('');
|
|
45
|
+
for (const test of report.tests) {
|
|
46
|
+
lines.push(`- ${test.passed ? '✅' : '❌'} ${test.name} (${test.durationMs} ms): ${test.details ?? ''}`);
|
|
47
|
+
}
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push('## Explain Score');
|
|
50
|
+
lines.push('');
|
|
51
|
+
if (report.scoreBreakdown.length === 0) {
|
|
52
|
+
lines.push('- No penalties applied.');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
for (const item of report.scoreBreakdown) {
|
|
56
|
+
lines.push(`- ${item}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await writeFile(output, `${lines.join('\n')}\n`, 'utf8');
|
|
60
|
+
return output;
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
function levelForSeverity(severity) {
|
|
3
|
+
if (severity === 'high')
|
|
4
|
+
return 'error';
|
|
5
|
+
if (severity === 'medium')
|
|
6
|
+
return 'warning';
|
|
7
|
+
return 'note';
|
|
8
|
+
}
|
|
9
|
+
export async function writeSarif(findings, outputFile) {
|
|
10
|
+
const sarif = {
|
|
11
|
+
version: '2.1.0',
|
|
12
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
13
|
+
runs: [
|
|
14
|
+
{
|
|
15
|
+
tool: {
|
|
16
|
+
driver: {
|
|
17
|
+
name: 'mcp-guard',
|
|
18
|
+
rules: findings.map((finding) => ({
|
|
19
|
+
id: finding.ruleId,
|
|
20
|
+
shortDescription: { text: finding.message }
|
|
21
|
+
}))
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
results: findings.map((finding) => ({
|
|
25
|
+
ruleId: finding.ruleId,
|
|
26
|
+
level: levelForSeverity(finding.severity),
|
|
27
|
+
message: { text: `${finding.message} Remediation: ${finding.remediation}` }
|
|
28
|
+
}))
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
};
|
|
32
|
+
await writeFile(outputFile, JSON.stringify(sarif, null, 2), 'utf8');
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface DiscoveredServer {
|
|
2
|
+
source: 'claude_desktop' | 'cursor';
|
|
3
|
+
path: string;
|
|
4
|
+
name: string;
|
|
5
|
+
transport: 'stdio' | 'http' | 'unknown';
|
|
6
|
+
command?: string;
|
|
7
|
+
rawCommand?: string;
|
|
8
|
+
permissions?: string[];
|
|
9
|
+
reachable?: boolean;
|
|
10
|
+
findingsCount?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ScanResult {
|
|
13
|
+
scannedPaths: string[];
|
|
14
|
+
servers: DiscoveredServer[];
|
|
15
|
+
}
|
|
16
|
+
export declare function scanConfigs(repoPath?: string, directPath?: string): Promise<ScanResult>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
const knownConfigNames = ['claude_desktop_config.json', 'claude_desktop_config.jsonc', 'cursor_mcp.json', '.cursor/mcp.json'];
|
|
4
|
+
function sortServers(servers) {
|
|
5
|
+
return [...servers].sort((a, b) => {
|
|
6
|
+
const bySource = a.source.localeCompare(b.source);
|
|
7
|
+
if (bySource !== 0)
|
|
8
|
+
return bySource;
|
|
9
|
+
const byName = a.name.localeCompare(b.name);
|
|
10
|
+
if (byName !== 0)
|
|
11
|
+
return byName;
|
|
12
|
+
return a.path.localeCompare(b.path);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function stripComments(content) {
|
|
16
|
+
return content.replace(/^\s*\/\/.*$/gm, '');
|
|
17
|
+
}
|
|
18
|
+
function redact(text) {
|
|
19
|
+
return text
|
|
20
|
+
.replace(/(token|api[_-]?key|secret)\s*[:=]\s*["']?[^\s"',}]+/gi, '$1=<redacted>')
|
|
21
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer <redacted>');
|
|
22
|
+
}
|
|
23
|
+
function detectTransport(server) {
|
|
24
|
+
if (typeof server.command === 'string')
|
|
25
|
+
return 'stdio';
|
|
26
|
+
if (typeof server.url === 'string' || typeof server.httpUrl === 'string')
|
|
27
|
+
return 'http';
|
|
28
|
+
return 'unknown';
|
|
29
|
+
}
|
|
30
|
+
function extractServers(raw, source, path) {
|
|
31
|
+
const content = stripComments(raw);
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(content);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const mcpServers = (parsed.mcpServers ?? parsed.servers ?? {});
|
|
40
|
+
return Object.entries(mcpServers).map(([name, value]) => ({
|
|
41
|
+
source,
|
|
42
|
+
path,
|
|
43
|
+
name,
|
|
44
|
+
transport: detectTransport(value),
|
|
45
|
+
rawCommand: typeof value.command === 'string' ? String(value.command) : undefined,
|
|
46
|
+
command: typeof value.command === 'string' ? redact(value.command) : undefined,
|
|
47
|
+
permissions: Array.isArray(value.permissions) ? value.permissions.map((p) => redact(String(p))) : []
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
export async function scanConfigs(repoPath, directPath) {
|
|
51
|
+
const scannedPaths = [];
|
|
52
|
+
const servers = [];
|
|
53
|
+
if (directPath) {
|
|
54
|
+
const absolute = resolve(directPath);
|
|
55
|
+
const raw = await readFile(absolute, 'utf8');
|
|
56
|
+
scannedPaths.push(absolute);
|
|
57
|
+
const source = absolute.toLowerCase().includes('cursor') ? 'cursor' : 'claude_desktop';
|
|
58
|
+
servers.push(...extractServers(raw, source, absolute));
|
|
59
|
+
return { scannedPaths: [...scannedPaths].sort(), servers: sortServers(servers) };
|
|
60
|
+
}
|
|
61
|
+
if (!repoPath) {
|
|
62
|
+
return { scannedPaths: [...scannedPaths].sort(), servers: sortServers(servers) };
|
|
63
|
+
}
|
|
64
|
+
const root = resolve(repoPath);
|
|
65
|
+
const queue = [root];
|
|
66
|
+
while (queue.length > 0) {
|
|
67
|
+
const current = queue.shift();
|
|
68
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const fullPath = join(current, entry.name);
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
if (entry.name === 'node_modules' || entry.name === '.git')
|
|
73
|
+
continue;
|
|
74
|
+
queue.push(fullPath);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (knownConfigNames.some((name) => fullPath.endsWith(name))) {
|
|
78
|
+
const raw = await readFile(fullPath, 'utf8');
|
|
79
|
+
scannedPaths.push(fullPath);
|
|
80
|
+
const source = fullPath.toLowerCase().includes('cursor') ? 'cursor' : 'claude_desktop';
|
|
81
|
+
servers.push(...extractServers(raw, source, fullPath));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { scannedPaths: [...scannedPaths].sort(), servers: sortServers(servers) };
|
|
86
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Finding, Profile } from '../mcp/types.js';
|
|
2
|
+
export declare function scoreFindings(findings: Finding[], profile: Profile): {
|
|
3
|
+
score: number;
|
|
4
|
+
breakdown: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function tuneFindingsForProfile(findings: Finding[], profile: Profile): Finding[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const severityPenaltyByProfile = {
|
|
2
|
+
default: { low: 5, medium: 12, high: 25 },
|
|
3
|
+
strict: { low: 8, medium: 15, high: 28 },
|
|
4
|
+
paranoid: { low: 10, medium: 20, high: 40 }
|
|
5
|
+
};
|
|
6
|
+
export function scoreFindings(findings, profile) {
|
|
7
|
+
const weights = severityPenaltyByProfile[profile];
|
|
8
|
+
const penalties = findings.map((finding) => `${finding.ruleId} (${finding.severity}) -${weights[finding.severity]}`);
|
|
9
|
+
const total = findings.reduce((sum, finding) => sum + weights[finding.severity], 0);
|
|
10
|
+
return {
|
|
11
|
+
score: Math.max(0, 100 - total),
|
|
12
|
+
breakdown: penalties
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function tuneFindingsForProfile(findings, profile) {
|
|
16
|
+
if (profile === 'paranoid') {
|
|
17
|
+
return findings.map((finding) => {
|
|
18
|
+
if (finding.ruleId === 'security.shell_injection' || finding.ruleId === 'security.raw_args') {
|
|
19
|
+
return { ...finding, severity: 'high' };
|
|
20
|
+
}
|
|
21
|
+
return finding;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return findings;
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function pathTraversalRule(tools) {
|
|
2
|
+
const findings = [];
|
|
3
|
+
for (const tool of tools) {
|
|
4
|
+
const schema = tool.inputSchema;
|
|
5
|
+
const properties = (schema.properties ?? {});
|
|
6
|
+
for (const [name, value] of Object.entries(properties)) {
|
|
7
|
+
if (!/(path|file)/i.test(name))
|
|
8
|
+
continue;
|
|
9
|
+
const prop = (value ?? {});
|
|
10
|
+
const hasConstraint = 'pattern' in prop || 'enum' in prop;
|
|
11
|
+
if (!hasConstraint) {
|
|
12
|
+
findings.push({
|
|
13
|
+
severity: 'high',
|
|
14
|
+
ruleId: 'security.path_traversal',
|
|
15
|
+
message: `Path-like parameter ${name} on tool ${tool.name} lacks constraints`,
|
|
16
|
+
evidence: JSON.stringify(prop),
|
|
17
|
+
remediation: 'Add strict pattern (allowlist base path) or enum constraints.',
|
|
18
|
+
toolName: tool.name
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return findings;
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function rawArgsRule(tools) {
|
|
2
|
+
const findings = [];
|
|
3
|
+
for (const tool of tools) {
|
|
4
|
+
const schema = tool.inputSchema;
|
|
5
|
+
const properties = (schema.properties ?? {});
|
|
6
|
+
for (const [name, value] of Object.entries(properties)) {
|
|
7
|
+
if (!/(argv|flags)/i.test(name))
|
|
8
|
+
continue;
|
|
9
|
+
const prop = (value ?? {});
|
|
10
|
+
const items = (prop.items ?? {});
|
|
11
|
+
if (prop.type === 'array' && items.type === 'string' && !('enum' in items) && !('maxItems' in prop)) {
|
|
12
|
+
findings.push({
|
|
13
|
+
severity: 'medium',
|
|
14
|
+
ruleId: 'security.raw_args',
|
|
15
|
+
message: `Raw CLI args array ${name} on tool ${tool.name} is unbounded`,
|
|
16
|
+
evidence: JSON.stringify(prop),
|
|
17
|
+
remediation: 'Constrain allowed values and maxItems for argv/flags arrays.',
|
|
18
|
+
toolName: tool.name
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return findings;
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function shellInjectionRule(tools) {
|
|
2
|
+
const findings = [];
|
|
3
|
+
for (const tool of tools) {
|
|
4
|
+
const schema = tool.inputSchema;
|
|
5
|
+
const properties = (schema.properties ?? {});
|
|
6
|
+
for (const [name, value] of Object.entries(properties)) {
|
|
7
|
+
if (!/(args|command|shell)/i.test(name))
|
|
8
|
+
continue;
|
|
9
|
+
const prop = (value ?? {});
|
|
10
|
+
if (prop.type === 'string' && !('enum' in prop)) {
|
|
11
|
+
findings.push({
|
|
12
|
+
severity: 'high',
|
|
13
|
+
ruleId: 'security.shell_injection',
|
|
14
|
+
message: `Potential command injection param ${name} on tool ${tool.name}`,
|
|
15
|
+
evidence: JSON.stringify(prop),
|
|
16
|
+
remediation: 'Use enum allowlists and never pass through raw shell commands.',
|
|
17
|
+
toolName: tool.name
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return findings;
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function runCallToolTest(client) {
|
|
2
|
+
const start = Date.now();
|
|
3
|
+
const response = await client.request('tools/call', {
|
|
4
|
+
name: 'hello',
|
|
5
|
+
arguments: { name: 'guard' }
|
|
6
|
+
});
|
|
7
|
+
const passed = typeof response.content === 'string' && response.content.includes('guard');
|
|
8
|
+
return {
|
|
9
|
+
name: 'call_tool',
|
|
10
|
+
passed,
|
|
11
|
+
durationMs: Date.now() - start,
|
|
12
|
+
details: passed ? response.content : 'Unexpected response format'
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export async function runCancellationTest(client) {
|
|
2
|
+
const start = Date.now();
|
|
3
|
+
try {
|
|
4
|
+
const result = await client.request('tools/cancel', { requestId: 42 });
|
|
5
|
+
return {
|
|
6
|
+
name: 'cancellation_behavior',
|
|
7
|
+
passed: result.status === 'cancelled',
|
|
8
|
+
durationMs: Date.now() - start,
|
|
9
|
+
details: JSON.stringify(result)
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
const normalized = client.normalizeError(error);
|
|
14
|
+
const isNotSupported = normalized.code === -32601 || /not supported/i.test(normalized.message);
|
|
15
|
+
return {
|
|
16
|
+
name: 'cancellation_behavior',
|
|
17
|
+
passed: isNotSupported,
|
|
18
|
+
durationMs: Date.now() - start,
|
|
19
|
+
details: isNotSupported ? 'Cancellation not supported by fixture server (accepted)' : JSON.stringify(normalized)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function runErrorShapesTest(client) {
|
|
2
|
+
const start = Date.now();
|
|
3
|
+
try {
|
|
4
|
+
await client.request('tools/call', {
|
|
5
|
+
name: 'hello',
|
|
6
|
+
arguments: {}
|
|
7
|
+
});
|
|
8
|
+
return {
|
|
9
|
+
name: 'error_shapes',
|
|
10
|
+
passed: false,
|
|
11
|
+
durationMs: Date.now() - start,
|
|
12
|
+
details: 'Expected an error but call succeeded'
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
const normalized = client.normalizeError(error);
|
|
17
|
+
const passed = Number.isInteger(normalized.code) && typeof normalized.message === 'string';
|
|
18
|
+
return {
|
|
19
|
+
name: 'error_shapes',
|
|
20
|
+
passed,
|
|
21
|
+
durationMs: Date.now() - start,
|
|
22
|
+
details: JSON.stringify(normalized)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export async function runLargePayloadTest(client) {
|
|
2
|
+
const start = Date.now();
|
|
3
|
+
const text = 'x'.repeat(50_000);
|
|
4
|
+
const response = await client.request('tools/call', {
|
|
5
|
+
name: 'echo',
|
|
6
|
+
arguments: { text }
|
|
7
|
+
}, 3000);
|
|
8
|
+
const passed = response.content.length === text.length;
|
|
9
|
+
return {
|
|
10
|
+
name: 'large_payload',
|
|
11
|
+
passed,
|
|
12
|
+
durationMs: Date.now() - start,
|
|
13
|
+
details: `echoed_length=${response.content.length}`
|
|
14
|
+
};
|
|
15
|
+
}
|