guardrail-mcp 0.1.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/index.d.ts +2 -0
- package/dist/index.js +17 -0
- package/dist/lib/api-client.d.ts +10 -0
- package/dist/lib/api-client.js +70 -0
- package/dist/lib/checks.d.ts +8 -0
- package/dist/lib/checks.js +239 -0
- package/dist/lib/grade.d.ts +11 -0
- package/dist/lib/grade.js +40 -0
- package/dist/lib/local-scanner.d.ts +5 -0
- package/dist/lib/local-scanner.js +88 -0
- package/dist/tools/fix.d.ts +2 -0
- package/dist/tools/fix.js +55 -0
- package/dist/tools/issues.d.ts +2 -0
- package/dist/tools/issues.js +49 -0
- package/dist/tools/scan.d.ts +2 -0
- package/dist/tools/scan.js +101 -0
- package/dist/tools/status.d.ts +2 -0
- package/dist/tools/status.js +49 -0
- package/package.json +26 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerScanTool } from "./tools/scan.js";
|
|
5
|
+
import { registerStatusTool } from "./tools/status.js";
|
|
6
|
+
import { registerIssuesTool } from "./tools/issues.js";
|
|
7
|
+
import { registerFixTool } from "./tools/fix.js";
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: "guardrail-mcp",
|
|
10
|
+
version: "0.1.0",
|
|
11
|
+
});
|
|
12
|
+
registerScanTool(server);
|
|
13
|
+
registerStatusTool(server);
|
|
14
|
+
registerIssuesTool(server);
|
|
15
|
+
registerFixTool(server);
|
|
16
|
+
const transport = new StdioServerTransport();
|
|
17
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class ApiClient {
|
|
2
|
+
private baseUrl;
|
|
3
|
+
private projectKey;
|
|
4
|
+
constructor();
|
|
5
|
+
isConfigured(): boolean;
|
|
6
|
+
getConfigError(): string;
|
|
7
|
+
post<T = unknown>(path: string, body: unknown): Promise<T>;
|
|
8
|
+
get<T = unknown>(path: string): Promise<T>;
|
|
9
|
+
}
|
|
10
|
+
export declare const apiClient: ApiClient;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const API_URL = process.env.GUARDRAIL_API_URL || "https://guardrail-seven.vercel.app";
|
|
2
|
+
const PROJECT_KEY = process.env.GUARDRAIL_PROJECT_KEY;
|
|
3
|
+
export class ApiClient {
|
|
4
|
+
baseUrl;
|
|
5
|
+
projectKey;
|
|
6
|
+
constructor() {
|
|
7
|
+
this.baseUrl = API_URL;
|
|
8
|
+
this.projectKey = PROJECT_KEY;
|
|
9
|
+
}
|
|
10
|
+
isConfigured() {
|
|
11
|
+
return !!this.projectKey;
|
|
12
|
+
}
|
|
13
|
+
getConfigError() {
|
|
14
|
+
return [
|
|
15
|
+
"GUARDRAIL_PROJECT_KEY is not set.",
|
|
16
|
+
"",
|
|
17
|
+
"To configure, add this to your Claude Code MCP settings:",
|
|
18
|
+
"",
|
|
19
|
+
'{',
|
|
20
|
+
' "mcpServers": {',
|
|
21
|
+
' "guardrail": {',
|
|
22
|
+
' "command": "npx",',
|
|
23
|
+
' "args": ["-y", "guardrail-mcp@latest"],',
|
|
24
|
+
' "env": {',
|
|
25
|
+
' "GUARDRAIL_PROJECT_KEY": "gr_sk_your_key_here"',
|
|
26
|
+
" }",
|
|
27
|
+
" }",
|
|
28
|
+
" }",
|
|
29
|
+
"}",
|
|
30
|
+
"",
|
|
31
|
+
"Get your project key from: https://guardrail-seven.vercel.app → Project Settings → MCP Connection",
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
async post(path, body) {
|
|
35
|
+
if (!this.projectKey) {
|
|
36
|
+
throw new Error(this.getConfigError());
|
|
37
|
+
}
|
|
38
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
Authorization: `Bearer ${this.projectKey}`,
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify(body),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const data = await res.json().catch(() => ({}));
|
|
48
|
+
throw new Error(data.error ||
|
|
49
|
+
`API error: ${res.status} ${res.statusText}`);
|
|
50
|
+
}
|
|
51
|
+
return res.json();
|
|
52
|
+
}
|
|
53
|
+
async get(path) {
|
|
54
|
+
if (!this.projectKey) {
|
|
55
|
+
throw new Error(this.getConfigError());
|
|
56
|
+
}
|
|
57
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: `Bearer ${this.projectKey}`,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const data = await res.json().catch(() => ({}));
|
|
64
|
+
throw new Error(data.error ||
|
|
65
|
+
`API error: ${res.status} ${res.statusText}`);
|
|
66
|
+
}
|
|
67
|
+
return res.json();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export const apiClient = new ApiClient();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RepoFile } from "./local-scanner.js";
|
|
2
|
+
export interface CheckResult {
|
|
3
|
+
check_key: string;
|
|
4
|
+
category: "payment" | "auth" | "secrets" | "infra" | "legal";
|
|
5
|
+
status: "pass" | "fail";
|
|
6
|
+
severity: "critical" | "warning";
|
|
7
|
+
}
|
|
8
|
+
export declare function runAllChecks(files: RepoFile[]): CheckResult[];
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
function isEnvFile(path) {
|
|
2
|
+
const name = path.split("/").pop() || "";
|
|
3
|
+
return name.startsWith(".env");
|
|
4
|
+
}
|
|
5
|
+
function isSourceFile(file) {
|
|
6
|
+
const ext = file.path.split(".").pop()?.toLowerCase();
|
|
7
|
+
return (!isEnvFile(file.path) &&
|
|
8
|
+
["js", "ts", "tsx", "jsx", "mjs", "cjs", "py", "rb", "go"].includes(ext || ""));
|
|
9
|
+
}
|
|
10
|
+
function getSourceFiles(files) {
|
|
11
|
+
return files.filter(isSourceFile);
|
|
12
|
+
}
|
|
13
|
+
function anyFileMatches(files, pattern) {
|
|
14
|
+
return files.some((f) => pattern.test(f.content));
|
|
15
|
+
}
|
|
16
|
+
function allContent(files) {
|
|
17
|
+
return files.map((f) => f.content).join("\n");
|
|
18
|
+
}
|
|
19
|
+
const checkStripeKeyExposed = (files) => {
|
|
20
|
+
const result = {
|
|
21
|
+
check_key: "stripe_key_exposed",
|
|
22
|
+
category: "payment",
|
|
23
|
+
status: "pass",
|
|
24
|
+
severity: "critical",
|
|
25
|
+
};
|
|
26
|
+
const pattern = /(?:sk_live_|sk_test_|rk_live_|rk_test_)[a-zA-Z0-9]{20,}/;
|
|
27
|
+
const sourceFiles = getSourceFiles(files);
|
|
28
|
+
if (anyFileMatches(sourceFiles, pattern)) {
|
|
29
|
+
result.status = "fail";
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
const checkWebhookNoVerify = (files) => {
|
|
34
|
+
const result = {
|
|
35
|
+
check_key: "webhook_no_verify",
|
|
36
|
+
category: "payment",
|
|
37
|
+
status: "pass",
|
|
38
|
+
severity: "warning",
|
|
39
|
+
};
|
|
40
|
+
const sourceFiles = getSourceFiles(files);
|
|
41
|
+
const webhookPattern = /webhook/i;
|
|
42
|
+
const hasWebhook = sourceFiles.some((f) => webhookPattern.test(f.path)) ||
|
|
43
|
+
anyFileMatches(sourceFiles, webhookPattern);
|
|
44
|
+
if (!hasWebhook)
|
|
45
|
+
return result;
|
|
46
|
+
const verifyPatterns = [
|
|
47
|
+
/constructEvent/,
|
|
48
|
+
/verify.*signature/i,
|
|
49
|
+
/timingSafeEqual/,
|
|
50
|
+
/svix/i,
|
|
51
|
+
/hmac/i,
|
|
52
|
+
/webhook.*secret/i,
|
|
53
|
+
];
|
|
54
|
+
const content = allContent(sourceFiles);
|
|
55
|
+
const hasVerify = verifyPatterns.some((p) => p.test(content));
|
|
56
|
+
if (!hasVerify) {
|
|
57
|
+
result.status = "fail";
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
const checkPasswordPlaintext = (files) => {
|
|
62
|
+
const result = {
|
|
63
|
+
check_key: "password_plaintext",
|
|
64
|
+
category: "auth",
|
|
65
|
+
status: "pass",
|
|
66
|
+
severity: "critical",
|
|
67
|
+
};
|
|
68
|
+
const sourceFiles = getSourceFiles(files);
|
|
69
|
+
const content = allContent(sourceFiles);
|
|
70
|
+
const plaintextPatterns = [
|
|
71
|
+
/password\s*[:=]\s*(?:req|request|body|input|data|params)\./i,
|
|
72
|
+
/\.create\([^)]*password:\s*(?:req|request|body|input|data|params)\./i,
|
|
73
|
+
/INSERT.*password.*VALUES.*\$\{/i,
|
|
74
|
+
/password\s*=\s*(?:req|request|body|input)\./i,
|
|
75
|
+
];
|
|
76
|
+
const hashPatterns = [
|
|
77
|
+
/bcrypt/i,
|
|
78
|
+
/argon2/i,
|
|
79
|
+
/scrypt/i,
|
|
80
|
+
/pbkdf2/i,
|
|
81
|
+
/hashPassword/i,
|
|
82
|
+
/hash\s*\(/,
|
|
83
|
+
/createHash/,
|
|
84
|
+
];
|
|
85
|
+
const hasPlaintext = plaintextPatterns.some((p) => p.test(content));
|
|
86
|
+
if (!hasPlaintext)
|
|
87
|
+
return result;
|
|
88
|
+
const hasHash = hashPatterns.some((p) => p.test(content));
|
|
89
|
+
if (!hasHash) {
|
|
90
|
+
result.status = "fail";
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
};
|
|
94
|
+
const checkNoRateLimit = (files) => {
|
|
95
|
+
const result = {
|
|
96
|
+
check_key: "no_rate_limit",
|
|
97
|
+
category: "auth",
|
|
98
|
+
status: "pass",
|
|
99
|
+
severity: "warning",
|
|
100
|
+
};
|
|
101
|
+
const authRoutePattern = /\/(api|pages\/api)\/.*(login|signin|sign-in|auth|register|signup)/i;
|
|
102
|
+
const hasAuthEndpoint = files.some((f) => authRoutePattern.test(f.path));
|
|
103
|
+
if (!hasAuthEndpoint)
|
|
104
|
+
return result;
|
|
105
|
+
const content = allContent(getSourceFiles(files));
|
|
106
|
+
const rateLimitPatterns = [
|
|
107
|
+
/ratelimit/i,
|
|
108
|
+
/rate[_-]?limit/i,
|
|
109
|
+
/throttle/i,
|
|
110
|
+
/RateLimiter/i,
|
|
111
|
+
/upstash.*ratelimit/i,
|
|
112
|
+
/status:\s*429/,
|
|
113
|
+
/too.many.requests/i,
|
|
114
|
+
/rate.limited/i,
|
|
115
|
+
];
|
|
116
|
+
const hasRateLimit = rateLimitPatterns.some((p) => p.test(content));
|
|
117
|
+
if (!hasRateLimit) {
|
|
118
|
+
result.status = "fail";
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
};
|
|
122
|
+
const checkEnvNotGitignored = (files) => {
|
|
123
|
+
const result = {
|
|
124
|
+
check_key: "env_not_gitignored",
|
|
125
|
+
category: "secrets",
|
|
126
|
+
status: "pass",
|
|
127
|
+
severity: "critical",
|
|
128
|
+
};
|
|
129
|
+
const gitignore = files.find((f) => f.path === ".gitignore");
|
|
130
|
+
if (!gitignore) {
|
|
131
|
+
result.status = "fail";
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
const envPatterns = [/^\.env$/m, /^\.env\.local$/m, /^\.env\*$/m, /^\.env\.\*$/m];
|
|
135
|
+
const hasEnvRule = envPatterns.some((p) => p.test(gitignore.content));
|
|
136
|
+
if (!hasEnvRule) {
|
|
137
|
+
result.status = "fail";
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
const committedEnv = files.some((f) => isEnvFile(f.path) &&
|
|
141
|
+
!f.path.includes(".example") &&
|
|
142
|
+
!f.path.includes(".sample") &&
|
|
143
|
+
f.path !== ".gitignore");
|
|
144
|
+
if (committedEnv) {
|
|
145
|
+
result.status = "fail";
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
};
|
|
149
|
+
const checkApiKeyHardcoded = (files) => {
|
|
150
|
+
const result = {
|
|
151
|
+
check_key: "api_key_hardcoded",
|
|
152
|
+
category: "secrets",
|
|
153
|
+
status: "pass",
|
|
154
|
+
severity: "critical",
|
|
155
|
+
};
|
|
156
|
+
const sourceFiles = files.filter((f) => isSourceFile(f) &&
|
|
157
|
+
!f.path.includes(".example") &&
|
|
158
|
+
!f.path.includes(".test.") &&
|
|
159
|
+
!f.path.includes(".spec.") &&
|
|
160
|
+
!f.path.includes("__test__") &&
|
|
161
|
+
!f.path.includes("__tests__"));
|
|
162
|
+
const patterns = [
|
|
163
|
+
/(?:api[_-]?key|apikey|secret[_-]?key|access[_-]?token)\s*[:=]\s*["'][a-zA-Z0-9_\-]{20,}["']/i,
|
|
164
|
+
/AKIA[0-9A-Z]{16}/,
|
|
165
|
+
/sk-[a-zA-Z0-9]{32,}/,
|
|
166
|
+
/SG\.[a-zA-Z0-9_\-]{22}\.[a-zA-Z0-9_\-]{43}/,
|
|
167
|
+
/ghp_[a-zA-Z0-9]{36}/,
|
|
168
|
+
/glpat-[a-zA-Z0-9\-_]{20}/,
|
|
169
|
+
/re_[a-zA-Z0-9]{20,}/,
|
|
170
|
+
];
|
|
171
|
+
for (const file of sourceFiles) {
|
|
172
|
+
for (const pattern of patterns) {
|
|
173
|
+
if (pattern.test(file.content)) {
|
|
174
|
+
result.status = "fail";
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
};
|
|
181
|
+
const checkNoHttps = (files) => {
|
|
182
|
+
const result = {
|
|
183
|
+
check_key: "no_https",
|
|
184
|
+
category: "infra",
|
|
185
|
+
status: "pass",
|
|
186
|
+
severity: "warning",
|
|
187
|
+
};
|
|
188
|
+
const hasVercel = files.some((f) => f.path === "vercel.json");
|
|
189
|
+
const hasNetlify = files.some((f) => f.path === "netlify.toml");
|
|
190
|
+
const pkgJson = files.find((f) => f.path === "package.json");
|
|
191
|
+
if (hasVercel || hasNetlify)
|
|
192
|
+
return result;
|
|
193
|
+
if (pkgJson) {
|
|
194
|
+
const content = pkgJson.content;
|
|
195
|
+
if (/vercel|netlify|railway/i.test(content) &&
|
|
196
|
+
(/"scripts"/.test(content) || /"dependencies"/.test(content))) {
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const content = allContent(getSourceFiles(files));
|
|
201
|
+
if (/strict-transport-security/i.test(content))
|
|
202
|
+
return result;
|
|
203
|
+
const insecurePattern = /http:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0|::1)[a-zA-Z]/;
|
|
204
|
+
if (insecurePattern.test(content)) {
|
|
205
|
+
result.status = "fail";
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
};
|
|
209
|
+
const checkNoPrivacyPolicy = (files) => {
|
|
210
|
+
const result = {
|
|
211
|
+
check_key: "no_privacy_policy",
|
|
212
|
+
category: "legal",
|
|
213
|
+
status: "pass",
|
|
214
|
+
severity: "warning",
|
|
215
|
+
};
|
|
216
|
+
const hasPrivacyFile = files.some((f) => /privacy/i.test(f.path));
|
|
217
|
+
if (hasPrivacyFile)
|
|
218
|
+
return result;
|
|
219
|
+
const content = allContent(files);
|
|
220
|
+
if (/(?:href|to)=["'][^"']*privacy/i.test(content))
|
|
221
|
+
return result;
|
|
222
|
+
if (/privacy\s*policy/i.test(content))
|
|
223
|
+
return result;
|
|
224
|
+
result.status = "fail";
|
|
225
|
+
return result;
|
|
226
|
+
};
|
|
227
|
+
const ALL_CHECKS = [
|
|
228
|
+
checkStripeKeyExposed,
|
|
229
|
+
checkWebhookNoVerify,
|
|
230
|
+
checkPasswordPlaintext,
|
|
231
|
+
checkNoRateLimit,
|
|
232
|
+
checkEnvNotGitignored,
|
|
233
|
+
checkApiKeyHardcoded,
|
|
234
|
+
checkNoHttps,
|
|
235
|
+
checkNoPrivacyPolicy,
|
|
236
|
+
];
|
|
237
|
+
export function runAllChecks(files) {
|
|
238
|
+
return ALL_CHECKS.map((check) => check(files));
|
|
239
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CheckResult } from "./checks.js";
|
|
2
|
+
export type ScanGrade = "A" | "B" | "C" | "D" | "F";
|
|
3
|
+
export interface GradeResult {
|
|
4
|
+
grade: ScanGrade;
|
|
5
|
+
summary: string;
|
|
6
|
+
passCount: number;
|
|
7
|
+
failCount: number;
|
|
8
|
+
criticalCount: number;
|
|
9
|
+
warningCount: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function calculateGrade(results: CheckResult[]): GradeResult;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const GRADE_SUMMARIES = {
|
|
2
|
+
A: "All clear — looking good!",
|
|
3
|
+
B: "Almost there — one thing to check",
|
|
4
|
+
C: "A few things need fixing",
|
|
5
|
+
D: "Several issues found",
|
|
6
|
+
F: "Critical issues need attention",
|
|
7
|
+
};
|
|
8
|
+
export function calculateGrade(results) {
|
|
9
|
+
const passCount = results.filter((r) => r.status === "pass").length;
|
|
10
|
+
const failCount = results.length - passCount;
|
|
11
|
+
const criticalCount = results.filter((r) => r.status === "fail" && r.severity === "critical").length;
|
|
12
|
+
const warningCount = results.filter((r) => r.status === "fail" && r.severity === "warning").length;
|
|
13
|
+
let grade;
|
|
14
|
+
if (criticalCount >= 2 || passCount <= 2) {
|
|
15
|
+
grade = "F";
|
|
16
|
+
}
|
|
17
|
+
else if (passCount <= 4) {
|
|
18
|
+
grade = "D";
|
|
19
|
+
}
|
|
20
|
+
else if (passCount <= 7 && criticalCount <= 1) {
|
|
21
|
+
grade = "C";
|
|
22
|
+
}
|
|
23
|
+
else if (passCount === 7 && criticalCount === 0) {
|
|
24
|
+
grade = "B";
|
|
25
|
+
}
|
|
26
|
+
else if (passCount === 8) {
|
|
27
|
+
grade = "A";
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
grade = "C";
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
grade,
|
|
34
|
+
summary: GRADE_SUMMARIES[grade],
|
|
35
|
+
passCount,
|
|
36
|
+
failCount,
|
|
37
|
+
criticalCount,
|
|
38
|
+
warningCount,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join, relative, extname } from "node:path";
|
|
3
|
+
const INCLUDE_EXTENSIONS = new Set([
|
|
4
|
+
".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs",
|
|
5
|
+
".py", ".rb", ".go", ".rs",
|
|
6
|
+
".json", ".toml", ".yaml", ".yml",
|
|
7
|
+
]);
|
|
8
|
+
const INCLUDE_EXACT = new Set([
|
|
9
|
+
".gitignore", ".env", ".env.local", ".env.production", ".env.development",
|
|
10
|
+
".env.example", "package.json", "next.config.js", "next.config.ts",
|
|
11
|
+
"next.config.mjs", "vercel.json", "netlify.toml", "railway.json",
|
|
12
|
+
"Dockerfile", "docker-compose.yml", "docker-compose.yaml",
|
|
13
|
+
]);
|
|
14
|
+
const EXCLUDE_DIRS = new Set([
|
|
15
|
+
"node_modules", ".next", "dist", "build", "vendor", ".git",
|
|
16
|
+
"__pycache__", ".cache", "coverage", ".turbo", ".vercel",
|
|
17
|
+
]);
|
|
18
|
+
const MAX_FILES = 50;
|
|
19
|
+
function shouldIncludeFile(filePath) {
|
|
20
|
+
const parts = filePath.split("/");
|
|
21
|
+
// Check exclusions
|
|
22
|
+
for (const part of parts) {
|
|
23
|
+
if (EXCLUDE_DIRS.has(part))
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const fileName = parts[parts.length - 1];
|
|
27
|
+
// Exact match
|
|
28
|
+
if (INCLUDE_EXACT.has(fileName))
|
|
29
|
+
return true;
|
|
30
|
+
// .env files
|
|
31
|
+
if (fileName.startsWith(".env"))
|
|
32
|
+
return true;
|
|
33
|
+
// Extension match
|
|
34
|
+
const ext = extname(fileName);
|
|
35
|
+
if (ext && INCLUDE_EXTENSIONS.has(ext))
|
|
36
|
+
return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
function priorityScore(filePath) {
|
|
40
|
+
if (filePath === ".gitignore" ||
|
|
41
|
+
filePath.startsWith(".env") ||
|
|
42
|
+
filePath === "package.json")
|
|
43
|
+
return 0;
|
|
44
|
+
if (filePath === "vercel.json" ||
|
|
45
|
+
filePath === "netlify.toml" ||
|
|
46
|
+
filePath.startsWith("next.config"))
|
|
47
|
+
return 1;
|
|
48
|
+
if (filePath.includes("/api/") || filePath.includes("/pages/api/"))
|
|
49
|
+
return 2;
|
|
50
|
+
if (filePath.includes("auth") ||
|
|
51
|
+
filePath.includes("login") ||
|
|
52
|
+
filePath.includes("signup"))
|
|
53
|
+
return 3;
|
|
54
|
+
if (filePath.startsWith("src/") ||
|
|
55
|
+
filePath.startsWith("app/") ||
|
|
56
|
+
filePath.startsWith("pages/"))
|
|
57
|
+
return 4;
|
|
58
|
+
return 5;
|
|
59
|
+
}
|
|
60
|
+
export async function scanLocalDirectory(dir) {
|
|
61
|
+
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
62
|
+
const filePaths = [];
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (!entry.isFile())
|
|
65
|
+
continue;
|
|
66
|
+
const fullPath = join(entry.parentPath || entry.path, entry.name);
|
|
67
|
+
const relPath = relative(dir, fullPath).replace(/\\/g, "/");
|
|
68
|
+
if (shouldIncludeFile(relPath)) {
|
|
69
|
+
filePaths.push(relPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Sort by priority and limit
|
|
73
|
+
const sorted = filePaths
|
|
74
|
+
.sort((a, b) => priorityScore(a) - priorityScore(b))
|
|
75
|
+
.slice(0, MAX_FILES);
|
|
76
|
+
// Read file contents
|
|
77
|
+
const files = [];
|
|
78
|
+
for (const relPath of sorted) {
|
|
79
|
+
try {
|
|
80
|
+
const content = await readFile(join(dir, relPath), "utf-8");
|
|
81
|
+
files.push({ path: relPath, content });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Skip unreadable files
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiClient } from "../lib/api-client.js";
|
|
3
|
+
export function registerFixTool(server) {
|
|
4
|
+
server.tool("guardrail_fix", "Get detailed fix instructions for a specific security issue. Includes step-by-step guide with code examples and AI prompts you can use.", {
|
|
5
|
+
check_key: z
|
|
6
|
+
.string()
|
|
7
|
+
.describe('The check_key of the issue to fix (e.g. "api_key_hardcoded", "stripe_key_exposed"). Get this from guardrail_issues.'),
|
|
8
|
+
}, async ({ check_key }) => {
|
|
9
|
+
if (!apiClient.isConfigured()) {
|
|
10
|
+
return {
|
|
11
|
+
content: [
|
|
12
|
+
{ type: "text", text: apiClient.getConfigError() },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const data = await apiClient.get(`/api/mcp/issues?check_key=${encodeURIComponent(check_key)}`);
|
|
18
|
+
const guide = data.guide;
|
|
19
|
+
const lines = [];
|
|
20
|
+
lines.push(`Fix: ${guide.title}`);
|
|
21
|
+
lines.push(`Severity: ${guide.severity}`);
|
|
22
|
+
lines.push("");
|
|
23
|
+
lines.push(`Why it matters: ${guide.why}`);
|
|
24
|
+
lines.push("");
|
|
25
|
+
lines.push("Steps:");
|
|
26
|
+
for (let i = 0; i < guide.steps.length; i++) {
|
|
27
|
+
const step = guide.steps[i];
|
|
28
|
+
lines.push(`${i + 1}. ${step.instruction}`);
|
|
29
|
+
if (step.code) {
|
|
30
|
+
lines.push(` ${step.code}`);
|
|
31
|
+
}
|
|
32
|
+
lines.push("");
|
|
33
|
+
}
|
|
34
|
+
if (guide.aiPrompts.length > 0) {
|
|
35
|
+
lines.push("AI Prompts (copy & paste to fix automatically):");
|
|
36
|
+
for (const prompt of guide.aiPrompts) {
|
|
37
|
+
lines.push(` "${prompt.text}"`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: `Failed to get fix guide: ${err instanceof Error ? err.message : "Unknown error"}`,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { apiClient } from "../lib/api-client.js";
|
|
2
|
+
export function registerIssuesTool(server) {
|
|
3
|
+
server.tool("guardrail_issues", "List all security issues found in the latest scan. Shows each issue's severity, category, and description.", {}, async () => {
|
|
4
|
+
if (!apiClient.isConfigured()) {
|
|
5
|
+
return {
|
|
6
|
+
content: [
|
|
7
|
+
{ type: "text", text: apiClient.getConfigError() },
|
|
8
|
+
],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const data = await apiClient.get("/api/mcp/issues");
|
|
13
|
+
if (data.issues.length === 0) {
|
|
14
|
+
return {
|
|
15
|
+
content: [
|
|
16
|
+
{
|
|
17
|
+
type: "text",
|
|
18
|
+
text: `No issues found! Your project has a grade of ${data.grade}.\n\nAll security checks passed.`,
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const lines = [];
|
|
24
|
+
lines.push(`${data.issues.length} issue${data.issues.length > 1 ? "s" : ""} found (Grade: ${data.grade}):`);
|
|
25
|
+
lines.push("");
|
|
26
|
+
for (let i = 0; i < data.issues.length; i++) {
|
|
27
|
+
const issue = data.issues[i];
|
|
28
|
+
lines.push(`${i + 1}. [${issue.severity}] ${issue.title}`);
|
|
29
|
+
lines.push(` Category: ${issue.category}`);
|
|
30
|
+
lines.push(` ${issue.description}`);
|
|
31
|
+
lines.push(` Fix: use guardrail_fix with check_key="${issue.check_key}"`);
|
|
32
|
+
lines.push("");
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return {
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: "text",
|
|
43
|
+
text: `Failed to get issues: ${err instanceof Error ? err.message : "Unknown error"}`,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { scanLocalDirectory } from "../lib/local-scanner.js";
|
|
3
|
+
import { runAllChecks } from "../lib/checks.js";
|
|
4
|
+
import { calculateGrade } from "../lib/grade.js";
|
|
5
|
+
import { apiClient } from "../lib/api-client.js";
|
|
6
|
+
const CHECK_LABELS = {
|
|
7
|
+
stripe_key_exposed: "Stripe secret key exposed in code",
|
|
8
|
+
webhook_no_verify: "Webhook signature not verified",
|
|
9
|
+
password_plaintext: "Password stored in plaintext",
|
|
10
|
+
no_rate_limit: "No rate limiting on auth endpoints",
|
|
11
|
+
env_not_gitignored: ".env file not in .gitignore",
|
|
12
|
+
api_key_hardcoded: "API key hardcoded in source code",
|
|
13
|
+
no_https: "HTTPS not configured",
|
|
14
|
+
no_privacy_policy: "No privacy policy found",
|
|
15
|
+
};
|
|
16
|
+
export function registerScanTool(server) {
|
|
17
|
+
server.tool("guardrail_scan", "Scan your local project directory for security issues. Checks for exposed API keys, hardcoded secrets, missing rate limiting, and more. Results are saved to your GuardRail dashboard.", {
|
|
18
|
+
directory: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Directory to scan. Defaults to the current working directory."),
|
|
22
|
+
}, async ({ directory }) => {
|
|
23
|
+
const dir = directory || process.cwd();
|
|
24
|
+
// 1. Scan local files
|
|
25
|
+
const files = await scanLocalDirectory(dir);
|
|
26
|
+
if (files.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
content: [
|
|
29
|
+
{
|
|
30
|
+
type: "text",
|
|
31
|
+
text: `No scannable files found in ${dir}. Make sure you're in a project directory with source code files (.js, .ts, .py, etc.).`,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// 2. Run checks locally
|
|
37
|
+
const checkResults = runAllChecks(files);
|
|
38
|
+
const gradeResult = calculateGrade(checkResults);
|
|
39
|
+
// 3. Try to save to API (non-blocking)
|
|
40
|
+
let savedToCloud = false;
|
|
41
|
+
let sdkConnected = false;
|
|
42
|
+
try {
|
|
43
|
+
if (apiClient.isConfigured()) {
|
|
44
|
+
const result = await apiClient.post("/api/mcp/scan", {
|
|
45
|
+
checkResults,
|
|
46
|
+
grade: gradeResult.grade,
|
|
47
|
+
passCount: gradeResult.passCount,
|
|
48
|
+
failCount: gradeResult.failCount,
|
|
49
|
+
criticalCount: gradeResult.criticalCount,
|
|
50
|
+
warningCount: gradeResult.warningCount,
|
|
51
|
+
summary: gradeResult.summary,
|
|
52
|
+
filesScanned: files.length,
|
|
53
|
+
});
|
|
54
|
+
savedToCloud = true;
|
|
55
|
+
sdkConnected = result?.sdkConnected ?? false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// API save failed, but local results are still valid
|
|
60
|
+
}
|
|
61
|
+
// 4. Format output
|
|
62
|
+
const failedChecks = checkResults.filter((r) => r.status === "fail");
|
|
63
|
+
const lines = [];
|
|
64
|
+
lines.push(`Scan complete! Grade: ${gradeResult.grade}`);
|
|
65
|
+
lines.push(`${gradeResult.passCount}/${checkResults.length} checks passed`);
|
|
66
|
+
lines.push("");
|
|
67
|
+
if (failedChecks.length === 0) {
|
|
68
|
+
lines.push("All checks passed! Your code looks secure.");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
lines.push(`${failedChecks.length} issue${failedChecks.length > 1 ? "s" : ""} found:`);
|
|
72
|
+
lines.push("");
|
|
73
|
+
for (const check of failedChecks) {
|
|
74
|
+
const label = CHECK_LABELS[check.check_key] || check.check_key;
|
|
75
|
+
lines.push(`- [${check.severity}] ${label}`);
|
|
76
|
+
}
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push("Use guardrail_fix with check_key to get detailed fix steps.");
|
|
79
|
+
}
|
|
80
|
+
if (savedToCloud) {
|
|
81
|
+
lines.push("");
|
|
82
|
+
lines.push("Results saved to your GuardRail dashboard.");
|
|
83
|
+
}
|
|
84
|
+
else if (!apiClient.isConfigured()) {
|
|
85
|
+
lines.push("");
|
|
86
|
+
lines.push("Note: Set GUARDRAIL_PROJECT_KEY to save results to your dashboard.");
|
|
87
|
+
}
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push(`Files scanned: ${files.length}`);
|
|
90
|
+
// SDK setup prompt (only when API saved and SDK not connected)
|
|
91
|
+
if (savedToCloud && !sdkConnected) {
|
|
92
|
+
lines.push("");
|
|
93
|
+
lines.push("---");
|
|
94
|
+
lines.push("Want real-time protection? Install the Guardrail SDK to detect brute force attacks, suspicious logins, and traffic spikes while your app runs.");
|
|
95
|
+
lines.push(" npm install guardrail-sdk");
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { apiClient } from "../lib/api-client.js";
|
|
2
|
+
export function registerStatusTool(server) {
|
|
3
|
+
server.tool("guardrail_status", "Get the current security status of your GuardRail project including the latest scan grade, uptime, and SSL certificate status.", {}, async () => {
|
|
4
|
+
if (!apiClient.isConfigured()) {
|
|
5
|
+
return {
|
|
6
|
+
content: [
|
|
7
|
+
{ type: "text", text: apiClient.getConfigError() },
|
|
8
|
+
],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const data = await apiClient.get("/api/mcp/status");
|
|
13
|
+
const lines = [];
|
|
14
|
+
lines.push(`Project: ${data.project.name}`);
|
|
15
|
+
if (data.latestScan) {
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push(`Grade: ${data.latestScan.grade} (${data.latestScan.summary})`);
|
|
18
|
+
lines.push(`Checks: ${data.latestScan.passedChecks}/${data.latestScan.totalChecks} passed`);
|
|
19
|
+
lines.push(`Last scan: ${data.latestScan.scannedAt} (${data.latestScan.source})`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
lines.push("");
|
|
23
|
+
lines.push("No scans yet. Run guardrail_scan to scan your project.");
|
|
24
|
+
}
|
|
25
|
+
if (data.monitoring) {
|
|
26
|
+
lines.push("");
|
|
27
|
+
if (data.monitoring.uptimePercentage != null) {
|
|
28
|
+
lines.push(`Uptime: ${data.monitoring.uptimePercentage}% (${data.monitoring.uptimeStatus})`);
|
|
29
|
+
}
|
|
30
|
+
if (data.monitoring.sslDaysRemaining != null) {
|
|
31
|
+
lines.push(`SSL: ${data.monitoring.sslDaysRemaining}d remaining (${data.monitoring.sslStatus})`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return {
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: "text",
|
|
43
|
+
text: `Failed to get status: ${err instanceof Error ? err.message : "Unknown error"}`,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "guardrail-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GuardRail MCP server for Claude Code — security scanning from your editor",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": "./dist/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.11.0",
|
|
16
|
+
"zod": "^3.23.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22.0.0",
|
|
20
|
+
"typescript": "^5.7.0"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18.0.0"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT"
|
|
26
|
+
}
|