@saiteja1123/mcp-server 1.1.2
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/package.json +52 -0
- package/src/api-scan.mjs +58 -0
- package/src/cli.js +143 -0
- package/src/index.js +6 -0
- package/src/lock.mjs +175 -0
- package/src/repo-scan.mjs +200 -0
- package/src/rule-engine/index.js +9 -0
- package/src/rule-engine/localScan.js +58 -0
- package/src/rule-engine/prompt.js +52 -0
- package/src/rule-engine/rules.js +51 -0
- package/src/rule-engine/score.js +22 -0
- package/src/server.js +491 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saiteja1123/mcp-server",
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Vibesecur MCP security scanner - one-folder locking, cross-IDE, Cursor/VSCode/Windsurf",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"vibesecur-mcp": "./src/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node ./src/server.js",
|
|
17
|
+
"dev": "node --watch ./src/server.js",
|
|
18
|
+
"bind": "node ./src/cli.js bind",
|
|
19
|
+
"test": "node --input-type=module --eval \"import('./src/server.js')\""
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.js",
|
|
23
|
+
"./server": "./src/server.js",
|
|
24
|
+
"./lock": "./src/lock.mjs"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
|
+
"fast-glob": "^3.3.3",
|
|
29
|
+
"zod": "^4.3.6"
|
|
30
|
+
},
|
|
31
|
+
"bundledDependencies": [],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20.0.0"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"mcp",
|
|
40
|
+
"security",
|
|
41
|
+
"vibesecur",
|
|
42
|
+
"cursor",
|
|
43
|
+
"windsurf",
|
|
44
|
+
"scanner",
|
|
45
|
+
"supabase-rls"
|
|
46
|
+
],
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/vibesecur/vibesecur"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://vibesecur.com/docs/mcp-setup"
|
|
52
|
+
}
|
package/src/api-scan.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
function sha256Hex(input) {
|
|
5
|
+
return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getMcpSessionId() {
|
|
9
|
+
const fromEnv = process.env.VIBESECUR_SESSION_ID;
|
|
10
|
+
if (typeof fromEnv === 'string' && fromEnv.trim().length > 0) {
|
|
11
|
+
return fromEnv.trim().slice(0, 64);
|
|
12
|
+
}
|
|
13
|
+
const basis = `mcp:${process.env.USER || process.env.USERNAME || 'anon'}:${process.cwd()}`;
|
|
14
|
+
return sha256Hex(basis);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getProjectHashForPath(rootPath) {
|
|
18
|
+
const resolved = path.resolve(rootPath || '.');
|
|
19
|
+
return sha256Hex(`vibesecur:mcp:${resolved}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeApiBase(raw) {
|
|
23
|
+
if (!raw || typeof raw !== 'string') return '';
|
|
24
|
+
const u = raw.trim().replace(/\/$/, '');
|
|
25
|
+
return u.endsWith('/api/v1') ? u : `${u}/api/v1`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function postRemoteLocalScan({
|
|
29
|
+
code,
|
|
30
|
+
lang = 'auto',
|
|
31
|
+
projectRoot = '.',
|
|
32
|
+
platform = 'mcp',
|
|
33
|
+
token,
|
|
34
|
+
} = {}) {
|
|
35
|
+
const apiBase = normalizeApiBase(process.env.VIBESECUR_API_BASE || process.env.VIBESECUR_API_URL || '');
|
|
36
|
+
if (!apiBase) {
|
|
37
|
+
return { skipped: true, reason: 'VIBESECUR_API_BASE not set' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const projectHash = getProjectHashForPath(projectRoot);
|
|
41
|
+
const headers = {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'x-session-id': getMcpSessionId(),
|
|
44
|
+
};
|
|
45
|
+
const bearer = token || process.env.VIBESECUR_AUTH_TOKEN || process.env.VIBESECUR_TOKEN;
|
|
46
|
+
if (bearer) {
|
|
47
|
+
headers.Authorization = bearer.startsWith('Bearer ') ? bearer : `Bearer ${bearer}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const res = await fetch(`${apiBase}/scan/local`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers,
|
|
53
|
+
body: JSON.stringify({ code, lang, platform, projectHash }),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const json = await res.json().catch(() => ({}));
|
|
57
|
+
return { apiBase, status: res.status, ok: res.ok, json };
|
|
58
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* vibesecur-mcp CLI
|
|
4
|
+
* Usage:
|
|
5
|
+
* vibesecur-mcp bind <folder>
|
|
6
|
+
* vibesecur-mcp rebind <folder>
|
|
7
|
+
* vibesecur-mcp status [folder]
|
|
8
|
+
* vibesecur-mcp config <folder>
|
|
9
|
+
* vibesecur-mcp start
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import { createLock, rebindLock, diagnosticLock, readLock } from './lock.mjs';
|
|
15
|
+
|
|
16
|
+
const [, , command, arg] = process.argv;
|
|
17
|
+
|
|
18
|
+
const BOLD = '\x1b[1m';
|
|
19
|
+
const GREEN = '\x1b[32m';
|
|
20
|
+
const RED = '\x1b[31m';
|
|
21
|
+
const CYAN = '\x1b[36m';
|
|
22
|
+
const DIM = '\x1b[2m';
|
|
23
|
+
const RESET = '\x1b[0m';
|
|
24
|
+
|
|
25
|
+
function h(t) { return `${BOLD}${CYAN}${t}${RESET}`; }
|
|
26
|
+
function ok(t) { return `${GREEN}✓ ${t}${RESET}`; }
|
|
27
|
+
function err(t) { return `${RED}✗ ${t}${RESET}`; }
|
|
28
|
+
function dim(t) { return `${DIM}${t}${RESET}`; }
|
|
29
|
+
|
|
30
|
+
const serverPath = fileURLToPath(new URL('./server.js', import.meta.url));
|
|
31
|
+
|
|
32
|
+
function serverCmd() {
|
|
33
|
+
if (serverPath.includes('node_modules')) {
|
|
34
|
+
return { command: 'npx', args: ['-y', '@vibesecur/mcp-server', 'start'] };
|
|
35
|
+
}
|
|
36
|
+
return { command: 'node', args: [serverPath] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function cmdBind() {
|
|
40
|
+
const folder = path.resolve(arg || process.cwd());
|
|
41
|
+
const account = process.env.VIBESECUR_ACCOUNT || 'anonymous';
|
|
42
|
+
console.log(`\n${h('Vibesecur MCP - Bind Install')}`);
|
|
43
|
+
console.log(`Folder : ${folder}`);
|
|
44
|
+
console.log(`Account: ${account}\n`);
|
|
45
|
+
const lock = await createLock({ rootPath: folder, account });
|
|
46
|
+
console.log(ok(`Lock created: ${folder}/.vibesecur/lock.json`));
|
|
47
|
+
console.log(`\n${BOLD}Install token (keep private):${RESET}`);
|
|
48
|
+
console.log(` ${lock.installToken}\n`);
|
|
49
|
+
console.log('Add to MCP config env:');
|
|
50
|
+
console.log(` VIBESECUR_INSTALL_TOKEN=${lock.installToken}`);
|
|
51
|
+
console.log(` VIBESECUR_BOUND_ROOT=${folder}`);
|
|
52
|
+
console.log(`\n${dim(`Run: vibesecur-mcp config ${folder} for IDE snippets`)}\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function cmdRebind() {
|
|
56
|
+
const folder = path.resolve(arg || process.cwd());
|
|
57
|
+
const account = process.env.VIBESECUR_ACCOUNT || 'anonymous';
|
|
58
|
+
console.log(`\n${h('Vibesecur MCP - Rebind Install')}`);
|
|
59
|
+
console.log(`Folder : ${folder}\n`);
|
|
60
|
+
const result = await rebindLock({ rootPath: folder, account });
|
|
61
|
+
console.log(ok('Rebind complete. Previous token is now invalid.'));
|
|
62
|
+
console.log(`Previous: ${dim(result.previousToken || 'none')}`);
|
|
63
|
+
console.log(`\n${BOLD}New install token:${RESET}`);
|
|
64
|
+
console.log(` ${result.newToken}\n`);
|
|
65
|
+
console.log('Update MCP config env:');
|
|
66
|
+
console.log(` VIBESECUR_INSTALL_TOKEN=${result.newToken}`);
|
|
67
|
+
console.log(` VIBESECUR_BOUND_ROOT=${result.boundRoot}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function cmdStatus() {
|
|
71
|
+
const folder = path.resolve(arg || process.cwd());
|
|
72
|
+
console.log(`\n${h('Vibesecur MCP - Lock Diagnostic')}`);
|
|
73
|
+
const diag = await diagnosticLock(folder);
|
|
74
|
+
console.log(diag.healthy ? ok('Lock healthy') : err('Lock has issues'));
|
|
75
|
+
console.log(`Bound root : ${diag.boundRoot || 'unknown'}`);
|
|
76
|
+
console.log(`Account : ${diag.account || 'unknown'}`);
|
|
77
|
+
console.log(`Created : ${diag.createdAt || 'unknown'}`);
|
|
78
|
+
console.log(`RootHash OK : ${diag.rootHash?.ok ? `${GREEN}yes${RESET}` : `${RED}NO${RESET}`}`);
|
|
79
|
+
console.log(`In scope : ${diag.pathInBoundFolder ? `${GREEN}yes${RESET}` : `${RED}NO${RESET}`}`);
|
|
80
|
+
if (diag.issues?.length) {
|
|
81
|
+
console.log(`\n${RED}Issues:${RESET}`);
|
|
82
|
+
diag.issues.forEach((i) => console.log(` - ${i}`));
|
|
83
|
+
console.log(`\nFix: vibesecur-mcp rebind ${diag.boundRoot || folder}`);
|
|
84
|
+
}
|
|
85
|
+
console.log();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function cmdConfig() {
|
|
89
|
+
const folder = path.resolve(arg || process.cwd());
|
|
90
|
+
const lock = await readLock(folder);
|
|
91
|
+
const token = lock?.installToken || 'YOUR_INSTALL_TOKEN';
|
|
92
|
+
|
|
93
|
+
console.log(`\n${h('Vibesecur MCP - IDE Config Snippets')}`);
|
|
94
|
+
console.log(`Folder: ${folder}`);
|
|
95
|
+
if (!lock) console.log(err(`No lock found. Run: vibesecur-mcp bind ${folder}`));
|
|
96
|
+
console.log();
|
|
97
|
+
|
|
98
|
+
const env = {
|
|
99
|
+
VIBESECUR_INSTALL_TOKEN: token,
|
|
100
|
+
VIBESECUR_BOUND_ROOT: folder,
|
|
101
|
+
VIBESECUR_API_BASE: 'https://api.vibesecur.com',
|
|
102
|
+
};
|
|
103
|
+
const sc = serverCmd();
|
|
104
|
+
|
|
105
|
+
console.log(`${BOLD}-- Cursor (.cursor/mcp.json) --${RESET}`);
|
|
106
|
+
console.log(JSON.stringify({ mcpServers: { vibesecur: { command: sc.command, args: sc.args, env } } }, null, 2));
|
|
107
|
+
|
|
108
|
+
console.log(`\n${BOLD}-- VS Code (.vscode/mcp.json) --${RESET}`);
|
|
109
|
+
console.log(JSON.stringify({ servers: { vibesecur: { type: 'stdio', command: sc.command, args: sc.args, env } } }, null, 2));
|
|
110
|
+
|
|
111
|
+
console.log(`\n${BOLD}-- Windsurf (~/.codeium/windsurf/mcp_config.json) --${RESET}`);
|
|
112
|
+
console.log(JSON.stringify({ mcpServers: { vibesecur: { command: sc.command, args: sc.args, env } } }, null, 2));
|
|
113
|
+
|
|
114
|
+
console.log(`\n${BOLD}-- Generic / Claude Desktop / Continue.dev --${RESET}`);
|
|
115
|
+
console.log(`command: ${sc.command} ${sc.args.join(' ')}`);
|
|
116
|
+
Object.entries(env).forEach(([k, v]) => console.log(` ${k}=${v}`));
|
|
117
|
+
console.log();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function usage() {
|
|
121
|
+
console.log(`\n${h('Vibesecur MCP')}`);
|
|
122
|
+
console.log(' bind <folder> bind install to a project folder');
|
|
123
|
+
console.log(' rebind <folder> revoke old token, issue new lock');
|
|
124
|
+
console.log(' status [folder] check lock health');
|
|
125
|
+
console.log(' config <folder> print IDE config snippets');
|
|
126
|
+
console.log(' start start MCP server (stdio)\n');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
switch (command) {
|
|
132
|
+
case 'bind': await cmdBind(); break;
|
|
133
|
+
case 'rebind': await cmdRebind(); break;
|
|
134
|
+
case 'status': await cmdStatus(); break;
|
|
135
|
+
case 'config': await cmdConfig(); break;
|
|
136
|
+
case 'start':
|
|
137
|
+
case undefined: await import('./server.js'); break;
|
|
138
|
+
default: usage();
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error(`\x1b[31m✗ ${e.message}\x1b[0m`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
package/src/index.js
ADDED
package/src/lock.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
export function sha256Hex(input) {
|
|
7
|
+
return crypto.createHash('sha256').update(String(input), 'utf8').digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function randomToken() {
|
|
11
|
+
return crypto.randomBytes(32).toString('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function deriveLockDir(rootPath) {
|
|
15
|
+
return path.join(path.resolve(rootPath), '.vibesecur');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function deriveLockFile(rootPath) {
|
|
19
|
+
return path.join(deriveLockDir(rootPath), 'lock.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readLock(rootPath) {
|
|
23
|
+
try {
|
|
24
|
+
const raw = await fs.readFile(deriveLockFile(rootPath), 'utf8');
|
|
25
|
+
return JSON.parse(raw);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function writeLock(rootPath, lockData) {
|
|
32
|
+
const dir = deriveLockDir(rootPath);
|
|
33
|
+
await fs.mkdir(dir, { recursive: true });
|
|
34
|
+
await fs.writeFile(deriveLockFile(rootPath), JSON.stringify(lockData, null, 2), 'utf8');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function createLock({ rootPath, account }) {
|
|
38
|
+
const resolved = path.resolve(rootPath);
|
|
39
|
+
const rootHash = sha256Hex(`vibesecur:lock:${resolved}`);
|
|
40
|
+
const installToken = randomToken();
|
|
41
|
+
const lock = {
|
|
42
|
+
installToken,
|
|
43
|
+
rootHash,
|
|
44
|
+
boundRoot: resolved,
|
|
45
|
+
createdAt: new Date().toISOString(),
|
|
46
|
+
account: account || 'anonymous',
|
|
47
|
+
version: 1,
|
|
48
|
+
};
|
|
49
|
+
await writeLock(resolved, lock);
|
|
50
|
+
return lock;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function validateScanPath(requestedPath, installToken) {
|
|
54
|
+
const resolved = path.resolve(requestedPath);
|
|
55
|
+
const lock = await findLockForPath(resolved);
|
|
56
|
+
|
|
57
|
+
if (!lock) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
code: 'LOCK_NOT_FOUND',
|
|
61
|
+
httpStatus: 403,
|
|
62
|
+
message:
|
|
63
|
+
'No Vibesecur lock found for this install. ' +
|
|
64
|
+
'Run "vibesecur-mcp bind <folder>" to bind this install to a project folder.',
|
|
65
|
+
rebindHint: `vibesecur-mcp bind ${resolved}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (installToken && lock.installToken !== installToken) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code: 'TOKEN_MISMATCH',
|
|
73
|
+
message:
|
|
74
|
+
'Install token does not match the lock for this folder. ' +
|
|
75
|
+
'Run "vibesecur-mcp rebind" to get a new token.',
|
|
76
|
+
rebindHint: `vibesecur-mcp rebind ${lock.boundRoot}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const boundRoot = path.resolve(lock.boundRoot);
|
|
81
|
+
if (!resolved.startsWith(boundRoot + path.sep) && resolved !== boundRoot) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
code: 'OUT_OF_FOLDER',
|
|
85
|
+
httpStatus: 403,
|
|
86
|
+
message:
|
|
87
|
+
`Path "${resolved}" is outside the locked project folder "${boundRoot}". ` +
|
|
88
|
+
'Vibesecur MCP is bound to one folder per install. ' +
|
|
89
|
+
'To scan a different folder, run "vibesecur-mcp rebind <new-folder>".',
|
|
90
|
+
rebindHint: `vibesecur-mcp rebind ${resolved}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const expectedHash = sha256Hex(`vibesecur:lock:${boundRoot}`);
|
|
95
|
+
if (lock.rootHash !== expectedHash) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
code: 'HASH_MISMATCH',
|
|
99
|
+
message: 'Lock rootHash does not match bound folder. The lock file may be corrupted. Rebind.',
|
|
100
|
+
rebindHint: `vibesecur-mcp rebind ${boundRoot}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { ok: true, lock, boundRoot };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function findLockForPath(startPath) {
|
|
108
|
+
let current = path.resolve(startPath);
|
|
109
|
+
const homeDir = os.homedir();
|
|
110
|
+
const fsRoot = path.parse(current).root;
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < 20; i += 1) {
|
|
113
|
+
const lockFile = deriveLockFile(current);
|
|
114
|
+
try {
|
|
115
|
+
const raw = await fs.readFile(lockFile, 'utf8');
|
|
116
|
+
const lock = JSON.parse(raw);
|
|
117
|
+
if (lock.installToken && lock.rootHash && lock.boundRoot) {
|
|
118
|
+
return lock;
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// keep walking up
|
|
122
|
+
}
|
|
123
|
+
const parent = path.dirname(current);
|
|
124
|
+
if (parent === current || parent === homeDir || parent === fsRoot) break;
|
|
125
|
+
current = parent;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function rebindLock({ rootPath, account }) {
|
|
131
|
+
const resolved = path.resolve(rootPath);
|
|
132
|
+
const oldLock = await readLock(resolved);
|
|
133
|
+
const newLock = await createLock({ rootPath: resolved, account });
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
previousToken: oldLock?.installToken || null,
|
|
137
|
+
newToken: newLock.installToken,
|
|
138
|
+
boundRoot: newLock.boundRoot,
|
|
139
|
+
rootHash: newLock.rootHash,
|
|
140
|
+
message: 'Lock rebound. Previous token is now invalid. Copy your new install token.',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function diagnosticLock(rootPath) {
|
|
145
|
+
const resolved = path.resolve(rootPath || process.cwd());
|
|
146
|
+
const lock = await findLockForPath(resolved);
|
|
147
|
+
|
|
148
|
+
if (!lock) {
|
|
149
|
+
return {
|
|
150
|
+
healthy: false,
|
|
151
|
+
issue: 'LOCK_NOT_FOUND',
|
|
152
|
+
message: 'No lock file found. Run vibesecur-mcp bind <folder> to initialize.',
|
|
153
|
+
resolved,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const expectedHash = sha256Hex(`vibesecur:lock:${path.resolve(lock.boundRoot)}`);
|
|
158
|
+
const hashOk = lock.rootHash === expectedHash;
|
|
159
|
+
const boundRoot = path.resolve(lock.boundRoot);
|
|
160
|
+
const inFolder = resolved.startsWith(boundRoot + path.sep) || resolved === boundRoot;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
healthy: hashOk && inFolder,
|
|
164
|
+
lockFile: deriveLockFile(lock.boundRoot),
|
|
165
|
+
boundRoot: lock.boundRoot,
|
|
166
|
+
account: lock.account,
|
|
167
|
+
createdAt: lock.createdAt,
|
|
168
|
+
rootHash: { expected: expectedHash, stored: lock.rootHash, ok: hashOk },
|
|
169
|
+
pathInBoundFolder: inFolder,
|
|
170
|
+
issues: [
|
|
171
|
+
!hashOk ? 'rootHash mismatch - lock may be corrupted' : null,
|
|
172
|
+
!inFolder ? `requested path ${resolved} is outside bound folder ${boundRoot}` : null,
|
|
173
|
+
].filter(Boolean),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fg from 'fast-glob';
|
|
5
|
+
import { CHECKLIST, localScan } from './rule-engine/index.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_INCLUDE = [
|
|
8
|
+
'**/*.{js,jsx,ts,tsx,mjs,cjs,py,json,go,java,kt,kts,rb,php,cs,rs,swift,scala,sh,bash,zsh,yml,yaml,toml,ini,env,sql}',
|
|
9
|
+
];
|
|
10
|
+
export const DEFAULT_EXCLUDE = [
|
|
11
|
+
'**/node_modules/**',
|
|
12
|
+
'**/.git/**',
|
|
13
|
+
'**/dist/**',
|
|
14
|
+
'**/build/**',
|
|
15
|
+
'**/.next/**',
|
|
16
|
+
'**/.venv/**',
|
|
17
|
+
'**/venv/**',
|
|
18
|
+
'**/.cursor/**',
|
|
19
|
+
'**/AppData/**',
|
|
20
|
+
'**/Application Data/**',
|
|
21
|
+
'**/$RECYCLE.BIN/**',
|
|
22
|
+
'**/System Volume Information/**',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function inferLang(filePath) {
|
|
26
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
27
|
+
if (ext === '.py') return 'py';
|
|
28
|
+
if (ext === '.json') return 'json';
|
|
29
|
+
if (ext === '.ts' || ext === '.tsx') return 'ts';
|
|
30
|
+
return 'js';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizeRootPath(rootPath) {
|
|
34
|
+
const raw = (rootPath || process.cwd()).trim();
|
|
35
|
+
if (!raw || raw.includes('your-other-repo')) {
|
|
36
|
+
return process.cwd();
|
|
37
|
+
}
|
|
38
|
+
if (raw.startsWith('~')) {
|
|
39
|
+
return path.resolve(process.env.USERPROFILE || process.env.HOME || '', raw.slice(1));
|
|
40
|
+
}
|
|
41
|
+
return path.resolve(raw);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isUnsafeWorkspaceRoot(candidatePath) {
|
|
45
|
+
const normalized = candidatePath.replace(/\//g, '\\').toLowerCase();
|
|
46
|
+
return (
|
|
47
|
+
normalized.includes('\\application data') ||
|
|
48
|
+
normalized.includes('\\appdata\\') ||
|
|
49
|
+
normalized.includes('\\windows\\') ||
|
|
50
|
+
normalized.includes('\\program files')
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isHomePath(candidatePath) {
|
|
55
|
+
const home = path.resolve(os.homedir()).toLowerCase();
|
|
56
|
+
const resolved = path.resolve(candidatePath).toLowerCase();
|
|
57
|
+
return resolved === home;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function detectWorkspacePath() {
|
|
61
|
+
const candidates = [
|
|
62
|
+
process.env.CURSOR_WORKSPACE_PATH,
|
|
63
|
+
process.env.CURSOR_PROJECT_PATH,
|
|
64
|
+
process.env.WORKSPACE_PATH,
|
|
65
|
+
process.env.PWD,
|
|
66
|
+
process.env.INIT_CWD,
|
|
67
|
+
process.cwd(),
|
|
68
|
+
].filter(Boolean);
|
|
69
|
+
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
const resolved = normalizeRootPath(candidate);
|
|
72
|
+
if (resolved && !isUnsafeWorkspaceRoot(resolved) && !isHomePath(resolved)) return resolved;
|
|
73
|
+
}
|
|
74
|
+
return process.cwd();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function ensureDirectory(targetPath) {
|
|
78
|
+
const stat = await fs.stat(targetPath);
|
|
79
|
+
if (!stat.isDirectory()) {
|
|
80
|
+
throw new Error(`Path is not a directory: ${targetPath}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function aggregateScanResults(fileResults) {
|
|
85
|
+
const allFindings = fileResults.flatMap((r) => r.result.findings || []);
|
|
86
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
87
|
+
for (const finding of allFindings) {
|
|
88
|
+
bySeverity[finding.severity] = (bySeverity[finding.severity] || 0) + 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const totalIssues =
|
|
92
|
+
bySeverity.critical + bySeverity.high + bySeverity.medium + bySeverity.low;
|
|
93
|
+
let score = 100;
|
|
94
|
+
score -= bySeverity.critical * 20;
|
|
95
|
+
score -= bySeverity.high * 10;
|
|
96
|
+
score -= bySeverity.medium * 5;
|
|
97
|
+
score -= bySeverity.low * 2;
|
|
98
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
99
|
+
|
|
100
|
+
const checklist = CHECKLIST.map((item) => ({
|
|
101
|
+
id: item.id,
|
|
102
|
+
item: item.item,
|
|
103
|
+
critical: item.critical,
|
|
104
|
+
pass: !item.ruleIds.some((rid) => allFindings.some((f) => f.ruleId === rid)),
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
summary: {
|
|
109
|
+
filesScanned: fileResults.length,
|
|
110
|
+
totalIssues,
|
|
111
|
+
bySeverity,
|
|
112
|
+
score,
|
|
113
|
+
},
|
|
114
|
+
checklist,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function readGitignoreChecks(rootPath) {
|
|
119
|
+
const gitignorePath = path.join(rootPath, '.gitignore');
|
|
120
|
+
try {
|
|
121
|
+
const content = await fs.readFile(gitignorePath, 'utf8');
|
|
122
|
+
const hasEnvPattern = /(^|\n)\s*\.env(\.\*)?\s*($|\n)/m.test(content);
|
|
123
|
+
return {
|
|
124
|
+
gitignoreExists: true,
|
|
125
|
+
hasEnvPattern,
|
|
126
|
+
gitignorePath,
|
|
127
|
+
};
|
|
128
|
+
} catch {
|
|
129
|
+
return {
|
|
130
|
+
gitignoreExists: false,
|
|
131
|
+
hasEnvPattern: false,
|
|
132
|
+
gitignorePath,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function gatherRepoScan(rootPath, includeGlobs, excludeGlobs, maxFiles) {
|
|
138
|
+
const files = await fg(includeGlobs, {
|
|
139
|
+
cwd: rootPath,
|
|
140
|
+
onlyFiles: true,
|
|
141
|
+
absolute: true,
|
|
142
|
+
ignore: excludeGlobs,
|
|
143
|
+
suppressErrors: true,
|
|
144
|
+
followSymbolicLinks: false,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const matchedFiles =
|
|
148
|
+
files.length === 0
|
|
149
|
+
? await fg(['**/*'], {
|
|
150
|
+
cwd: rootPath,
|
|
151
|
+
onlyFiles: true,
|
|
152
|
+
absolute: true,
|
|
153
|
+
ignore: [
|
|
154
|
+
...excludeGlobs,
|
|
155
|
+
'**/*.png',
|
|
156
|
+
'**/*.jpg',
|
|
157
|
+
'**/*.jpeg',
|
|
158
|
+
'**/*.gif',
|
|
159
|
+
'**/*.webp',
|
|
160
|
+
'**/*.pdf',
|
|
161
|
+
'**/*.zip',
|
|
162
|
+
],
|
|
163
|
+
suppressErrors: true,
|
|
164
|
+
followSymbolicLinks: false,
|
|
165
|
+
})
|
|
166
|
+
: files;
|
|
167
|
+
|
|
168
|
+
const limitedFiles = matchedFiles.slice(0, maxFiles);
|
|
169
|
+
const fileResults = [];
|
|
170
|
+
for (const absPath of limitedFiles) {
|
|
171
|
+
const code = await fs.readFile(absPath, 'utf8');
|
|
172
|
+
const lang = inferLang(absPath);
|
|
173
|
+
const result = localScan(code, lang);
|
|
174
|
+
fileResults.push({
|
|
175
|
+
filePath: absPath,
|
|
176
|
+
lang,
|
|
177
|
+
result,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const aggregate = aggregateScanResults(fileResults);
|
|
182
|
+
const topRiskFiles = fileResults
|
|
183
|
+
.map((f) => ({
|
|
184
|
+
filePath: f.filePath,
|
|
185
|
+
score: f.result.score,
|
|
186
|
+
findings: f.result.findings.length,
|
|
187
|
+
critical: f.result.findings.filter((x) => x.severity === 'critical').length,
|
|
188
|
+
high: f.result.findings.filter((x) => x.severity === 'high').length,
|
|
189
|
+
}))
|
|
190
|
+
.sort((a, b) => b.critical - a.critical || b.high - a.high || b.findings - a.findings)
|
|
191
|
+
.slice(0, 20);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
matchedFiles,
|
|
195
|
+
limitedFiles,
|
|
196
|
+
fileResults,
|
|
197
|
+
aggregate,
|
|
198
|
+
topRiskFiles,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rule-engine/index.js
|
|
3
|
+
* Bundled inline - no external @vibesecur/rule-engine dep needed.
|
|
4
|
+
* This allows `npx @vibesecur/mcp-server` to work standalone.
|
|
5
|
+
*/
|
|
6
|
+
export { JS_RULES, PY_RULES, CHECKLIST } from './rules.js';
|
|
7
|
+
export { localScan } from './localScan.js';
|
|
8
|
+
export { buildClaudePrompt } from './prompt.js';
|
|
9
|
+
export { calculateScore, getGrade, getVerdict } from './score.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { JS_RULES, PY_RULES, CHECKLIST } from './rules.js';
|
|
3
|
+
import { calculateScore, getGrade, getVerdict } from './score.js';
|
|
4
|
+
|
|
5
|
+
export function localScan(code, lang = 'js') {
|
|
6
|
+
const rules = lang === 'py'
|
|
7
|
+
? PY_RULES
|
|
8
|
+
: lang === 'auto'
|
|
9
|
+
? [...JS_RULES, ...PY_RULES]
|
|
10
|
+
: JS_RULES;
|
|
11
|
+
|
|
12
|
+
const findings = [];
|
|
13
|
+
|
|
14
|
+
for (const rule of rules) {
|
|
15
|
+
const matches = [...code.matchAll(rule.re)];
|
|
16
|
+
for (const match of matches) {
|
|
17
|
+
if (rule.id === 'A003' && /expiresIn|['"]exp['"]|,\s*exp\s*:/.test(match[0])) continue;
|
|
18
|
+
|
|
19
|
+
const lineNumber = code.substring(0, match.index).split('\n').length;
|
|
20
|
+
const snippet = match[0].substring(0, 80) + (match[0].length > 80 ? '...' : '');
|
|
21
|
+
findings.push({
|
|
22
|
+
ruleId: rule.id,
|
|
23
|
+
ruleName: rule.name,
|
|
24
|
+
severity: rule.sev,
|
|
25
|
+
category: rule.cat,
|
|
26
|
+
lineNumber,
|
|
27
|
+
snippet,
|
|
28
|
+
fix: rule.fix,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const score = calculateScore(findings);
|
|
34
|
+
const grade = getGrade(score);
|
|
35
|
+
const verdict = getVerdict(score);
|
|
36
|
+
const codeHash = crypto.createHash('sha256').update(code).digest('hex');
|
|
37
|
+
|
|
38
|
+
const checklist = CHECKLIST.map((cl) => ({
|
|
39
|
+
...cl,
|
|
40
|
+
pass: !cl.ruleIds.some((rid) => findings.find((f) => f.ruleId === rid)),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
score,
|
|
45
|
+
grade,
|
|
46
|
+
verdict,
|
|
47
|
+
findings,
|
|
48
|
+
checklist,
|
|
49
|
+
codeHash,
|
|
50
|
+
linesAnalysed: code.split('\n').length,
|
|
51
|
+
engine: 'local',
|
|
52
|
+
summary: `Local engine found ${findings.length} issue${findings.length !== 1 ? 's' : ''}. ${
|
|
53
|
+
findings.length === 0
|
|
54
|
+
? 'No common vibe coding vulnerabilities detected.'
|
|
55
|
+
: 'Fix critical items before deploying to production.'
|
|
56
|
+
}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function buildClaudePrompt(code, platform, mode, lang) {
|
|
2
|
+
const isSupabase = mode === 'supabase' || code.toLowerCase().includes('supabase');
|
|
3
|
+
const isPython = lang === 'py' || code.includes('import os') || code.includes('def ');
|
|
4
|
+
const deepMode = mode === 'deep' ? 'DEEP MODE: find subtle and complex issues.' : '';
|
|
5
|
+
const sbMode = isSupabase ? 'Focus especially on Supabase RLS policies and service key exposure.' : '';
|
|
6
|
+
|
|
7
|
+
return `You are Vibesecur, an expert AI security scanner for AI-generated code. Analyse this ${lang.toUpperCase()} code from ${platform}.
|
|
8
|
+
Return ONLY valid JSON - no markdown, no explanation, no backticks.
|
|
9
|
+
|
|
10
|
+
\`\`\`${lang}
|
|
11
|
+
${code.substring(0, 8000)}
|
|
12
|
+
\`\`\`
|
|
13
|
+
|
|
14
|
+
Return exactly this JSON structure:
|
|
15
|
+
{
|
|
16
|
+
"score": <0-100>,
|
|
17
|
+
"grade": "<A|B|C|D|F>",
|
|
18
|
+
"verdict": "<one sentence>",
|
|
19
|
+
"summary": "<2-3 sentences about this specific codebase's risk profile>",
|
|
20
|
+
"findings": [
|
|
21
|
+
{
|
|
22
|
+
"ruleId": "<S001|RLS1|P001 etc>",
|
|
23
|
+
"ruleName": "<descriptive name>",
|
|
24
|
+
"severity": "<critical|high|medium|low>",
|
|
25
|
+
"lineNumber": <integer or null>,
|
|
26
|
+
"category": "<Secrets|Auth|Injection|CORS|RLS|Exposure|Python|SSRF>",
|
|
27
|
+
"description": "<what was found - be specific>",
|
|
28
|
+
"fix": "<exact actionable code fix>"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"checklist": [
|
|
32
|
+
{"id":"CL01","item":"No API keys hardcoded","critical":true,"pass":<bool>},
|
|
33
|
+
{"id":"CL02","item":".env in .gitignore","critical":true,"pass":<bool>},
|
|
34
|
+
{"id":"CL03","item":"bcrypt/argon2 for passwords","critical":true,"pass":<bool>},
|
|
35
|
+
{"id":"CL04","item":"JWT expiry set","critical":true,"pass":<bool>},
|
|
36
|
+
{"id":"CL05","item":"Rate limiting on auth","critical":true,"pass":<bool>},
|
|
37
|
+
{"id":"CL06","item":"CORS restricted","critical":true,"pass":<bool>},
|
|
38
|
+
{"id":"CL07","item":"No SQL injection","critical":true,"pass":<bool>},
|
|
39
|
+
{"id":"CL08","item":"Supabase RLS enabled","critical":${isSupabase},"pass":<bool>},
|
|
40
|
+
{"id":"CL09","item":"Firebase rules restricted","critical":false,"pass":<bool>},
|
|
41
|
+
{"id":"CL10","item":"No stack traces","critical":false,"pass":<bool>},
|
|
42
|
+
{"id":"CL11","item":"eval() not used","critical":true,"pass":<bool>},
|
|
43
|
+
{"id":"CL12","item":"No debug mode","critical":false,"pass":<bool>}
|
|
44
|
+
],
|
|
45
|
+
"aiInsight": "<3-4 sentences of specific insight about this codebase's security posture>",
|
|
46
|
+
"envVars": ["<VAR_NAME: what it replaces>"],
|
|
47
|
+
"policies": ["<specific action: exact detail>"]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Scoring: start 100, subtract 20 per critical, 10 per high, 5 per medium. ${deepMode} ${sbMode}
|
|
51
|
+
Be specific about ${isPython ? 'Python eval/pickle/subprocess patterns' : 'auth patterns and Supabase RLS'}.`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const JS_RULES = [
|
|
2
|
+
{ id: 'S001', name: 'Hardcoded API Key', sev: 'critical', re: /api[_-]?key\s*[:=]\s*['"][a-zA-Z0-9_\-]{16,}['"]/gi, fix: 'Move to .env: process.env.API_KEY', cat: 'Secrets' },
|
|
3
|
+
{ id: 'S002', name: 'Hardcoded Password', sev: 'critical', re: /password\s*[:=]\s*['"][^'"]{4,}['"]/gi, fix: 'Use env var: process.env.PASSWORD', cat: 'Secrets' },
|
|
4
|
+
{ id: 'S003', name: 'JWT Secret Hardcoded', sev: 'critical', re: /jwt[_-]?secret\s*[:=]\s*['"][^'"]{6,}['"]/gi, fix: 'Move to .env: JWT_SECRET=...', cat: 'Secrets' },
|
|
5
|
+
{ id: 'S004', name: 'Database URL Exposed', sev: 'critical', re: /(mongodb|mysql|postgres|supabase):\/\/[^\s'"]{8,}/gi, fix: 'Move to .env: DATABASE_URL=...', cat: 'Secrets' },
|
|
6
|
+
{ id: 'S005', name: 'AWS Access Key', sev: 'critical', re: /AKIA[0-9A-Z]{16}/g, fix: 'Rotate immediately. Use IAM roles.', cat: 'Secrets' },
|
|
7
|
+
{ id: 'S006', name: 'Stripe Key Exposed', sev: 'critical', re: /(sk_live|sk_test)_[a-zA-Z0-9]{20,}/g, fix: 'Move to .env: STRIPE_SECRET_KEY=...', cat: 'Secrets' },
|
|
8
|
+
{ id: 'S007', name: 'Private Key in Code', sev: 'critical', re: /-----BEGIN[A-Z ]*PRIVATE KEY-----/g, fix: 'Never commit private keys. Use a secrets manager.', cat: 'Secrets' },
|
|
9
|
+
{ id: 'A001', name: 'MD5 Password Hashing', sev: 'critical', re: /md5\s*\(/gi, fix: 'Use bcrypt: await bcrypt.hash(password, 12)', cat: 'Auth' },
|
|
10
|
+
{ id: 'A002', name: 'SHA1 Password Hashing', sev: 'critical', re: /sha1\s*\(/gi, fix: 'Use bcrypt or argon2 for passwords', cat: 'Auth' },
|
|
11
|
+
{ id: 'A003', name: 'JWT Without Expiry', sev: 'critical', re: /jwt\.sign\s*\([^)]{0,200}\)/g, fix: 'Add: { expiresIn: "1h" }', cat: 'Auth' },
|
|
12
|
+
{ id: 'A004', name: 'eval() Usage', sev: 'critical', re: /\beval\s*\(/g, fix: 'Never use eval() - arbitrary code execution', cat: 'Injection' },
|
|
13
|
+
{ id: 'A005', name: 'SQL Injection Risk', sev: 'critical', re: /(SELECT|INSERT|UPDATE|DELETE)[^;`]{0,120}(\+\s*(req\.|user\.)|\$\{(req\.|user\.))/gi, fix: 'Use parameterized queries: db.query("?", [val])', cat: 'Injection' },
|
|
14
|
+
{ id: 'A006', name: 'Wildcard CORS', sev: 'critical', re: /origin\s*:\s*['"]\*['"]/gi, fix: 'cors({ origin: process.env.ALLOWED_ORIGIN })', cat: 'CORS' },
|
|
15
|
+
{ id: 'A007', name: 'Missing Rate Limit', sev: 'high', re: /app\.(post|put)\s*\(['"]\/(login|auth|register)/gi, fix: 'Add rateLimit({ windowMs:900000, max:5 }) middleware', cat: 'Auth' },
|
|
16
|
+
{ id: 'RLS1', name: 'Supabase - Missing RLS', sev: 'critical', re: /supabase\.from\(['"`][^'"`]+['"`]\)\.(select|insert|update|delete)/gi, fix: 'ENABLE ROW LEVEL SECURITY on this table', cat: 'RLS' },
|
|
17
|
+
{ id: 'RLS2', name: 'Supabase Service Key FE', sev: 'critical', re: /service_role|supabase_service/gi, fix: 'Service key must be server-side only - never in browser', cat: 'RLS' },
|
|
18
|
+
{ id: 'RLS3', name: 'Firebase Open Rules', sev: 'critical', re: /allow read, write:\s*if true/gi, fix: 'Restrict: allow read if request.auth != null', cat: 'RLS' },
|
|
19
|
+
{ id: 'E001', name: 'Credentials in Logs', sev: 'high', re: /console\.log\(.*?(password|token|secret|key)/gi, fix: 'Remove console.log with sensitive data', cat: 'Exposure' },
|
|
20
|
+
{ id: 'E002', name: 'Debug Mode On', sev: 'high', re: /debug\s*[:=]\s*true/gi, fix: 'Set debug: false in production', cat: 'Exposure' },
|
|
21
|
+
{ id: 'E003', name: 'Stack Trace Exposed', sev: 'high', re: /res\.(send|json)\(.*?err\.(stack|message)/gi, fix: 'return { error: "Server error" } only', cat: 'Exposure' },
|
|
22
|
+
{ id: 'E004', name: 'TODO Security Note', sev: 'medium', re: /\/\/\s*(TODO|FIXME).*?(auth|security|password|token)/gi, fix: 'Resolve all security TODOs before deploy', cat: 'Exposure' },
|
|
23
|
+
{ id: 'E005', name: 'Sensitive GET Params', sev: 'medium', re: /\?.*?(token|password|secret|key)=/gi, fix: 'Use POST body - never sensitive data in URL params', cat: 'Exposure' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const PY_RULES = [
|
|
27
|
+
{ id: 'P001', name: 'Python eval()', sev: 'critical', re: /\beval\s*\(/g, fix: 'Never use eval() - arbitrary code execution', cat: 'Injection' },
|
|
28
|
+
{ id: 'P002', name: 'Python pickle.loads()', sev: 'critical', re: /pickle\.loads?\s*\(/g, fix: 'Never unpickle untrusted data - use JSON', cat: 'Injection' },
|
|
29
|
+
{ id: 'P003', name: 'Python SQL Concatenation', sev: 'critical', re: /cursor\.execute\s*\([^)]*%\s*|f"SELECT.*\{/gi, fix: 'Use parameterized queries: cursor.execute("?", (v,))', cat: 'Injection' },
|
|
30
|
+
{ id: 'P004', name: 'Python Hardcoded Secret', sev: 'critical', re: /(password|api_key|secret|token)\s*=\s*['"][^'"]{6,}['"]/gi, fix: 'Use os.environ.get("SECRET")', cat: 'Secrets' },
|
|
31
|
+
{ id: 'P005', name: 'Python subprocess shell', sev: 'high', re: /subprocess\.[^(]+\([^)]*shell\s*=\s*True/gi, fix: 'Avoid shell=True - use list args', cat: 'Injection' },
|
|
32
|
+
{ id: 'P006', name: 'Python MD5 Passwords', sev: 'critical', re: /hashlib\.(md5|sha1)/gi, fix: 'Use bcrypt or argon2-cffi for passwords', cat: 'Auth' },
|
|
33
|
+
{ id: 'P007', name: 'Python DEBUG=True', sev: 'high', re: /DEBUG\s*=\s*True/g, fix: 'Set DEBUG=False in production settings', cat: 'Exposure' },
|
|
34
|
+
{ id: 'P008', name: 'Python Open Redirect', sev: 'medium', re: /redirect\s*\([^)]*request\.(args|form|params)/gi, fix: 'Validate redirect URLs against allowlist', cat: 'Auth' },
|
|
35
|
+
{ id: 'P009', name: 'Python SSRF Risk', sev: 'high', re: /requests\.(get|post)\s*\([^)]*request\./gi, fix: 'Validate and allowlist URLs before fetching', cat: 'SSRF' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export const CHECKLIST = [
|
|
39
|
+
{ id: 'CL01', item: 'No API keys hardcoded', critical: true, ruleIds: ['S001', 'S002', 'S003', 'S005', 'S006', 'P004'] },
|
|
40
|
+
{ id: 'CL02', item: '.env in .gitignore', critical: true, ruleIds: [] },
|
|
41
|
+
{ id: 'CL03', item: 'bcrypt/argon2 for passwords', critical: true, ruleIds: ['A001', 'A002', 'P006'] },
|
|
42
|
+
{ id: 'CL04', item: 'JWT expiry set', critical: true, ruleIds: ['A003'] },
|
|
43
|
+
{ id: 'CL05', item: 'Rate limiting on auth routes', critical: true, ruleIds: ['A007'] },
|
|
44
|
+
{ id: 'CL06', item: 'CORS restricted', critical: true, ruleIds: ['A006'] },
|
|
45
|
+
{ id: 'CL07', item: 'No SQL injection patterns', critical: true, ruleIds: ['A005', 'P003'] },
|
|
46
|
+
{ id: 'CL08', item: 'Supabase RLS enabled', critical: true, ruleIds: ['RLS1', 'RLS2'] },
|
|
47
|
+
{ id: 'CL09', item: 'Firebase rules restricted', critical: false, ruleIds: ['RLS3'] },
|
|
48
|
+
{ id: 'CL10', item: 'No stack traces exposed', critical: false, ruleIds: ['E003'] },
|
|
49
|
+
{ id: 'CL11', item: 'eval() not used', critical: true, ruleIds: ['A004', 'P001', 'P002'] },
|
|
50
|
+
{ id: 'CL12', item: 'No debug mode in production', critical: false, ruleIds: ['E002', 'P007'] },
|
|
51
|
+
];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function calculateScore(findings) {
|
|
2
|
+
const WEIGHTS = { critical: 20, high: 10, medium: 5, low: 2 };
|
|
3
|
+
let score = 100;
|
|
4
|
+
for (const f of findings) score -= (WEIGHTS[f.severity] || 2);
|
|
5
|
+
const cats = new Set(findings.map((f) => f.category));
|
|
6
|
+
if (cats.size >= 4) score -= 5;
|
|
7
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getGrade(score) {
|
|
11
|
+
if (score >= 90) return 'A';
|
|
12
|
+
if (score >= 80) return 'B';
|
|
13
|
+
if (score >= 70) return 'C';
|
|
14
|
+
if (score >= 60) return 'D';
|
|
15
|
+
return 'F';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getVerdict(score) {
|
|
19
|
+
if (score >= 80) return 'Safe to Deploy';
|
|
20
|
+
if (score >= 60) return 'Review Before Deploy';
|
|
21
|
+
return 'Critical Issues - Do Not Ship';
|
|
22
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import * as z from 'zod';
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import {
|
|
8
|
+
CHECKLIST, JS_RULES, PY_RULES,
|
|
9
|
+
buildClaudePrompt, localScan,
|
|
10
|
+
} from './rule-engine/index.js';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_INCLUDE, DEFAULT_EXCLUDE,
|
|
13
|
+
inferLang, normalizeRootPath, ensureDirectory,
|
|
14
|
+
gatherRepoScan, readGitignoreChecks,
|
|
15
|
+
detectWorkspacePath, isHomePath,
|
|
16
|
+
} from './repo-scan.mjs';
|
|
17
|
+
import { postRemoteLocalScan } from './api-scan.mjs';
|
|
18
|
+
import { validateScanPath, diagnosticLock } from './lock.mjs';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const mcpPkg = require('../package.json');
|
|
22
|
+
|
|
23
|
+
const INSTALL_TOKEN = process.env.VIBESECUR_INSTALL_TOKEN || null;
|
|
24
|
+
const BOUND_ROOT = process.env.VIBESECUR_BOUND_ROOT
|
|
25
|
+
? path.resolve(process.env.VIBESECUR_BOUND_ROOT)
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
if (!INSTALL_TOKEN || !BOUND_ROOT) {
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
'[vibesecur] WARNING: VIBESECUR_INSTALL_TOKEN and VIBESECUR_BOUND_ROOT are not set.\n' +
|
|
31
|
+
'[vibesecur] Run: vibesecur-mcp bind <folder> then vibesecur-mcp config <folder>\n' +
|
|
32
|
+
'[vibesecur] Scans will be restricted to process.cwd() as fallback.\n',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const server = new McpServer({
|
|
37
|
+
name: 'vibesecur-mcp-server',
|
|
38
|
+
version: mcpPkg.version || '2.0.0',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
42
|
+
|
|
43
|
+
async function guardPath(requestedPath) {
|
|
44
|
+
const target = normalizeRootPath(requestedPath);
|
|
45
|
+
if (!BOUND_ROOT) {
|
|
46
|
+
const cwd = path.resolve(process.cwd());
|
|
47
|
+
if (!target.startsWith(cwd + path.sep) && target !== cwd) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
httpStatus: 403,
|
|
51
|
+
code: 'NO_LOCK_OUT_OF_CWD',
|
|
52
|
+
message:
|
|
53
|
+
`No lock configured and "${target}" is outside process.cwd() "${cwd}". ` +
|
|
54
|
+
'Run vibesecur-mcp bind <folder> and add VIBESECUR_BOUND_ROOT to your MCP config.',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, resolvedRoot: target };
|
|
58
|
+
}
|
|
59
|
+
if (!target.startsWith(BOUND_ROOT + path.sep) && target !== BOUND_ROOT) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
httpStatus: 403,
|
|
63
|
+
code: 'OUT_OF_FOLDER',
|
|
64
|
+
message:
|
|
65
|
+
`Path "${target}" is outside the locked project folder "${BOUND_ROOT}". ` +
|
|
66
|
+
'Vibesecur MCP is bound to one folder per install. ' +
|
|
67
|
+
'To scan a different folder, run "vibesecur-mcp rebind <new-folder>".',
|
|
68
|
+
rebindHint: `vibesecur-mcp rebind ${target}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const result = await validateScanPath(target, INSTALL_TOKEN);
|
|
72
|
+
if (!result.ok) return result;
|
|
73
|
+
return { ok: true, resolvedRoot: target, lock: result.lock };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function guardError(guard) {
|
|
77
|
+
const status = guard.httpStatus ? ` (${guard.httpStatus})` : '';
|
|
78
|
+
const text = JSON.stringify({
|
|
79
|
+
error: guard.code || 'SCAN_BLOCKED',
|
|
80
|
+
message: guard.message,
|
|
81
|
+
rebindHint: guard.rebindHint || null,
|
|
82
|
+
docs: 'https://vibesecur.com/docs/mcp-setup',
|
|
83
|
+
}, null, 2);
|
|
84
|
+
return {
|
|
85
|
+
content: [{ type: 'text', text: `Security Lock Error${status}:\n${text}` }],
|
|
86
|
+
isError: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildScanMeta(resolvedRoot, includeGlobs, excludeGlobs, maxFiles, matchedLen, scannedLen) {
|
|
91
|
+
return {
|
|
92
|
+
resolvedRoot,
|
|
93
|
+
includeGlobsUsed: includeGlobs,
|
|
94
|
+
excludeGlobsUsed: excludeGlobs,
|
|
95
|
+
maxFiles,
|
|
96
|
+
matchedFiles: matchedLen,
|
|
97
|
+
scannedFiles: scannedLen,
|
|
98
|
+
cappedByMaxFiles: matchedLen > maxFiles,
|
|
99
|
+
boundRoot: BOUND_ROOT || 'unconfigured',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function humanRepoSummary(meta, agg) {
|
|
104
|
+
const { bySeverity, score, totalIssues } = agg.summary;
|
|
105
|
+
const parts = [`Scanned ${meta.scannedFiles} file(s) of ${meta.matchedFiles} matched under "${meta.resolvedRoot}".`];
|
|
106
|
+
if (meta.cappedByMaxFiles) parts.push(`More files matched than maxFiles (${meta.maxFiles}); increase maxFiles for full coverage.`);
|
|
107
|
+
parts.push(`Score ${score} - ${totalIssues} issue(s): ${bySeverity.critical} critical, ${bySeverity.high} high, ${bySeverity.medium} medium, ${bySeverity.low} low.`);
|
|
108
|
+
return parts.join(' ');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function flattenFindings(fileResults) {
|
|
112
|
+
return fileResults.flatMap((fr) =>
|
|
113
|
+
(fr.result.findings || []).map((f) => ({ ...f, filePath: fr.filePath })),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pickTopFindings(fileResults, n) {
|
|
118
|
+
const flat = flattenFindings(fileResults);
|
|
119
|
+
flat.sort((a, b) => {
|
|
120
|
+
const da = SEVERITY_ORDER[a.severity] ?? 9;
|
|
121
|
+
const db = SEVERITY_ORDER[b.severity] ?? 9;
|
|
122
|
+
return da !== db ? da - db : (a.filePath || '').localeCompare(b.filePath || '');
|
|
123
|
+
});
|
|
124
|
+
return flat.slice(0, n).map((f) => ({
|
|
125
|
+
filePath: f.filePath,
|
|
126
|
+
lineNumber: f.lineNumber,
|
|
127
|
+
ruleId: f.ruleId,
|
|
128
|
+
ruleName: f.ruleName,
|
|
129
|
+
severity: f.severity,
|
|
130
|
+
snippetPreview: (f.snippet || '').slice(0, 120),
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
server.registerTool('health', {
|
|
135
|
+
title: 'MCP Health',
|
|
136
|
+
description: 'Server version, rule counts, lock status, and workspace hints.',
|
|
137
|
+
inputSchema: { detail: z.enum(['brief', 'full']).default('brief') },
|
|
138
|
+
}, async ({ detail = 'brief' }) => {
|
|
139
|
+
const cwd = process.cwd();
|
|
140
|
+
const detected = detectWorkspacePath();
|
|
141
|
+
const diagRoot = BOUND_ROOT || cwd;
|
|
142
|
+
const diag = await diagnosticLock(diagRoot).catch(() => ({ healthy: false, issue: 'diagnostic_error' }));
|
|
143
|
+
const payload = {
|
|
144
|
+
ok: true,
|
|
145
|
+
server: { name: 'vibesecur-mcp-server', version: mcpPkg.version },
|
|
146
|
+
lock: {
|
|
147
|
+
configured: !!(INSTALL_TOKEN && BOUND_ROOT),
|
|
148
|
+
boundRoot: BOUND_ROOT || null,
|
|
149
|
+
healthy: diag.healthy,
|
|
150
|
+
issues: diag.issues || [],
|
|
151
|
+
},
|
|
152
|
+
rules: {
|
|
153
|
+
jsRuleCount: JS_RULES.length,
|
|
154
|
+
pyRuleCount: PY_RULES.length,
|
|
155
|
+
totalRules: JS_RULES.length + PY_RULES.length,
|
|
156
|
+
checklistItems: CHECKLIST.length,
|
|
157
|
+
},
|
|
158
|
+
paths: { processCwd: cwd, detectedWorkspace: detected, detectedIsUserHome: isHomePath(detected) },
|
|
159
|
+
ide: {
|
|
160
|
+
supported: ['cursor', 'vscode-mcp', 'windsurf', 'claude-desktop', 'continue-dev', 'cline'],
|
|
161
|
+
setupGuide: 'https://vibesecur.com/docs/mcp-setup',
|
|
162
|
+
configTool: 'vibesecur-mcp config <folder>',
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (detail === 'full') {
|
|
167
|
+
payload.envHints = {
|
|
168
|
+
VIBESECUR_INSTALL_TOKEN: INSTALL_TOKEN ? '***set***' : 'NOT SET',
|
|
169
|
+
VIBESECUR_BOUND_ROOT: BOUND_ROOT || 'NOT SET',
|
|
170
|
+
VIBESECUR_API_BASE: process.env.VIBESECUR_API_BASE || 'NOT SET',
|
|
171
|
+
CURSOR_WORKSPACE_PATH: process.env.CURSOR_WORKSPACE_PATH ?? null,
|
|
172
|
+
WORKSPACE_PATH: process.env.WORKSPACE_PATH ?? null,
|
|
173
|
+
};
|
|
174
|
+
payload.lockDiagnostic = diag;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const brief = {
|
|
178
|
+
ok: true,
|
|
179
|
+
version: mcpPkg.version,
|
|
180
|
+
lockConfigured: payload.lock.configured,
|
|
181
|
+
lockHealthy: payload.lock.healthy,
|
|
182
|
+
boundRoot: BOUND_ROOT || null,
|
|
183
|
+
totalRules: JS_RULES.length + PY_RULES.length,
|
|
184
|
+
processCwd: cwd,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: 'text', text: JSON.stringify(detail === 'full' ? payload : brief, null, 2) }],
|
|
189
|
+
structuredContent: payload,
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
server.registerTool('installDiagnostic', {
|
|
194
|
+
title: 'Install Diagnostic',
|
|
195
|
+
description: 'Full diagnostic of lock health, token validity, and config suggestions.',
|
|
196
|
+
inputSchema: { path: z.string().default('.') },
|
|
197
|
+
}, async ({ path: reqPath = '.' }) => {
|
|
198
|
+
const target = normalizeRootPath(reqPath);
|
|
199
|
+
const diag = await diagnosticLock(target);
|
|
200
|
+
const payload = {
|
|
201
|
+
...diag,
|
|
202
|
+
installToken: INSTALL_TOKEN ? '***set***' : 'NOT SET - run: vibesecur-mcp bind <folder>',
|
|
203
|
+
boundRoot: BOUND_ROOT || 'NOT SET - run: vibesecur-mcp bind <folder>',
|
|
204
|
+
apiBase: process.env.VIBESECUR_API_BASE || 'NOT SET (offline mode)',
|
|
205
|
+
setupDocs: 'https://vibesecur.com/docs/mcp-setup',
|
|
206
|
+
configCommand: `vibesecur-mcp config ${target}`,
|
|
207
|
+
rebindCommand: `vibesecur-mcp rebind ${target}`,
|
|
208
|
+
};
|
|
209
|
+
if (!diag.healthy) {
|
|
210
|
+
payload.resolution =
|
|
211
|
+
`Run: vibesecur-mcp rebind ${BOUND_ROOT || target}` +
|
|
212
|
+
'\nThen copy the new token to your IDE MCP config env.';
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
216
|
+
structuredContent: payload,
|
|
217
|
+
isError: !diag.healthy,
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
server.registerTool('localScan', {
|
|
222
|
+
title: 'Local Security Scan',
|
|
223
|
+
description: 'Run Vibesecur rule-engine on code. Restricted to bound project folder.',
|
|
224
|
+
inputSchema: {
|
|
225
|
+
code: z.string().min(1).max(50000).describe('Source code to scan'),
|
|
226
|
+
lang: z.enum(['js', 'ts', 'py', 'json', 'auto']).default('auto'),
|
|
227
|
+
projectRoot: z.string().default('.').describe('Must be inside bound folder'),
|
|
228
|
+
},
|
|
229
|
+
}, async ({ code, lang = 'auto', projectRoot = '.' }) => {
|
|
230
|
+
const guard = await guardPath(projectRoot);
|
|
231
|
+
if (!guard.ok) return guardError(guard);
|
|
232
|
+
const remote = await postRemoteLocalScan({
|
|
233
|
+
code,
|
|
234
|
+
lang,
|
|
235
|
+
projectRoot: guard.resolvedRoot,
|
|
236
|
+
platform: 'mcp',
|
|
237
|
+
token: INSTALL_TOKEN,
|
|
238
|
+
});
|
|
239
|
+
if (!remote.skipped && remote.status === 402) {
|
|
240
|
+
return {
|
|
241
|
+
content: [{ type: 'text', text: JSON.stringify({ ...remote.json, upgradeUrl: 'https://vibesecur.com/#pricing' }, null, 2) }],
|
|
242
|
+
isError: true,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (!remote.skipped && remote.ok && remote.json?.success && remote.json?.data) {
|
|
246
|
+
const data = remote.json.data;
|
|
247
|
+
const humanSummary = `${data.verdict || ''} Score ${data.score} (${data.grade}) - ${(data.findings || []).length} finding(s).`;
|
|
248
|
+
const enriched = { ...data, humanSummary, engineVersion: mcpPkg.version, quota: remote.json.quota };
|
|
249
|
+
return { content: [{ type: 'text', text: JSON.stringify(enriched, null, 2) }], structuredContent: enriched };
|
|
250
|
+
}
|
|
251
|
+
const result = localScan(code, lang);
|
|
252
|
+
const humanSummary = `${result.verdict} Score ${result.score} (${result.grade}) - ${result.findings.length} finding(s).`;
|
|
253
|
+
const enriched = { ...result, humanSummary, engineVersion: mcpPkg.version, mode: 'offline' };
|
|
254
|
+
return { content: [{ type: 'text', text: JSON.stringify(enriched, null, 2) }], structuredContent: enriched };
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
server.registerTool('scanFile', {
|
|
258
|
+
title: 'Scan File',
|
|
259
|
+
description: 'Scan a single file. Must be inside the bound project folder.',
|
|
260
|
+
inputSchema: {
|
|
261
|
+
filePath: z.string().min(1).describe('Absolute or relative file path (must be inside bound folder)'),
|
|
262
|
+
lang: z.enum(['js', 'ts', 'py', 'json', 'auto']).default('auto'),
|
|
263
|
+
},
|
|
264
|
+
}, async ({ filePath, lang = 'auto' }) => {
|
|
265
|
+
try {
|
|
266
|
+
const resolvedPath = path.resolve(filePath);
|
|
267
|
+
const guard = await guardPath(path.dirname(resolvedPath));
|
|
268
|
+
if (!guard.ok) return guardError(guard);
|
|
269
|
+
const code = await fs.readFile(resolvedPath, 'utf8');
|
|
270
|
+
const useLang = lang === 'auto' ? inferLang(resolvedPath) : lang;
|
|
271
|
+
const remote = await postRemoteLocalScan({
|
|
272
|
+
code,
|
|
273
|
+
lang: useLang,
|
|
274
|
+
projectRoot: guard.resolvedRoot,
|
|
275
|
+
platform: 'mcp',
|
|
276
|
+
token: INSTALL_TOKEN,
|
|
277
|
+
});
|
|
278
|
+
let result;
|
|
279
|
+
if (!remote.skipped && remote.ok && remote.json?.success && remote.json?.data) {
|
|
280
|
+
result = remote.json.data;
|
|
281
|
+
} else if (!remote.skipped && remote.status === 402) {
|
|
282
|
+
return { content: [{ type: 'text', text: JSON.stringify(remote.json, null, 2) }], isError: true };
|
|
283
|
+
} else {
|
|
284
|
+
result = localScan(code, useLang);
|
|
285
|
+
}
|
|
286
|
+
const findings = result.findings || [];
|
|
287
|
+
const bySev = findings.reduce((a, f) => {
|
|
288
|
+
a[f.severity] = (a[f.severity] || 0) + 1;
|
|
289
|
+
return a;
|
|
290
|
+
}, { critical: 0, high: 0, medium: 0, low: 0 });
|
|
291
|
+
const humanSummary = `File "${resolvedPath}": score ${result.score} (${result.grade}), ${findings.length} issue(s).`;
|
|
292
|
+
const body = {
|
|
293
|
+
humanSummary,
|
|
294
|
+
filePath: resolvedPath,
|
|
295
|
+
lang: useLang,
|
|
296
|
+
score: result.score,
|
|
297
|
+
grade: result.grade,
|
|
298
|
+
findings: findings.length,
|
|
299
|
+
bySeverity: bySev,
|
|
300
|
+
checklist: result.checklist,
|
|
301
|
+
result,
|
|
302
|
+
};
|
|
303
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return { content: [{ type: 'text', text: `scanFile failed: ${e.message}` }], isError: true };
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
server.registerTool('scanRepo', {
|
|
310
|
+
title: 'Scan Repository',
|
|
311
|
+
description: 'Scan files in a repo. rootPath must be inside the bound project folder.',
|
|
312
|
+
inputSchema: {
|
|
313
|
+
rootPath: z.string().default('.').describe('Repo root (must be inside bound folder)'),
|
|
314
|
+
includeGlobs: z.array(z.string()).default(DEFAULT_INCLUDE),
|
|
315
|
+
excludeGlobs: z.array(z.string()).default(DEFAULT_EXCLUDE),
|
|
316
|
+
maxFiles: z.number().int().min(1).max(5000).default(300),
|
|
317
|
+
},
|
|
318
|
+
}, async ({ rootPath, includeGlobs = DEFAULT_INCLUDE, excludeGlobs = DEFAULT_EXCLUDE, maxFiles = 300 }) => {
|
|
319
|
+
try {
|
|
320
|
+
const guard = await guardPath(rootPath);
|
|
321
|
+
if (!guard.ok) return guardError(guard);
|
|
322
|
+
const resolvedRoot = guard.resolvedRoot;
|
|
323
|
+
await ensureDirectory(resolvedRoot);
|
|
324
|
+
const { matchedFiles, limitedFiles, fileResults, aggregate, topRiskFiles } =
|
|
325
|
+
await gatherRepoScan(resolvedRoot, includeGlobs, excludeGlobs, maxFiles);
|
|
326
|
+
const meta = buildScanMeta(resolvedRoot, includeGlobs, excludeGlobs, maxFiles, matchedFiles.length, limitedFiles.length);
|
|
327
|
+
const body = {
|
|
328
|
+
meta,
|
|
329
|
+
humanSummary: humanRepoSummary(meta, aggregate),
|
|
330
|
+
rootPath: resolvedRoot,
|
|
331
|
+
scannedFiles: limitedFiles.length,
|
|
332
|
+
matchedFiles: matchedFiles.length,
|
|
333
|
+
cappedByMaxFiles: matchedFiles.length > maxFiles,
|
|
334
|
+
summary: aggregate.summary,
|
|
335
|
+
checklist: aggregate.checklist,
|
|
336
|
+
topRiskFiles,
|
|
337
|
+
};
|
|
338
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: { ...body, fileResults } };
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return { content: [{ type: 'text', text: `scanRepo failed: ${e.message}` }], isError: true };
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
server.registerTool('scanSummary', {
|
|
345
|
+
title: 'Scan Summary',
|
|
346
|
+
description: 'Compact scan summary for chat. rootPath must be inside bound folder.',
|
|
347
|
+
inputSchema: {
|
|
348
|
+
rootPath: z.string().default('.'),
|
|
349
|
+
includeGlobs: z.array(z.string()).default(DEFAULT_INCLUDE),
|
|
350
|
+
excludeGlobs: z.array(z.string()).default(DEFAULT_EXCLUDE),
|
|
351
|
+
maxFiles: z.number().int().min(1).max(5000).default(200),
|
|
352
|
+
topFindings: z.number().int().min(1).max(50).default(20),
|
|
353
|
+
},
|
|
354
|
+
}, async ({ rootPath, includeGlobs = DEFAULT_INCLUDE, excludeGlobs = DEFAULT_EXCLUDE, maxFiles = 200, topFindings = 20 }) => {
|
|
355
|
+
try {
|
|
356
|
+
const guard = await guardPath(rootPath);
|
|
357
|
+
if (!guard.ok) return guardError(guard);
|
|
358
|
+
const resolvedRoot = guard.resolvedRoot;
|
|
359
|
+
await ensureDirectory(resolvedRoot);
|
|
360
|
+
const { matchedFiles, limitedFiles, fileResults, aggregate } =
|
|
361
|
+
await gatherRepoScan(resolvedRoot, includeGlobs, excludeGlobs, maxFiles);
|
|
362
|
+
const meta = buildScanMeta(resolvedRoot, includeGlobs, excludeGlobs, maxFiles, matchedFiles.length, limitedFiles.length);
|
|
363
|
+
const top = pickTopFindings(fileResults, topFindings);
|
|
364
|
+
const payload = {
|
|
365
|
+
meta,
|
|
366
|
+
humanSummary: humanRepoSummary(meta, aggregate),
|
|
367
|
+
summary: aggregate.summary,
|
|
368
|
+
checklist: { passed: aggregate.checklist.filter((c) => c.pass).length, total: aggregate.checklist.length },
|
|
369
|
+
topFindings: top,
|
|
370
|
+
};
|
|
371
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], structuredContent: payload };
|
|
372
|
+
} catch (e) {
|
|
373
|
+
return { content: [{ type: 'text', text: `scanSummary failed: ${e.message}` }], isError: true };
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
server.registerTool('scanCurrentWorkspace', {
|
|
378
|
+
title: 'Scan Current Workspace',
|
|
379
|
+
description: 'Scan workspace auto-detected from IDE environment. Must match bound folder.',
|
|
380
|
+
inputSchema: {
|
|
381
|
+
includeGlobs: z.array(z.string()).default(DEFAULT_INCLUDE),
|
|
382
|
+
excludeGlobs: z.array(z.string()).default(DEFAULT_EXCLUDE),
|
|
383
|
+
maxFiles: z.number().int().min(1).max(5000).default(300),
|
|
384
|
+
},
|
|
385
|
+
}, async ({ includeGlobs = DEFAULT_INCLUDE, excludeGlobs = DEFAULT_EXCLUDE, maxFiles = 300 }) => {
|
|
386
|
+
try {
|
|
387
|
+
const detected = detectWorkspacePath();
|
|
388
|
+
if (isHomePath(detected)) {
|
|
389
|
+
return {
|
|
390
|
+
content: [{ type: 'text', text: `Could not safely detect workspace (resolved to home: ${detected}). Use scanRepo with explicit rootPath.` }],
|
|
391
|
+
isError: true,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const guard = await guardPath(detected);
|
|
395
|
+
if (!guard.ok) return guardError(guard);
|
|
396
|
+
await ensureDirectory(guard.resolvedRoot);
|
|
397
|
+
const { matchedFiles, limitedFiles, fileResults, aggregate, topRiskFiles } =
|
|
398
|
+
await gatherRepoScan(guard.resolvedRoot, includeGlobs, excludeGlobs, maxFiles);
|
|
399
|
+
const meta = buildScanMeta(guard.resolvedRoot, includeGlobs, excludeGlobs, maxFiles, matchedFiles.length, limitedFiles.length);
|
|
400
|
+
const body = {
|
|
401
|
+
meta,
|
|
402
|
+
humanSummary: humanRepoSummary(meta, aggregate),
|
|
403
|
+
rootPath: guard.resolvedRoot,
|
|
404
|
+
scannedFiles: limitedFiles.length,
|
|
405
|
+
matchedFiles: matchedFiles.length,
|
|
406
|
+
cappedByMaxFiles: matchedFiles.length > maxFiles,
|
|
407
|
+
summary: aggregate.summary,
|
|
408
|
+
checklist: aggregate.checklist,
|
|
409
|
+
topRiskFiles,
|
|
410
|
+
};
|
|
411
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: { ...body, fileResults } };
|
|
412
|
+
} catch (e) {
|
|
413
|
+
return { content: [{ type: 'text', text: `scanCurrentWorkspace failed: ${e.message}` }], isError: true };
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
server.registerTool('projectChecklist', {
|
|
418
|
+
title: 'Project Checklist',
|
|
419
|
+
description: 'Security checklist with evidence. rootPath must be inside bound folder.',
|
|
420
|
+
inputSchema: {
|
|
421
|
+
rootPath: z.string().default('.'),
|
|
422
|
+
includeGlobs: z.array(z.string()).default(DEFAULT_INCLUDE),
|
|
423
|
+
excludeGlobs: z.array(z.string()).default(DEFAULT_EXCLUDE),
|
|
424
|
+
maxFiles: z.number().int().min(1).max(5000).default(300),
|
|
425
|
+
},
|
|
426
|
+
}, async ({ rootPath, includeGlobs = DEFAULT_INCLUDE, excludeGlobs = DEFAULT_EXCLUDE, maxFiles = 300 }) => {
|
|
427
|
+
try {
|
|
428
|
+
const guard = await guardPath(rootPath);
|
|
429
|
+
if (!guard.ok) return guardError(guard);
|
|
430
|
+
const resolvedRoot = guard.resolvedRoot;
|
|
431
|
+
await ensureDirectory(resolvedRoot);
|
|
432
|
+
const { matchedFiles, limitedFiles, fileResults, aggregate } =
|
|
433
|
+
await gatherRepoScan(resolvedRoot, includeGlobs, excludeGlobs, maxFiles);
|
|
434
|
+
const gitChecks = await readGitignoreChecks(resolvedRoot);
|
|
435
|
+
const checklistWithEvidence = aggregate.checklist.map((item) => {
|
|
436
|
+
if (item.id === 'CL02') return { ...item, pass: gitChecks.hasEnvPattern, evidence: gitChecks };
|
|
437
|
+
const ruleIds = CHECKLIST.find((c) => c.id === item.id)?.ruleIds || [];
|
|
438
|
+
const violations = fileResults.flatMap((fr) =>
|
|
439
|
+
fr.result.findings.filter((f) => ruleIds.includes(f.ruleId))
|
|
440
|
+
.map((f) => ({ filePath: fr.filePath, ruleId: f.ruleId, ruleName: f.ruleName, severity: f.severity, lineNumber: f.lineNumber })),
|
|
441
|
+
);
|
|
442
|
+
return { ...item, pass: violations.length === 0, evidence: violations.slice(0, 30) };
|
|
443
|
+
});
|
|
444
|
+
const meta = buildScanMeta(resolvedRoot, includeGlobs, excludeGlobs, maxFiles, matchedFiles.length, limitedFiles.length);
|
|
445
|
+
const passed = checklistWithEvidence.filter((c) => c.pass).length;
|
|
446
|
+
const body = {
|
|
447
|
+
meta,
|
|
448
|
+
humanSummary: `${humanRepoSummary(meta, aggregate)} Checklist: ${passed}/${checklistWithEvidence.length} pass.`,
|
|
449
|
+
rootPath: resolvedRoot,
|
|
450
|
+
scannedFiles: limitedFiles.length,
|
|
451
|
+
matchedFiles: matchedFiles.length,
|
|
452
|
+
summary: aggregate.summary,
|
|
453
|
+
checklist: checklistWithEvidence,
|
|
454
|
+
};
|
|
455
|
+
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
|
|
456
|
+
} catch (e) {
|
|
457
|
+
return { content: [{ type: 'text', text: `projectChecklist failed: ${e.message}` }], isError: true };
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
server.registerTool('buildClaudePrompt', {
|
|
462
|
+
title: 'Build Claude Prompt',
|
|
463
|
+
description: 'Build a Vibesecur prompt for deep AI security analysis (no folder lock needed).',
|
|
464
|
+
inputSchema: {
|
|
465
|
+
code: z.string().min(1).max(50000),
|
|
466
|
+
platform: z.string().min(1).max(50).default('cursor'),
|
|
467
|
+
mode: z.enum(['quick', 'deep', 'supabase', 'ownership']).default('quick'),
|
|
468
|
+
lang: z.enum(['js', 'ts', 'py', 'json', 'auto']).default('auto'),
|
|
469
|
+
},
|
|
470
|
+
}, async ({ code, platform = 'cursor', mode = 'quick', lang = 'auto' }) => {
|
|
471
|
+
const prompt = buildClaudePrompt(code, platform, mode, lang);
|
|
472
|
+
return {
|
|
473
|
+
content: [{ type: 'text', text: prompt }],
|
|
474
|
+
structuredContent: { platform, mode, lang, codeChars: code.length, engineVersion: mcpPkg.version, prompt },
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
async function main() {
|
|
479
|
+
const transport = new StdioServerTransport();
|
|
480
|
+
await server.connect(transport);
|
|
481
|
+
process.stderr.write(
|
|
482
|
+
`[vibesecur] MCP server v${mcpPkg.version} started. ` +
|
|
483
|
+
`BoundRoot: ${BOUND_ROOT || 'unconfigured'}. ` +
|
|
484
|
+
`Lock: ${INSTALL_TOKEN ? 'set' : 'NOT SET'}.\n`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
main().catch((e) => {
|
|
489
|
+
process.stderr.write(`[vibesecur] Fatal: ${e.message}\n`);
|
|
490
|
+
process.exit(1);
|
|
491
|
+
});
|