@gianmarcomaz/vantyr 1.0.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 +808 -0
- package/bin/vantyr.js +6 -0
- package/package.json +50 -0
- package/src/cli.js +148 -0
- package/src/config/localScanner.js +50 -0
- package/src/fetcher/github.js +155 -0
- package/src/output/json.js +64 -0
- package/src/output/sarif.js +243 -0
- package/src/output/terminal.js +130 -0
- package/src/scanner/commandInjection.js +427 -0
- package/src/scanner/credentialLeaks.js +187 -0
- package/src/scanner/index.js +62 -0
- package/src/scanner/inputValidation.js +243 -0
- package/src/scanner/networkExposure.js +302 -0
- package/src/scanner/specCompliance.js +243 -0
- package/src/scanner/toolPoisoning.js +248 -0
- package/src/scoring/trustScore.js +82 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Validation Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Distinguishes between Type Validation (Zod, Joi, basic schemas) and
|
|
5
|
+
* Security Validation (path containment, URL allowlists, parameterized queries).
|
|
6
|
+
*
|
|
7
|
+
* Skips Command Injection patterns to avoid double-counting with the CI module.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const TYPE_VALIDATION_RE = /z\.(?:string|number|boolean|object|array|enum)|Joi\.(?:string|number)|joi|yup|superstruct|valibot|inputSchema|input_schema|"type"\s*:\s*"string"/i;
|
|
11
|
+
|
|
12
|
+
// Common variable names that represent direct MCP / LLM tool input.
|
|
13
|
+
// Used in "direct-call" sink patterns (bare identifier, no property access).
|
|
14
|
+
// Kept intentionally specific to avoid flagging readFile(config) or fetch(url)
|
|
15
|
+
// where those variables come from application config rather than tool input.
|
|
16
|
+
// Property-access patterns (ending in [\.\[]) keep the broader [a-zA-Z0-9_]+
|
|
17
|
+
// because args.path / input.url etc. are structurally tool-input shaped.
|
|
18
|
+
const MCP_INPUT_NAMES = '(?:args|params|input|validatedArgs|toolInput|userInput|llmInput|req(?:uest)?|body|payload|data|parsed|toolArgs|callArgs|handlerArgs)';
|
|
19
|
+
|
|
20
|
+
const DANGEROUS_SINKS = [
|
|
21
|
+
// ── Path traversal ──
|
|
22
|
+
{
|
|
23
|
+
// Direct-call: restrict to known MCP input variable names to avoid FP on
|
|
24
|
+
// readFile(config), readFile(filename), readFile(p), etc.
|
|
25
|
+
regex: new RegExp(`(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|appendFile|appendFileSync)\\s*\\(\\s*${MCP_INPUT_NAMES}\\b`),
|
|
26
|
+
name: "File operation with tool input",
|
|
27
|
+
type: "file"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
// Property-access: keep broad identifier — args.path, input.file, etc.
|
|
31
|
+
regex: /(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream)\s*\(\s*(?:args|params|input|validatedArgs|[a-zA-Z0-9_]+)\s*[\.\[]/,
|
|
32
|
+
name: "File operation with tool input property",
|
|
33
|
+
type: "file"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
// Python open(): restrict to MCP input names — open(filepath) is too common
|
|
37
|
+
regex: new RegExp(`open\\s*\\(\\s*${MCP_INPUT_NAMES}\\b`),
|
|
38
|
+
name: "Python open() with tool input",
|
|
39
|
+
type: "file"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
regex: /fs\.\w+\s*\(\s*`[^`]*\$\{.*(?:args|params|input)/,
|
|
43
|
+
name: "File path built from template literal with tool input",
|
|
44
|
+
type: "file"
|
|
45
|
+
},
|
|
46
|
+
// ── SSRF ──
|
|
47
|
+
{
|
|
48
|
+
// Direct-call: restrict to known MCP input names — fetch(url) is extremely
|
|
49
|
+
// common in non-tool-handler code and generates many FPs otherwise.
|
|
50
|
+
regex: new RegExp(`(?:fetch|axios\\.get|axios\\.post|axios|got|request|http\\.get|https\\.get|urllib\\.request)\\s*\\(\\s*${MCP_INPUT_NAMES}\\b`),
|
|
51
|
+
name: "HTTP request with tool input URL",
|
|
52
|
+
type: "network"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
// Property-access: keep broad — fetch(args.url), axios(input.endpoint), etc.
|
|
56
|
+
regex: /(?:fetch|axios|got|request|http\.get|https\.get)\s*\(\s*(?:args|params|input|validatedArgs|[a-zA-Z0-9_]+)\s*[\.\[]/,
|
|
57
|
+
name: "HTTP request with tool input property",
|
|
58
|
+
type: "network"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
regex: /(?:fetch|axios|got|request)\s*\(\s*`([^`]+)?\$\{.*(?:args|params|input)/,
|
|
62
|
+
name: "URL built from template literal with tool input",
|
|
63
|
+
type: "network"
|
|
64
|
+
},
|
|
65
|
+
// ── SQL injection ──
|
|
66
|
+
{
|
|
67
|
+
regex: /\.query\s*\(\s*`[^`]*\$\{.*(?:args|params|input)/,
|
|
68
|
+
name: "SQL query with interpolated tool input",
|
|
69
|
+
type: "sql"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
regex: /\.query\s*\(\s*['"][^'"]*['"]\s*\+\s*(?:args|params|input)/,
|
|
73
|
+
name: "SQL query with concatenated tool input",
|
|
74
|
+
type: "sql"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
regex: /(?:execute|exec|run)\s*\(\s*`[^`]*\$\{.*(?:args|params|input)/,
|
|
78
|
+
name: "Database execution with interpolated tool input",
|
|
79
|
+
type: "sql"
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const DISPATCH_PATTERNS = [
|
|
84
|
+
{
|
|
85
|
+
regex: /\w+\s*\[\s*(?:args|params|input)\s*\.\s*\w+\s*\]\s*\(/,
|
|
86
|
+
name: "Dynamic dispatch from user input",
|
|
87
|
+
severity: "high",
|
|
88
|
+
remediation: "Do not use user-supplied values to dynamically select and invoke functions. Use an explicit allowlist map instead.",
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const DYNAMIC_ACCESS_REGEX = /args\s*\[\s*\w+\s*\]/;
|
|
93
|
+
const SPREAD_REGEX = /\.\.\.(?:args|params|input)\b/;
|
|
94
|
+
const TEST_FILE_RE = /(?:[\\/](?:__tests__|tests?|spec|__mocks__|mock|fixture)[\\/]|\.(?:test|spec)\.[jt]sx?$)/i;
|
|
95
|
+
const SAFE_VARIABLE_RE = /\b(?:redact|mask|sanitiz|log|debug|print|censor|hide|obfuscat|display|format|stringify|output|response|result|reply)\w*\s*\[/i;
|
|
96
|
+
const CONFIG_SOURCE_RE = /\b(?:config|settings|env|process\.env|os\.environ|serverConfig|appConfig|options|defaults|constants)\b/i;
|
|
97
|
+
const RESPONSE_BUILD_RE = /\b(?:response|result|output|reply|data|body|payload|ret|res)\s*\[/i;
|
|
98
|
+
|
|
99
|
+
function isDynamicAccessSafe(line, filePath) {
|
|
100
|
+
if (TEST_FILE_RE.test(filePath)) return true;
|
|
101
|
+
if (SAFE_VARIABLE_RE.test(line)) return true;
|
|
102
|
+
if (CONFIG_SOURCE_RE.test(line) && !/exec|spawn|eval|system/i.test(line)) return true;
|
|
103
|
+
if (RESPONSE_BUILD_RE.test(line)) return true;
|
|
104
|
+
if (/args\s*\[\s*\d+\s*\]/.test(line) && !/exec|spawn|eval|system/i.test(line)) return true;
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isSpreadSafe(line, filePath) {
|
|
109
|
+
if (TEST_FILE_RE.test(filePath)) return true;
|
|
110
|
+
if (!/exec|spawn|eval|system/i.test(line)) {
|
|
111
|
+
if (/(?:vi|jest)\.fn|mock/i.test(line)) return true;
|
|
112
|
+
if (/console\.|log\(|print\(/i.test(line)) return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function analyzeInputValidation(files) {
|
|
118
|
+
const findings = [];
|
|
119
|
+
let hasInputSchema = false;
|
|
120
|
+
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
const content = file.content;
|
|
123
|
+
if (!/\.(?:js|ts|jsx|tsx|py|go|rs)$/.test(file.path)) continue;
|
|
124
|
+
|
|
125
|
+
if (/inputSchema|input_schema|parameters\s*[:=]\s*\{|InputSchema|mcp\.Property|WithDescription|Required\s*[:=]/.test(content)) {
|
|
126
|
+
hasInputSchema = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Test files are not production code — skip sink scanning entirely.
|
|
130
|
+
// The inputSchema check above still runs so test fixtures that define
|
|
131
|
+
// schemas contribute to the global hasInputSchema flag correctly.
|
|
132
|
+
if (TEST_FILE_RE.test(file.path)) continue;
|
|
133
|
+
|
|
134
|
+
const lines = content.split("\n");
|
|
135
|
+
const hasTypeValidation = TYPE_VALIDATION_RE.test(content);
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < lines.length; i++) {
|
|
138
|
+
const line = lines[i];
|
|
139
|
+
|
|
140
|
+
/* ── Pass 1 & 2 combined: Find sinks and check context ── */
|
|
141
|
+
for (const sink of DANGEROUS_SINKS) {
|
|
142
|
+
const match = line.match(sink.regex);
|
|
143
|
+
if (match) {
|
|
144
|
+
const blockContext = Math.max(0, i - 15);
|
|
145
|
+
const contextUpstream = lines.slice(blockContext, i + 1).join("\n");
|
|
146
|
+
|
|
147
|
+
if (sink.type === "file") {
|
|
148
|
+
const hasResolve = /path\.resolve|os\.path\.realpath/.test(contextUpstream);
|
|
149
|
+
const hasStartsWith = /\.startsWith|indexOf|includes/.test(contextUpstream);
|
|
150
|
+
const hasJoin = /path\.join|os\.path\.join/.test(contextUpstream);
|
|
151
|
+
|
|
152
|
+
// We also need to be careful not to flag static requires
|
|
153
|
+
if (/fs\.readFile\s*\(\s*['"]/.test(line)) continue;
|
|
154
|
+
|
|
155
|
+
if (hasResolve && hasStartsWith) {
|
|
156
|
+
// SAFE
|
|
157
|
+
} else if (hasResolve && !hasStartsWith) {
|
|
158
|
+
findings.push({ severity: "medium", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: "Path is resolved but not confined.", remediation: "Add .startsWith(allowedBaseDir) to prevent path traversal." });
|
|
159
|
+
} else if (hasJoin && !hasStartsWith) {
|
|
160
|
+
findings.push({ severity: "medium", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: "path.join doesn't prevent traversal.", remediation: "Use path.resolve() and .startsWith(allowedBaseDir)." });
|
|
161
|
+
} else if (hasTypeValidation) {
|
|
162
|
+
findings.push({ severity: "medium", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: `Tool input passes type check but has no path traversal protection before being passed to file operation.`, remediation: "Add path.resolve() + startsWith(allowedBaseDir) to confine file access to an allowed directory." });
|
|
163
|
+
} else {
|
|
164
|
+
findings.push({ severity: "high", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: `Unsanitized file path from tool input — path traversal risk.`, remediation: "Add path.resolve() + startsWith(allowedBaseDir) to confine file access to an allowed directory." });
|
|
165
|
+
}
|
|
166
|
+
} else if (sink.type === "network") {
|
|
167
|
+
const hasNewURL = /new\s+URL/.test(contextUpstream);
|
|
168
|
+
const hasAllowlist = /allowlist|whitelist|includes|indexOf|==|===/.test(contextUpstream);
|
|
169
|
+
|
|
170
|
+
if (hasNewURL && hasAllowlist) {
|
|
171
|
+
// SAFE
|
|
172
|
+
} else if (hasAllowlist && !hasNewURL) {
|
|
173
|
+
// Also basically safe if we do string matching
|
|
174
|
+
// SAFE
|
|
175
|
+
} else if (hasNewURL && !hasAllowlist) {
|
|
176
|
+
findings.push({ severity: "medium", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: "URL is parsed but not validated.", remediation: "Validate URL protocol and hostname against an allowlist." });
|
|
177
|
+
} else if (match[1] && /^https?:\/\//.test(match[1])) {
|
|
178
|
+
// Hardcoded base URL in template string
|
|
179
|
+
findings.push({ severity: "low", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: "Base URL is hardcoded, but dynamic path should be validated.", remediation: "Ensure the path segment doesn't allow traversal out of the base URL's API space." });
|
|
180
|
+
} else if (hasTypeValidation) {
|
|
181
|
+
findings.push({ severity: "medium", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: `Tool input passes type check but has no SSRF protection before being passed to network request.`, remediation: "Validate URL protocol (https only) and hostname against an allowlist. Block requests to internal IP ranges." });
|
|
182
|
+
} else {
|
|
183
|
+
findings.push({ severity: "high", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: `Unsanitized URL from tool input — SSRF risk.`, remediation: "Validate URL protocol (https only) and hostname against an allowlist." });
|
|
184
|
+
}
|
|
185
|
+
} else if (sink.type === "sql") {
|
|
186
|
+
if (hasTypeValidation) {
|
|
187
|
+
findings.push({ severity: "high", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: `Tool input passes type check but is vulnerable to SQL injection.`, remediation: "Use parameterized queries (e.g. $1, ?) instead of string interpolation for database operations." });
|
|
188
|
+
} else {
|
|
189
|
+
findings.push({ severity: "critical", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: `SQL injection risk from tool input.`, remediation: "Use parameterized queries (e.g. $1, ?) instead of string interpolation for database operations." });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
break; // one sink finding per line
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── Dynamic dispatch ── */
|
|
198
|
+
for (const dp of DISPATCH_PATTERNS) {
|
|
199
|
+
if (dp.regex.test(line)) {
|
|
200
|
+
findings.push({ severity: dp.severity, file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: `${dp.name} — could allow arbitrary function invocation.`, remediation: dp.remediation });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ── Dynamic property access — non-sinks ── */
|
|
205
|
+
if (DYNAMIC_ACCESS_REGEX.test(line)) {
|
|
206
|
+
if (!isDynamicAccessSafe(line, file.path)) {
|
|
207
|
+
const isToolHandler = /server\.tool|\.addTool|@server\.tool|@mcp\.tool|tool_handler|ToolHandler/.test(content);
|
|
208
|
+
if (isToolHandler && !hasTypeValidation) {
|
|
209
|
+
findings.push({ severity: "medium", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: "Dynamic property access on args in tool handler — validate inputs before use.", remediation: "Validate all tool inputs using a schema validation library like Zod." });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ── Spread patterns ── */
|
|
215
|
+
if (SPREAD_REGEX.test(line)) {
|
|
216
|
+
if (!isSpreadSafe(line, file.path)) {
|
|
217
|
+
const isToolHandler = /server\.tool|\.addTool|@server\.tool|@mcp\.tool|tool_handler|ToolHandler/.test(content);
|
|
218
|
+
if (isToolHandler && !hasTypeValidation) {
|
|
219
|
+
findings.push({ severity: "low", file: file.path, line: i + 1, snippet: line.trim().slice(0, 200), message: "Spreading args in tool handler — verify all spread targets are safe.", remediation: "Validate all tool inputs before spreading." });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!hasInputSchema) {
|
|
227
|
+
findings.push({
|
|
228
|
+
severity: "high",
|
|
229
|
+
file: "project",
|
|
230
|
+
line: null,
|
|
231
|
+
snippet: "",
|
|
232
|
+
message: "No inputSchema definitions found. The server accepts arbitrary input without constraints.",
|
|
233
|
+
remediation: "Declare an inputSchema for all tools. Use JSON Schema to constrain inputs, specifying types, required fields, and enum values.",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Filter out potential duplicate findings that are overly broad (like if multiple sinks triggered on same line)
|
|
238
|
+
// We already have deduplication at the terminal level, but we can do a quick unique filter here too if needed.
|
|
239
|
+
|
|
240
|
+
return findings.map(f => ({ ...f, category: 'IV' }));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export { analyzeInputValidation };
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Exposure Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Two-step analysis:
|
|
5
|
+
* 1. Detect 0.0.0.0 / :: / INADDR_ANY bindings (same patterns as before)
|
|
6
|
+
* 2. For each hit, check the SAME file + nearby files for authentication
|
|
7
|
+
* middleware, webhook/bot indicators, or signing verification.
|
|
8
|
+
*
|
|
9
|
+
* Context-aware:
|
|
10
|
+
* - Test files are excluded (not production code)
|
|
11
|
+
* - CLI/build tools are excluded
|
|
12
|
+
*
|
|
13
|
+
* Severity assignment:
|
|
14
|
+
* - 0.0.0.0 with NO auth detected → critical
|
|
15
|
+
* - 0.0.0.0 in a webhook/bot file → medium
|
|
16
|
+
* - 0.0.0.0 WITH auth detected → low
|
|
17
|
+
* - 127.0.0.1 / localhost / ::1 → pass (no finding)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/* ════════════════════════════════════════════════════
|
|
21
|
+
Detection Patterns — same as original, DO NOT REMOVE
|
|
22
|
+
════════════════════════════════════════════════════ */
|
|
23
|
+
|
|
24
|
+
const CRITICAL_PATTERNS = [
|
|
25
|
+
// JavaScript/TypeScript
|
|
26
|
+
{ regex: /\.listen\s*\([^)]*['"]0\.0\.0\.0['"]/, lang: "js" },
|
|
27
|
+
{ regex: /\.listen\s*\([^)]*['"]::['"]/, lang: "js" },
|
|
28
|
+
{ regex: /host\s*[:=]\s*['"]0\.0\.0\.0['"]/, lang: "any" },
|
|
29
|
+
{ regex: /host\s*[:=]\s*['"]::['"]/, lang: "any" },
|
|
30
|
+
{ regex: /host\s*[:=]\s*['"]["']/, lang: "any" }, // empty string = 0.0.0.0
|
|
31
|
+
// Python
|
|
32
|
+
{ regex: /\.bind\s*\(\s*\(\s*['"]0\.0\.0\.0['"]/, lang: "py" },
|
|
33
|
+
{ regex: /\.bind\s*\(\s*\(\s*['"]::['"]/, lang: "py" },
|
|
34
|
+
// Go
|
|
35
|
+
{ regex: /net\.Listen\s*\([^)]*['"]0\.0\.0\.0/, lang: "go" },
|
|
36
|
+
{ regex: /INADDR_ANY/, lang: "any" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const SAFE_PATTERNS = [
|
|
40
|
+
/['"]127\.0\.0\.1['"]/,
|
|
41
|
+
/['"]localhost['"]/,
|
|
42
|
+
/['"]::1['"]/,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const WARNING_PATTERNS = [
|
|
46
|
+
{ regex: /host\s*[:=]\s*(?:process\.env|os\.environ|os\.Getenv)/, message: "Host from environment variable" },
|
|
47
|
+
{ regex: /host\s*[:=]\s*(?:config|settings|options)\./, message: "Host from config (verify default)" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const URL_PATTERNS = [
|
|
51
|
+
{
|
|
52
|
+
regex: /['"`]http:\/\/(?!(?:localhost|127\.0\.0\.1|::1|0\.0\.0\.0))[^'"`${}]+['"`]/i,
|
|
53
|
+
severity: "high",
|
|
54
|
+
message: "Unencrypted HTTP communication with external host detected.",
|
|
55
|
+
remediation: "Use HTTPS for all external API communication to prevent man-in-the-middle attacks.",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
regex: /['"`]ws:\/\/(?!(?:localhost|127\.0\.0\.1|::1|0\.0\.0\.0))[^'"`${}]+['"`]/i,
|
|
59
|
+
severity: "high",
|
|
60
|
+
message: "Unencrypted WebSocket (ws://) communication with external host detected.",
|
|
61
|
+
remediation: "Use secure WebSockets (wss://) for all external ongoing connections.",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
regex: /['"`](?:http|ws):\/\/\$\{/i,
|
|
65
|
+
severity: "medium",
|
|
66
|
+
message: "Dynamic unencrypted URL detected (http/ws).",
|
|
67
|
+
remediation: "Ensure the dynamically constructed URL uses HTTPS/WSS if connecting to external hosts.",
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/* ════════════════════════════════════════════════════
|
|
72
|
+
Authentication & Webhook Indicator Patterns
|
|
73
|
+
════════════════════════════════════════════════════ */
|
|
74
|
+
|
|
75
|
+
const AUTH_PATTERNS = [
|
|
76
|
+
// Python decorators & middleware
|
|
77
|
+
/@requires_auth/i,
|
|
78
|
+
/@authenticate/i,
|
|
79
|
+
/verify_signature/i,
|
|
80
|
+
/verify_slack_signature/i,
|
|
81
|
+
/signing_secret/i,
|
|
82
|
+
/hmac\./i,
|
|
83
|
+
/token_required/i,
|
|
84
|
+
/api_key_required/i,
|
|
85
|
+
/BasicAuth|BearerAuth/i,
|
|
86
|
+
/OAuth/,
|
|
87
|
+
|
|
88
|
+
// Python auth libraries
|
|
89
|
+
/from\s+(?:fastapi\.security|starlette\.authentication|flask_login|flask_httpauth)/,
|
|
90
|
+
/Depends\s*\(\s*\w*[Aa]uth/,
|
|
91
|
+
|
|
92
|
+
// JS/TS auth middleware & libraries
|
|
93
|
+
/passport\./,
|
|
94
|
+
/jwt\.verify/i,
|
|
95
|
+
/express-jwt/,
|
|
96
|
+
/next-auth/i,
|
|
97
|
+
/clerk/i,
|
|
98
|
+
/supabase\.auth/i,
|
|
99
|
+
/firebase.*auth/i,
|
|
100
|
+
/req\.headers\s*\[\s*['"]authorization['"]\s*\]/i,
|
|
101
|
+
/req\.headers\.authorization/i,
|
|
102
|
+
/apiKey|api_key/i,
|
|
103
|
+
/bearerToken|bearer_token/i,
|
|
104
|
+
|
|
105
|
+
// General auth header checks
|
|
106
|
+
/['"]Authorization['"]/,
|
|
107
|
+
/['"]X-API-Key['"]/i,
|
|
108
|
+
/['"]Bearer\s/i,
|
|
109
|
+
|
|
110
|
+
// Middleware with "auth" in the name
|
|
111
|
+
/middleware.*auth/i,
|
|
112
|
+
/auth.*middleware/i,
|
|
113
|
+
/use\s*\(\s*\w*[Aa]uth/,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const WEBHOOK_FILE_INDICATORS = [
|
|
117
|
+
// Filename patterns
|
|
118
|
+
/webhook/i,
|
|
119
|
+
/bot\./i,
|
|
120
|
+
/callback/i,
|
|
121
|
+
/_bot\b/i,
|
|
122
|
+
/slack_bot|slackbot/i,
|
|
123
|
+
/whatsapp/i,
|
|
124
|
+
/telegram/i,
|
|
125
|
+
/discord_bot|discordbot/i,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const WEBHOOK_CONTENT_INDICATORS = [
|
|
129
|
+
/import\s+.*slack_sdk/,
|
|
130
|
+
/from\s+slack_sdk/,
|
|
131
|
+
/require\s*\(\s*['"]@slack\/bolt['"]\)/,
|
|
132
|
+
/require\s*\(\s*['"]twilio['"]\)/,
|
|
133
|
+
/from\s+twilio/,
|
|
134
|
+
/whatsapp/i,
|
|
135
|
+
/SlackRequestHandler|SlackEventAdapter/i,
|
|
136
|
+
/verify_slack_request|verify_slack_signature/i,
|
|
137
|
+
/TwilioClient/i,
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
/* ════════════════════════════════════════════════════
|
|
141
|
+
Helpers
|
|
142
|
+
════════════════════════════════════════════════════ */
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if a file (by content) contains authentication patterns.
|
|
146
|
+
*/
|
|
147
|
+
function fileHasAuth(content) {
|
|
148
|
+
return AUTH_PATTERNS.some((re) => re.test(content));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if a file is a webhook/bot receiver (by path OR content).
|
|
153
|
+
*/
|
|
154
|
+
function fileIsWebhook(filePath, content) {
|
|
155
|
+
const pathMatch = WEBHOOK_FILE_INDICATORS.some((re) => re.test(filePath));
|
|
156
|
+
const contentMatch = WEBHOOK_CONTENT_INDICATORS.some((re) => re.test(content));
|
|
157
|
+
return pathMatch || contentMatch;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build a directory→files lookup for checking "nearby" files in the same dir.
|
|
162
|
+
*/
|
|
163
|
+
function buildDirIndex(files) {
|
|
164
|
+
const index = {};
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
const dir = file.path.replace(/[/\\][^/\\]+$/, "") || ".";
|
|
167
|
+
if (!index[dir]) index[dir] = [];
|
|
168
|
+
index[dir].push(file);
|
|
169
|
+
}
|
|
170
|
+
return index;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if ANY file in the same directory has auth patterns.
|
|
175
|
+
*/
|
|
176
|
+
function dirHasAuth(filePath, dirIndex) {
|
|
177
|
+
const dir = filePath.replace(/[/\\][^/\\]+$/, "") || ".";
|
|
178
|
+
const siblings = dirIndex[dir] || [];
|
|
179
|
+
return siblings.some((f) => fileHasAuth(f.content));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Test file paths — not production code */
|
|
183
|
+
const TEST_FILE_RE =
|
|
184
|
+
/(?:[\\/](?:__tests__|tests?|spec|__mocks__|mock|fixture|e2e|testdata|testutil)[\\/]|[\\/](?:.*_test|.*\.test|.*\.spec)\.[a-z]+$)/i;
|
|
185
|
+
|
|
186
|
+
/** CLI-tool / build-script / dev-tooling paths */
|
|
187
|
+
const CLI_TOOL_RE =
|
|
188
|
+
/(?:^|[\\/])(?:cmd|cli|scripts|tools|bin|hack|contrib)[\\/]/i;
|
|
189
|
+
|
|
190
|
+
/* ════════════════════════════════════════════════════
|
|
191
|
+
Main Analyzer
|
|
192
|
+
════════════════════════════════════════════════════ */
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {Array<{path: string, content: string}>} files
|
|
196
|
+
* @returns {{ score: number, status: string, findings: Array }}
|
|
197
|
+
*/
|
|
198
|
+
function analyzeNetworkExposure(files) {
|
|
199
|
+
const findings = [];
|
|
200
|
+
const dirIndex = buildDirIndex(files);
|
|
201
|
+
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
const lines = file.content.split("\n");
|
|
204
|
+
const isTestFile = TEST_FILE_RE.test(file.path);
|
|
205
|
+
const isCliTool = CLI_TOOL_RE.test(file.path);
|
|
206
|
+
|
|
207
|
+
// Skip test files and CLI tools entirely — not production server code
|
|
208
|
+
if (isTestFile || isCliTool) continue;
|
|
209
|
+
|
|
210
|
+
const thisFileHasAuth = fileHasAuth(file.content);
|
|
211
|
+
const thisFileIsWebhook = fileIsWebhook(file.path, file.content);
|
|
212
|
+
const nearbyAuth = dirHasAuth(file.path, dirIndex);
|
|
213
|
+
|
|
214
|
+
for (let i = 0; i < lines.length; i++) {
|
|
215
|
+
const line = lines[i];
|
|
216
|
+
const trimmed = line.trim();
|
|
217
|
+
|
|
218
|
+
// Skip comments and documentation examples
|
|
219
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*") || trimmed.startsWith("<!--")) continue;
|
|
220
|
+
// Skip common documentation keywords
|
|
221
|
+
if (/example|documentation|placeholder/i.test(line)) continue;
|
|
222
|
+
|
|
223
|
+
/* ── Step 1: detect 0.0.0.0 binding ── */
|
|
224
|
+
for (const pattern of CRITICAL_PATTERNS) {
|
|
225
|
+
if (!pattern.regex.test(line)) continue;
|
|
226
|
+
// Skip if safe pattern on same line
|
|
227
|
+
if (SAFE_PATTERNS.some((sp) => sp.test(line))) continue;
|
|
228
|
+
|
|
229
|
+
/* ── Step 2: contextual severity ── */
|
|
230
|
+
if (thisFileIsWebhook) {
|
|
231
|
+
findings.push({
|
|
232
|
+
severity: "medium",
|
|
233
|
+
file: file.path,
|
|
234
|
+
line: i + 1,
|
|
235
|
+
snippet: line.trim(),
|
|
236
|
+
message:
|
|
237
|
+
"Webhook receiver binds to all network interfaces. Verify that incoming requests are validated using platform signing secrets (e.g., Slack signing secret, WhatsApp verification token).",
|
|
238
|
+
remediation:
|
|
239
|
+
"Validate webhook signatures on every incoming request to prevent unauthorized access.",
|
|
240
|
+
});
|
|
241
|
+
} else if (thisFileHasAuth || nearbyAuth) {
|
|
242
|
+
findings.push({
|
|
243
|
+
severity: "low",
|
|
244
|
+
file: file.path,
|
|
245
|
+
line: i + 1,
|
|
246
|
+
snippet: line.trim(),
|
|
247
|
+
message:
|
|
248
|
+
"Server binds to all network interfaces. Authentication middleware detected — verify it covers all MCP endpoints.",
|
|
249
|
+
remediation:
|
|
250
|
+
"Ensure authentication is enforced on all tool execution endpoints, not just some routes.",
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
findings.push({
|
|
254
|
+
severity: "critical",
|
|
255
|
+
file: file.path,
|
|
256
|
+
line: i + 1,
|
|
257
|
+
snippet: line.trim(),
|
|
258
|
+
message:
|
|
259
|
+
"Server binds to all network interfaces with no authentication detected. Anyone who can reach this port can execute MCP operations without authorization.",
|
|
260
|
+
remediation:
|
|
261
|
+
"Either bind to 127.0.0.1 for local-only access, or add authentication middleware (API key validation, OAuth, or request signing) to protect network-exposed endpoints.",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* ── Warning patterns (env/config host) ── */
|
|
267
|
+
for (const pattern of WARNING_PATTERNS) {
|
|
268
|
+
if (pattern.regex.test(line)) {
|
|
269
|
+
findings.push({
|
|
270
|
+
severity: "medium",
|
|
271
|
+
file: file.path,
|
|
272
|
+
line: i + 1,
|
|
273
|
+
snippet: line.trim(),
|
|
274
|
+
message: pattern.message + " — could be safe or unsafe depending on deployment.",
|
|
275
|
+
remediation:
|
|
276
|
+
"Ensure the default value for the host binding is 127.0.0.1 or localhost. Document the expected deployment configuration.",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ── Unencrypted external URL patterns ── */
|
|
282
|
+
for (const pattern of URL_PATTERNS) {
|
|
283
|
+
if (pattern.regex.test(line)) {
|
|
284
|
+
findings.push({
|
|
285
|
+
severity: pattern.severity,
|
|
286
|
+
file: file.path,
|
|
287
|
+
line: i + 1,
|
|
288
|
+
snippet: line.trim().slice(0, 150),
|
|
289
|
+
message: pattern.message,
|
|
290
|
+
remediation: pattern.remediation,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Return raw findings with category tagged
|
|
299
|
+
return findings.map(f => ({ ...f, category: 'NE' }));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export { analyzeNetworkExposure };
|