@getvetai/cli 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/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/commands/audit.d.ts +5 -0
- package/dist/commands/audit.js +45 -0
- package/dist/commands/find.d.ts +4 -0
- package/dist/commands/find.js +34 -0
- package/dist/commands/install.d.ts +5 -0
- package/dist/commands/install.js +44 -0
- package/dist/commands/scan.d.ts +4 -0
- package/dist/commands/scan.js +53 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +42 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +1 -0
- package/dist/utils/analyzer.d.ts +3 -0
- package/dist/utils/analyzer.js +94 -0
- package/dist/utils/config.d.ts +14 -0
- package/dist/utils/config.js +86 -0
- package/dist/utils/display.d.ts +12 -0
- package/dist/utils/display.js +137 -0
- package/dist/utils/fetch.d.ts +6 -0
- package/dist/utils/fetch.js +38 -0
- package/dist/utils/mcp-analyzer.d.ts +19 -0
- package/dist/utils/mcp-analyzer.js +187 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 getvet.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @getvetai/cli
|
|
2
|
+
|
|
3
|
+
Security audit CLI for AI skills and MCP servers. Scan, audit, and score tools before you install them.
|
|
4
|
+
|
|
5
|
+
🌐 **Registry:** [getvet.ai](https://getvet.ai) — 12,000+ AI tools cataloged and scored
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @getvetai/cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
### `vet scan <target>`
|
|
16
|
+
|
|
17
|
+
Scan a single tool for security issues. Accepts file paths, URLs, npm packages, or GitHub repos.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Scan a local SKILL.md
|
|
21
|
+
vet scan ./my-skill/SKILL.md
|
|
22
|
+
|
|
23
|
+
# Scan an npm package
|
|
24
|
+
vet scan @modelcontextprotocol/server-filesystem
|
|
25
|
+
|
|
26
|
+
# Scan a GitHub repo
|
|
27
|
+
vet scan https://github.com/modelcontextprotocol/servers
|
|
28
|
+
|
|
29
|
+
# Output JSON
|
|
30
|
+
vet scan ./SKILL.md --json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Output includes:** trust score, badge (certified/reviewed/unverified/flagged), detected permissions, security issues, risk factors, and tools list.
|
|
34
|
+
|
|
35
|
+
### `vet audit [path]`
|
|
36
|
+
|
|
37
|
+
Audit all AI tools in a project. Discovers tools from `package.json`, MCP configs (`.cursor/mcp.json`, Claude Desktop config), OpenClaw configs, and `SKILL.md` files.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Audit current directory
|
|
41
|
+
vet audit
|
|
42
|
+
|
|
43
|
+
# Audit a specific project
|
|
44
|
+
vet audit ./my-project
|
|
45
|
+
|
|
46
|
+
# Strict mode — exit code 1 if any tool is unverified or flagged
|
|
47
|
+
vet audit --strict
|
|
48
|
+
|
|
49
|
+
# JSON output
|
|
50
|
+
vet audit --json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### `vet find <query>`
|
|
54
|
+
|
|
55
|
+
Search the getvet.ai registry by description.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Search for tools
|
|
59
|
+
vet find "file management"
|
|
60
|
+
vet find "database query tool"
|
|
61
|
+
|
|
62
|
+
# JSON output
|
|
63
|
+
vet find "weather" --json
|
|
64
|
+
|
|
65
|
+
# Use local API
|
|
66
|
+
vet find "search" --api http://localhost:3300
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### `vet install <package>`
|
|
70
|
+
|
|
71
|
+
Install a package with a pre-install security audit. Shows the security report and asks for confirmation if the tool is flagged.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Audit + install npm package
|
|
75
|
+
vet install @modelcontextprotocol/server-github
|
|
76
|
+
|
|
77
|
+
# Install globally
|
|
78
|
+
vet install -g some-mcp-server
|
|
79
|
+
|
|
80
|
+
# Install as OpenClaw skill
|
|
81
|
+
vet install --skill weather
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Trust Scores
|
|
85
|
+
|
|
86
|
+
| Score | Badge | Meaning |
|
|
87
|
+
|-------|-------|---------|
|
|
88
|
+
| 75+ | ✅ Certified | No critical issues, good practices |
|
|
89
|
+
| 50-74 | 🔍 Reviewed | Some concerns, use with caution |
|
|
90
|
+
| 25-49 | ⚠️ Unverified | Not yet reviewed or limited info |
|
|
91
|
+
| 0-24 | 🚫 Flagged | Critical security issues found |
|
|
92
|
+
|
|
93
|
+
## What It Detects
|
|
94
|
+
|
|
95
|
+
**Permissions:** shell execution, file read/write, network access, browser control, message sending, device access (camera, screen, location), database queries, crypto operations.
|
|
96
|
+
|
|
97
|
+
**Security Issues:** destructive commands (`rm -rf`), remote code execution (`curl | bash`), dynamic code eval, credential patterns, elevated privileges (`sudo`), permissive file permissions.
|
|
98
|
+
|
|
99
|
+
**MCP-specific:** tool parameter analysis, transport detection (stdio/http/sse), runtime detection, environment variable scanning.
|
|
100
|
+
|
|
101
|
+
## API
|
|
102
|
+
|
|
103
|
+
By default, `vet find` uses the public API at `https://getvet.ai`. Use `--api` to point to a different instance.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT — [getvet.ai](https://getvet.ai)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { discoverTools } from '../utils/config.js';
|
|
4
|
+
import { analyzeSkill } from '../utils/analyzer.js';
|
|
5
|
+
import { analyzeMcp } from '../utils/mcp-analyzer.js';
|
|
6
|
+
import { displayAuditReport } from '../utils/display.js';
|
|
7
|
+
export async function auditCommand(path, options) {
|
|
8
|
+
const pp = resolve(path || '.');
|
|
9
|
+
const spinner = ora(`Scanning ${pp}...`).start();
|
|
10
|
+
try {
|
|
11
|
+
const { tools, sources } = discoverTools(pp);
|
|
12
|
+
if (!tools.length) {
|
|
13
|
+
spinner.info('No AI tools found.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
spinner.text = `Found ${tools.length} tools...`;
|
|
17
|
+
const results = [];
|
|
18
|
+
for (const t of tools) {
|
|
19
|
+
spinner.text = `Analyzing ${t.name}...`;
|
|
20
|
+
try {
|
|
21
|
+
if (t.type === 'skill' && t.content)
|
|
22
|
+
results.push(analyzeSkill(t.content, t.name));
|
|
23
|
+
else if (t.type === 'mcp-package' || t.type === 'mcp-config')
|
|
24
|
+
results.push(await analyzeMcp({ npmPackage: t.target }));
|
|
25
|
+
else
|
|
26
|
+
results.push(analyzeSkill('', t.name));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
results.push({ name: t.name, permissions: [], issues: [{ severity: 'warning', message: 'Analysis failed' }], trustScore: 0, badge: 'unverified', codeQuality: { hasTests: false, hasDocs: false, linesOfCode: 0 }, riskFactors: [] });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
spinner.stop();
|
|
33
|
+
if (options.json)
|
|
34
|
+
console.log(JSON.stringify({ sources, results }, null, 2));
|
|
35
|
+
else
|
|
36
|
+
displayAuditReport(results, sources);
|
|
37
|
+
process.exitCode = options.strict
|
|
38
|
+
? (results.some(r => r.badge === 'unverified' || r.badge === 'flagged') ? 1 : 0)
|
|
39
|
+
: (results.some(r => r.badge === 'flagged') ? 1 : 0);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
spinner.fail(`Failed: ${err.message}`);
|
|
43
|
+
process.exitCode = 2;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import ora from 'ora';
|
|
2
|
+
import { displayFindResults } from '../utils/display.js';
|
|
3
|
+
export async function findCommand(query, options) {
|
|
4
|
+
const spinner = ora(`Searching "${query}"...`).start();
|
|
5
|
+
const api = options.api || 'https://getvet.ai';
|
|
6
|
+
try {
|
|
7
|
+
const r = await fetch(`${api}/api/skills/search?q=${encodeURIComponent(query)}`);
|
|
8
|
+
if (!r.ok)
|
|
9
|
+
throw new Error(`API returned ${r.status}`);
|
|
10
|
+
const data = await r.json();
|
|
11
|
+
// Handle both array response and { results: [...] } response
|
|
12
|
+
const items = Array.isArray(data) ? data : (data.results || []);
|
|
13
|
+
const results = items.map(x => ({
|
|
14
|
+
name: x.name,
|
|
15
|
+
slug: x.slug,
|
|
16
|
+
description: x.description,
|
|
17
|
+
trustScore: x.trustScore ?? x.trust_score,
|
|
18
|
+
badge: x.badge,
|
|
19
|
+
author: x.author,
|
|
20
|
+
version: x.version,
|
|
21
|
+
installs: x.installs,
|
|
22
|
+
}));
|
|
23
|
+
spinner.stop();
|
|
24
|
+
if (options.json)
|
|
25
|
+
console.log(JSON.stringify(results, null, 2));
|
|
26
|
+
else
|
|
27
|
+
displayFindResults(results);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
spinner.fail(`Search failed: ${err.message}`);
|
|
31
|
+
console.log(' Tip: use --api http://localhost:3300 for local dev');
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { analyzeMcp } from '../utils/mcp-analyzer.js';
|
|
6
|
+
import { displayScanReport } from '../utils/display.js';
|
|
7
|
+
async function confirm(msg) {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
return new Promise(res => {
|
|
10
|
+
rl.question(msg, a => { rl.close(); res(a.toLowerCase() === 'y' || a.toLowerCase() === 'yes'); });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export async function installCommand(pkg, options) {
|
|
14
|
+
const spinner = ora(`Auditing ${pkg}...`).start();
|
|
15
|
+
try {
|
|
16
|
+
const result = await analyzeMcp({ npmPackage: pkg });
|
|
17
|
+
spinner.stop();
|
|
18
|
+
displayScanReport(result);
|
|
19
|
+
if (result.badge === 'flagged') {
|
|
20
|
+
console.log(chalk.red.bold(' ⚠️ This package has been flagged!'));
|
|
21
|
+
if (!await confirm(chalk.yellow(' Install anyway? (y/N) '))) {
|
|
22
|
+
console.log(chalk.gray(' Cancelled.'));
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const s2 = ora(`Installing ${pkg}...`).start();
|
|
28
|
+
try {
|
|
29
|
+
if (options.skill)
|
|
30
|
+
execSync(`npx clawhub install ${pkg}`, { stdio: 'pipe' });
|
|
31
|
+
else
|
|
32
|
+
execSync(`npm install${options.global ? ' -g' : ''} ${pkg}`, { stdio: 'pipe' });
|
|
33
|
+
s2.succeed(`${pkg} installed`);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
s2.fail(`Install failed: ${e.message}`);
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
spinner.fail(`Audit failed: ${err.message}`);
|
|
42
|
+
process.exitCode = 2;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { analyzeSkill } from '../utils/analyzer.js';
|
|
4
|
+
import { analyzeMcp } from '../utils/mcp-analyzer.js';
|
|
5
|
+
import { displayScanReport } from '../utils/display.js';
|
|
6
|
+
function detect(t) {
|
|
7
|
+
if (/^https?:\/\/github\.com\//.test(t))
|
|
8
|
+
return 'github';
|
|
9
|
+
if (/^https?:\/\//.test(t))
|
|
10
|
+
return 'url';
|
|
11
|
+
if (existsSync(t))
|
|
12
|
+
return 'file';
|
|
13
|
+
return 'npm';
|
|
14
|
+
}
|
|
15
|
+
export async function scanCommand(target, options) {
|
|
16
|
+
const spinner = ora('Analyzing...').start();
|
|
17
|
+
try {
|
|
18
|
+
const tt = detect(target);
|
|
19
|
+
let result;
|
|
20
|
+
if (tt === 'npm') {
|
|
21
|
+
spinner.text = `Fetching npm: ${target}`;
|
|
22
|
+
result = await analyzeMcp({ npmPackage: target });
|
|
23
|
+
}
|
|
24
|
+
else if (tt === 'github') {
|
|
25
|
+
spinner.text = `Fetching GitHub: ${target}`;
|
|
26
|
+
result = await analyzeMcp({ githubUrl: target });
|
|
27
|
+
}
|
|
28
|
+
else if (tt === 'url') {
|
|
29
|
+
spinner.text = `Fetching: ${target}`;
|
|
30
|
+
const resp = await fetch(target, { headers: { 'User-Agent': 'Vet/1.0' } });
|
|
31
|
+
if (!resp.ok)
|
|
32
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
33
|
+
const content = await resp.text();
|
|
34
|
+
result = target.endsWith('SKILL.md') ? analyzeSkill(content) : await analyzeMcp({ readme: content, name: target.split('/').pop()?.replace(/\.(md|json)$/, '') });
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const content = readFileSync(target, 'utf-8');
|
|
38
|
+
result = target.endsWith('SKILL.md') ? analyzeSkill(content)
|
|
39
|
+
: (content.includes('MCP') || target.includes('mcp')) ? await analyzeMcp({ readme: content, name: target.split('/').pop()?.replace(/\.(md|json)$/, '') })
|
|
40
|
+
: analyzeSkill(content);
|
|
41
|
+
}
|
|
42
|
+
spinner.stop();
|
|
43
|
+
if (options.json)
|
|
44
|
+
console.log(JSON.stringify(result, null, 2));
|
|
45
|
+
else
|
|
46
|
+
displayScanReport(result);
|
|
47
|
+
process.exitCode = result.badge === 'flagged' ? 1 : 0;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
spinner.fail(`Failed: ${err.message}`);
|
|
51
|
+
process.exitCode = 2;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { scanCommand } from './commands/scan.js';
|
|
4
|
+
import { auditCommand } from './commands/audit.js';
|
|
5
|
+
import { findCommand } from './commands/find.js';
|
|
6
|
+
import { installCommand } from './commands/install.js';
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name('vet')
|
|
10
|
+
.description('Security audit CLI for AI skills & MCP servers')
|
|
11
|
+
.version('0.1.0');
|
|
12
|
+
program
|
|
13
|
+
.command('scan')
|
|
14
|
+
.description('Scan a single tool for security issues')
|
|
15
|
+
.argument('<target>', 'URL, npm package, file path, or GitHub repo')
|
|
16
|
+
.option('--json', 'Output JSON instead of formatted report')
|
|
17
|
+
.option('--overview', 'Include AI-generated overview')
|
|
18
|
+
.action(scanCommand);
|
|
19
|
+
program
|
|
20
|
+
.command('audit')
|
|
21
|
+
.description('Audit all AI tools in a project')
|
|
22
|
+
.argument('[path]', 'Project path (defaults to current directory)')
|
|
23
|
+
.option('--json', 'Output JSON')
|
|
24
|
+
.option('--strict', 'Exit code 1 if any tool is unverified or flagged')
|
|
25
|
+
.option('--fix', 'Suggest safer alternatives for flagged tools')
|
|
26
|
+
.action(auditCommand);
|
|
27
|
+
program
|
|
28
|
+
.command('find')
|
|
29
|
+
.description('Search for tools by description')
|
|
30
|
+
.argument('<query>', 'Natural language search query')
|
|
31
|
+
.option('--json', 'Output JSON')
|
|
32
|
+
.option('--api <url>', 'API endpoint (default: https://getvet.ai)')
|
|
33
|
+
.action(findCommand);
|
|
34
|
+
program
|
|
35
|
+
.command('install')
|
|
36
|
+
.description('Install a package with pre-install security audit')
|
|
37
|
+
.argument('<package>', 'npm package name')
|
|
38
|
+
.option('--json', 'Output JSON')
|
|
39
|
+
.option('-g, --global', 'Install globally')
|
|
40
|
+
.option('--skill', 'Install as OpenClaw skill via clawhub')
|
|
41
|
+
.action(installCommand);
|
|
42
|
+
program.parse();
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type BadgeType = 'certified' | 'reviewed' | 'unverified' | 'flagged';
|
|
2
|
+
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
|
3
|
+
export type SkillType = 'skill' | 'mcp';
|
|
4
|
+
export type Transport = 'stdio' | 'http' | 'sse';
|
|
5
|
+
export interface Permission {
|
|
6
|
+
type: string;
|
|
7
|
+
target?: string;
|
|
8
|
+
risk: RiskLevel;
|
|
9
|
+
detected_by?: string;
|
|
10
|
+
evidence?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface SecurityIssue {
|
|
13
|
+
severity: RiskLevel | 'info' | 'warning';
|
|
14
|
+
message: string;
|
|
15
|
+
count?: number;
|
|
16
|
+
evidence?: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface CodeQuality {
|
|
19
|
+
hasTests: boolean;
|
|
20
|
+
hasDocs: boolean;
|
|
21
|
+
hasLicense?: boolean;
|
|
22
|
+
hasPermissionDeclaration?: boolean;
|
|
23
|
+
linesOfCode: number;
|
|
24
|
+
dependencyCount?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface McpTool {
|
|
27
|
+
name: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
inputSchema?: McpToolParam[];
|
|
30
|
+
permissions?: Permission[];
|
|
31
|
+
}
|
|
32
|
+
export interface McpToolParam {
|
|
33
|
+
name: string;
|
|
34
|
+
type: string;
|
|
35
|
+
description: string;
|
|
36
|
+
}
|
|
37
|
+
export interface AnalysisResult {
|
|
38
|
+
name: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
type?: SkillType;
|
|
41
|
+
transport?: Transport;
|
|
42
|
+
runtime?: string;
|
|
43
|
+
tools?: McpTool[];
|
|
44
|
+
permissions: Permission[];
|
|
45
|
+
issues: SecurityIssue[];
|
|
46
|
+
trustScore: number;
|
|
47
|
+
badge: BadgeType;
|
|
48
|
+
codeQuality: CodeQuality;
|
|
49
|
+
riskFactors: string[];
|
|
50
|
+
overview?: string;
|
|
51
|
+
envVars?: string[];
|
|
52
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const PERM_PATTERNS = [
|
|
2
|
+
{ pattern: /\bexec\b|\bshell\b|\bcommand\b|\bspawn\b|\bchild_process\b/gi, type: 'exec:shell', risk: 'critical' },
|
|
3
|
+
{ pattern: /\bread\b.*file|\bfs[:\.]read|\bReadFile|\bfile.*read/gi, type: 'fs:read', risk: 'low' },
|
|
4
|
+
{ pattern: /\bwrite\b.*file|\bfs[:\.]write|\bWriteFile|\bfile.*write|\bcreate.*file/gi, type: 'fs:write', risk: 'medium' },
|
|
5
|
+
{ pattern: /\bweb_fetch\b|\bfetch\b|\bhttp[s]?:\/\/|\baxios\b|\brequest\b/gi, type: 'net:outbound', risk: 'medium' },
|
|
6
|
+
{ pattern: /\bbrowser\b|\bpuppeteer\b|\bplaywright\b|\bselenium\b/gi, type: 'browser:control', risk: 'high' },
|
|
7
|
+
{ pattern: /\bmessage\b.*send|\bsend.*message|\bemail\b|\bslack\b|\bdiscord\b|\btelegram\b/gi, type: 'msg:send', risk: 'high' },
|
|
8
|
+
{ pattern: /\bprocess\b|\bbackground\b|\bdaemon\b/gi, type: 'exec:process', risk: 'medium' },
|
|
9
|
+
{ pattern: /\bweb_search\b|\bsearch.*web/gi, type: 'net:search', risk: 'low' },
|
|
10
|
+
{ pattern: /\bcrypto\b|\bsign\b|\bencrypt\b|\bdecrypt\b/gi, type: 'crypto:sign', risk: 'medium' },
|
|
11
|
+
{ pattern: /\bimage\b.*analy|\bvision\b|\bocr\b/gi, type: 'media:analyze', risk: 'low' },
|
|
12
|
+
{ pattern: /\btts\b|\btext.to.speech\b|\bspeech\b/gi, type: 'media:tts', risk: 'low' },
|
|
13
|
+
{ pattern: /\bcanvas\b|\bpresent\b.*ui/gi, type: 'ui:canvas', risk: 'low' },
|
|
14
|
+
{ pattern: /\bnodes?\b.*camera|\bcamera\b.*snap/gi, type: 'device:camera', risk: 'high' },
|
|
15
|
+
{ pattern: /\bscreen\b.*record|\bscreenshot\b/gi, type: 'device:screen', risk: 'medium' },
|
|
16
|
+
{ pattern: /\blocation\b|\bgps\b|\bgeolocat/gi, type: 'device:location', risk: 'high' },
|
|
17
|
+
];
|
|
18
|
+
const RISKY_PATTERNS = [
|
|
19
|
+
{ pattern: /rm\s+-rf\b/g, message: 'Destructive file deletion (rm -rf)', severity: 'critical' },
|
|
20
|
+
{ pattern: /curl\s.*\|\s*bash/g, message: 'Remote code execution (curl | bash)', severity: 'critical' },
|
|
21
|
+
{ pattern: /\beval\s*\(/g, message: 'Dynamic code execution (eval)', severity: 'critical' },
|
|
22
|
+
{ pattern: /\bnew\s+Function\s*\(/g, message: 'Dynamic function creation', severity: 'high' },
|
|
23
|
+
{ pattern: /\b(password|secret|token|api[_-]?key|private[_-]?key|mnemonic)\b/gi, message: 'Credential/secret pattern detected', severity: 'high' },
|
|
24
|
+
{ pattern: /\bchmod\s+[0-7]*7[0-7]*\b/g, message: 'Permissive file permissions', severity: 'high' },
|
|
25
|
+
{ pattern: /\bsudo\b/g, message: 'Sudo/elevated privilege usage', severity: 'critical' },
|
|
26
|
+
{ pattern: /\bssh\b.*connect|\bssh\s+-/g, message: 'SSH connection', severity: 'high' },
|
|
27
|
+
{ pattern: /\bcrontab\b|\bsystemctl\b/g, message: 'System service manipulation', severity: 'high' },
|
|
28
|
+
];
|
|
29
|
+
export function parseSkillName(content, fallback = 'unknown') {
|
|
30
|
+
const h = content.match(/^#\s+(.+)$/m);
|
|
31
|
+
if (h) {
|
|
32
|
+
const n = h[1].trim().replace(/[*_`]/g, '').trim();
|
|
33
|
+
if (n.length > 0 && n.length < 100)
|
|
34
|
+
return n;
|
|
35
|
+
}
|
|
36
|
+
if (fallback !== 'unknown')
|
|
37
|
+
return fallback.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
export function analyzeSkill(content, name = 'unknown') {
|
|
41
|
+
const parsedName = parseSkillName(content, name);
|
|
42
|
+
const permissions = [], issues = [], seen = new Set();
|
|
43
|
+
for (const p of PERM_PATTERNS) {
|
|
44
|
+
const m = content.match(p.pattern);
|
|
45
|
+
if (m && !seen.has(p.type)) {
|
|
46
|
+
seen.add(p.type);
|
|
47
|
+
permissions.push({ type: p.type, risk: p.risk, detected_by: 'static', evidence: m.slice(0, 3).map(x => x.trim()) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const r of RISKY_PATTERNS) {
|
|
51
|
+
const m = content.match(r.pattern);
|
|
52
|
+
if (m)
|
|
53
|
+
issues.push({ severity: r.severity, message: r.message, count: m.length, evidence: m.slice(0, 3).map(x => x.trim()) });
|
|
54
|
+
}
|
|
55
|
+
let ts = 80;
|
|
56
|
+
for (const p of permissions) {
|
|
57
|
+
if (p.risk === 'critical')
|
|
58
|
+
ts -= 15;
|
|
59
|
+
else if (p.risk === 'high')
|
|
60
|
+
ts -= 10;
|
|
61
|
+
else if (p.risk === 'medium')
|
|
62
|
+
ts -= 5;
|
|
63
|
+
}
|
|
64
|
+
for (const i of issues) {
|
|
65
|
+
if (i.severity === 'critical')
|
|
66
|
+
ts -= 20;
|
|
67
|
+
else if (i.severity === 'high')
|
|
68
|
+
ts -= 10;
|
|
69
|
+
else if (i.severity === 'medium')
|
|
70
|
+
ts -= 5;
|
|
71
|
+
}
|
|
72
|
+
const cq = {
|
|
73
|
+
hasTests: /\btest\b|\bspec\b|\bjest\b/i.test(content), hasDocs: /\b(readme|documentation|usage|example)\b/i.test(content),
|
|
74
|
+
hasLicense: /\blicense\b|\bMIT\b|\bApache\b/i.test(content), hasPermissionDeclaration: /\bpermission\b|\baccess\b|\brequire\b/i.test(content),
|
|
75
|
+
linesOfCode: content.split('\n').length,
|
|
76
|
+
};
|
|
77
|
+
if (cq.hasTests)
|
|
78
|
+
ts += 5;
|
|
79
|
+
if (cq.hasDocs)
|
|
80
|
+
ts += 3;
|
|
81
|
+
if (cq.hasLicense)
|
|
82
|
+
ts += 2;
|
|
83
|
+
if (cq.hasPermissionDeclaration)
|
|
84
|
+
ts += 5;
|
|
85
|
+
ts = Math.max(0, Math.min(100, ts));
|
|
86
|
+
let badge = 'unverified';
|
|
87
|
+
if (issues.some(i => i.severity === 'critical'))
|
|
88
|
+
badge = 'flagged';
|
|
89
|
+
else if (ts >= 80)
|
|
90
|
+
badge = 'certified';
|
|
91
|
+
else if (ts >= 50)
|
|
92
|
+
badge = 'reviewed';
|
|
93
|
+
return { name: parsedName, type: 'skill', permissions, issues, trustScore: ts, badge, codeQuality: cq, riskFactors: issues.filter(i => i.severity === 'critical' || i.severity === 'high').map(i => i.message) };
|
|
94
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface DiscoveredTool {
|
|
2
|
+
name: string;
|
|
3
|
+
source: string;
|
|
4
|
+
type: 'mcp-config' | 'mcp-package' | 'skill' | 'openclaw-skill';
|
|
5
|
+
target?: string;
|
|
6
|
+
content?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function discoverTools(projectPath: string): {
|
|
9
|
+
tools: DiscoveredTool[];
|
|
10
|
+
sources: {
|
|
11
|
+
source: string;
|
|
12
|
+
count: number;
|
|
13
|
+
}[];
|
|
14
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
export function discoverTools(projectPath) {
|
|
5
|
+
const tools = [], sc = new Map();
|
|
6
|
+
const add = (s, n) => { if (n > 0)
|
|
7
|
+
sc.set(s, (sc.get(s) || 0) + n); };
|
|
8
|
+
const pkgPath = join(projectPath, 'package.json');
|
|
9
|
+
if (existsSync(pkgPath)) {
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
12
|
+
const all = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
13
|
+
let c = 0;
|
|
14
|
+
for (const d of Object.keys(all)) {
|
|
15
|
+
if (d.includes('mcp') || d.startsWith('@modelcontextprotocol/')) {
|
|
16
|
+
tools.push({ name: d, source: 'package.json', type: 'mcp-package', target: d });
|
|
17
|
+
c++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
add('package.json', c);
|
|
21
|
+
}
|
|
22
|
+
catch { /* skip */ }
|
|
23
|
+
}
|
|
24
|
+
const mcpPaths = [
|
|
25
|
+
join(projectPath, '.cursor', 'mcp.json'),
|
|
26
|
+
join(projectPath, 'mcp.json'),
|
|
27
|
+
join(homedir(), '.config', 'claude', 'claude_desktop_config.json'),
|
|
28
|
+
join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
29
|
+
];
|
|
30
|
+
for (const cp of mcpPaths) {
|
|
31
|
+
if (!existsSync(cp))
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
const cfg = JSON.parse(readFileSync(cp, 'utf-8'));
|
|
35
|
+
const svrs = cfg.mcpServers || cfg.servers || {};
|
|
36
|
+
let c = 0;
|
|
37
|
+
for (const [n, v] of Object.entries(svrs)) {
|
|
38
|
+
const target = (v.command === 'npx' ? v.args?.[0] : undefined) || n;
|
|
39
|
+
tools.push({ name: n, source: cp.replace(homedir(), '~'), type: 'mcp-config', target });
|
|
40
|
+
c++;
|
|
41
|
+
}
|
|
42
|
+
add(cp.replace(homedir(), '~'), c);
|
|
43
|
+
}
|
|
44
|
+
catch { /* skip */ }
|
|
45
|
+
}
|
|
46
|
+
for (const op of [join(projectPath, 'openclaw.json'), join(homedir(), '.openclaw', 'openclaw.json')]) {
|
|
47
|
+
if (!existsSync(op))
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
const cfg = JSON.parse(readFileSync(op, 'utf-8'));
|
|
51
|
+
const skills = cfg.skills || cfg.installedSkills || [];
|
|
52
|
+
let c = 0;
|
|
53
|
+
if (Array.isArray(skills)) {
|
|
54
|
+
for (const s of skills) {
|
|
55
|
+
const n = typeof s === 'string' ? s : s.name;
|
|
56
|
+
tools.push({ name: n, source: op.replace(homedir(), '~'), type: 'openclaw-skill', target: n });
|
|
57
|
+
c++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
add(op.replace(homedir(), '~'), c);
|
|
61
|
+
}
|
|
62
|
+
catch { /* skip */ }
|
|
63
|
+
}
|
|
64
|
+
findSkills(projectPath, tools, sc, 0);
|
|
65
|
+
return { tools, sources: [...sc.entries()].map(([source, count]) => ({ source, count })) };
|
|
66
|
+
}
|
|
67
|
+
function findSkills(dir, tools, sc, depth) {
|
|
68
|
+
if (depth > 4)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
72
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist')
|
|
73
|
+
continue;
|
|
74
|
+
const fp = join(dir, e.name);
|
|
75
|
+
if (e.isFile() && e.name === 'SKILL.md') {
|
|
76
|
+
const content = readFileSync(fp, 'utf-8');
|
|
77
|
+
const nm = content.match(/^#\s+(.+)$/m)?.[1]?.trim().replace(/[*_`]/g, '') || e.name;
|
|
78
|
+
tools.push({ name: nm, source: fp, type: 'skill', target: fp, content });
|
|
79
|
+
sc.set('SKILL.md files', (sc.get('SKILL.md files') || 0) + 1);
|
|
80
|
+
}
|
|
81
|
+
if (e.isDirectory())
|
|
82
|
+
findSkills(fp, tools, sc, depth + 1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { /* permission denied */ }
|
|
86
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AnalysisResult, BadgeType } from '../types.js';
|
|
2
|
+
export declare function displayScanReport(r: AnalysisResult): void;
|
|
3
|
+
export declare function displayAuditReport(results: AnalysisResult[], sources: {
|
|
4
|
+
source: string;
|
|
5
|
+
count: number;
|
|
6
|
+
}[]): void;
|
|
7
|
+
export declare function displayFindResults(results: Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
trustScore?: number;
|
|
11
|
+
badge?: BadgeType;
|
|
12
|
+
}>): void;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
const BADGE = {
|
|
3
|
+
certified: { emoji: '✅', label: 'Certified', color: chalk.green },
|
|
4
|
+
reviewed: { emoji: '🔍', label: 'Reviewed', color: chalk.blue },
|
|
5
|
+
unverified: { emoji: '⚠️', label: 'Unverified', color: chalk.yellow },
|
|
6
|
+
flagged: { emoji: '🚫', label: 'Flagged', color: chalk.red },
|
|
7
|
+
};
|
|
8
|
+
const RC = { low: chalk.green, medium: chalk.yellow, high: chalk.red, critical: chalk.redBright.bold };
|
|
9
|
+
function bar(score) {
|
|
10
|
+
const f = Math.round(score / 10), e = 10 - f;
|
|
11
|
+
const c = score >= 75 ? chalk.green : score >= 50 ? chalk.yellow : chalk.red;
|
|
12
|
+
return c('█'.repeat(f)) + chalk.gray('░'.repeat(e)) + ' ' + c(`${score}/100`);
|
|
13
|
+
}
|
|
14
|
+
function dot(risk) {
|
|
15
|
+
return RC[risk](`⬤ ${risk.charAt(0).toUpperCase() + risk.slice(1)}`);
|
|
16
|
+
}
|
|
17
|
+
function overallRisk(r) {
|
|
18
|
+
if (r.badge === 'flagged')
|
|
19
|
+
return 'critical';
|
|
20
|
+
if (r.trustScore < 50)
|
|
21
|
+
return 'high';
|
|
22
|
+
if (r.trustScore < 75)
|
|
23
|
+
return 'medium';
|
|
24
|
+
return 'low';
|
|
25
|
+
}
|
|
26
|
+
export function displayScanReport(r) {
|
|
27
|
+
const b = BADGE[r.badge], rk = overallRisk(r);
|
|
28
|
+
console.log();
|
|
29
|
+
console.log(chalk.bold(' 🔍 Vet Security Report'));
|
|
30
|
+
console.log(chalk.gray(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
31
|
+
console.log();
|
|
32
|
+
console.log(` ${chalk.gray('Name:')} ${chalk.bold(r.name)}`);
|
|
33
|
+
if (r.description)
|
|
34
|
+
console.log(` ${chalk.gray('Description:')} ${r.description.slice(0, 80)}`);
|
|
35
|
+
if (r.type === 'mcp') {
|
|
36
|
+
console.log(` ${chalk.gray('Type:')} MCP Server`);
|
|
37
|
+
if (r.transport)
|
|
38
|
+
console.log(` ${chalk.gray('Transport:')} ${r.transport}`);
|
|
39
|
+
if (r.runtime)
|
|
40
|
+
console.log(` ${chalk.gray('Runtime:')} ${r.runtime}`);
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
console.log(` ${chalk.gray('Type:')} AI Skill`);
|
|
44
|
+
console.log();
|
|
45
|
+
console.log(` ${chalk.gray('Trust Score:')} ${bar(r.trustScore)}`);
|
|
46
|
+
console.log(` ${chalk.gray('Badge:')} ${b.emoji} ${b.color(b.label)}`);
|
|
47
|
+
console.log(` ${chalk.gray('Risk:')} ${dot(rk)}`);
|
|
48
|
+
if (r.permissions.length > 0) {
|
|
49
|
+
console.log();
|
|
50
|
+
console.log(chalk.bold(' 📋 Permissions'));
|
|
51
|
+
console.log(chalk.gray(` ${'Permission'.padEnd(25)} ${'Risk'.padEnd(12)} Evidence`));
|
|
52
|
+
console.log(chalk.gray(` ${'─'.repeat(25)} ${'─'.repeat(12)} ${'─'.repeat(30)}`));
|
|
53
|
+
for (const p of r.permissions)
|
|
54
|
+
console.log(` ${p.type.padEnd(25)} ${RC[p.risk](p.risk.padEnd(12))} ${chalk.gray(p.evidence?.slice(0, 2).join(', ') || '')}`);
|
|
55
|
+
}
|
|
56
|
+
if (r.issues.length > 0) {
|
|
57
|
+
console.log();
|
|
58
|
+
console.log(chalk.bold(' ⚠️ Issues'));
|
|
59
|
+
for (const i of r.issues) {
|
|
60
|
+
const icon = i.severity === 'critical' ? '🔴' : i.severity === 'high' ? '🟠' : i.severity === 'medium' ? '🟡' : '🔵';
|
|
61
|
+
const cf = RC[i.severity];
|
|
62
|
+
console.log(` ${icon} ${cf ? cf(i.message) : i.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (r.tools && r.tools.length > 0) {
|
|
66
|
+
console.log();
|
|
67
|
+
console.log(chalk.bold(` 🔧 Tools (${r.tools.length})`));
|
|
68
|
+
for (const t of r.tools.slice(0, 15)) {
|
|
69
|
+
console.log(` ${chalk.cyan(t.name.padEnd(25))} ${chalk.gray(t.description?.slice(0, 40) || '')}`);
|
|
70
|
+
const ps = t.inputSchema?.map(p => p.name).join(', ');
|
|
71
|
+
if (ps)
|
|
72
|
+
console.log(` ${' '.repeat(25)} ${chalk.gray(`params: ${ps}`)}`);
|
|
73
|
+
}
|
|
74
|
+
if (r.tools.length > 15)
|
|
75
|
+
console.log(chalk.gray(` ... and ${r.tools.length - 15} more`));
|
|
76
|
+
}
|
|
77
|
+
if (r.envVars && r.envVars.length > 0) {
|
|
78
|
+
console.log();
|
|
79
|
+
console.log(chalk.bold(' 🔑 Environment Variables'));
|
|
80
|
+
for (const v of r.envVars)
|
|
81
|
+
console.log(` ${chalk.yellow(v)}`);
|
|
82
|
+
}
|
|
83
|
+
if (r.overview) {
|
|
84
|
+
console.log();
|
|
85
|
+
console.log(chalk.bold(' 📝 Overview'));
|
|
86
|
+
for (const l of r.overview.split('\n'))
|
|
87
|
+
console.log(` ${chalk.gray(l)}`);
|
|
88
|
+
}
|
|
89
|
+
console.log();
|
|
90
|
+
console.log(chalk.gray(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
91
|
+
console.log();
|
|
92
|
+
}
|
|
93
|
+
export function displayAuditReport(results, sources) {
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(chalk.bold(' 🔍 Vet Audit Report'));
|
|
96
|
+
console.log(chalk.gray(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
97
|
+
console.log();
|
|
98
|
+
for (const s of sources)
|
|
99
|
+
console.log(` 📦 Found ${s.count} tool${s.count !== 1 ? 's' : ''} in ${s.source}`);
|
|
100
|
+
console.log();
|
|
101
|
+
const W = 35;
|
|
102
|
+
console.log(chalk.gray(` ${'Tool'.padEnd(W)} ${'Score'.padEnd(7)} ${'Badge'.padEnd(16)} Risk`));
|
|
103
|
+
console.log(chalk.gray(` ${'─'.repeat(W)} ${'─'.repeat(7)} ${'─'.repeat(16)} ${'─'.repeat(12)}`));
|
|
104
|
+
for (const r of results) {
|
|
105
|
+
const b = BADGE[r.badge], rk = overallRisk(r);
|
|
106
|
+
const nm = r.name.length > W - 1 ? r.name.slice(0, W - 4) + '...' : r.name;
|
|
107
|
+
const sc = r.trustScore >= 75 ? chalk.green : r.trustScore >= 50 ? chalk.yellow : chalk.red;
|
|
108
|
+
console.log(` ${nm.padEnd(W)} ${sc(String(r.trustScore).padEnd(7))} ${b.emoji} ${b.color(b.label.padEnd(13))} ${dot(rk)}`);
|
|
109
|
+
}
|
|
110
|
+
console.log();
|
|
111
|
+
console.log(chalk.gray(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
112
|
+
const c = { certified: 0, reviewed: 0, unverified: 0, flagged: 0 };
|
|
113
|
+
for (const r of results)
|
|
114
|
+
c[r.badge]++;
|
|
115
|
+
console.log(` Summary: ${results.length} tools audited | ${c.certified} certified | ${c.reviewed} reviewed | ${c.unverified} unverified | ${c.flagged} flagged`);
|
|
116
|
+
if (c.flagged > 0)
|
|
117
|
+
console.log(chalk.red.bold(` ⚠️ ${c.flagged} tool${c.flagged > 1 ? 's' : ''} requires attention`));
|
|
118
|
+
console.log();
|
|
119
|
+
}
|
|
120
|
+
export function displayFindResults(results) {
|
|
121
|
+
if (!results.length) {
|
|
122
|
+
console.log(chalk.yellow('\n No results found.\n'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
console.log();
|
|
126
|
+
console.log(chalk.bold(` 🔎 Search Results (${results.length})`));
|
|
127
|
+
console.log(chalk.gray(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
128
|
+
for (const r of results) {
|
|
129
|
+
const b = r.badge ? BADGE[r.badge] : BADGE.unverified;
|
|
130
|
+
console.log();
|
|
131
|
+
console.log(` ${b.emoji} ${chalk.bold(r.name)}`);
|
|
132
|
+
if (r.description)
|
|
133
|
+
console.log(` ${chalk.gray(r.description.slice(0, 80))}`);
|
|
134
|
+
console.log(` Score: ${r.trustScore != null ? bar(r.trustScore) : chalk.gray('N/A')}`);
|
|
135
|
+
}
|
|
136
|
+
console.log();
|
|
137
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
function detect(content, source) {
|
|
3
|
+
if (/SKILL\.md/i.test(source) || /^#\s+.*skill/im.test(content))
|
|
4
|
+
return 'skill';
|
|
5
|
+
if (/mcp|model.context.protocol/i.test(content))
|
|
6
|
+
return 'mcp';
|
|
7
|
+
if (/^#\s+/m.test(content))
|
|
8
|
+
return 'skill';
|
|
9
|
+
return 'unknown';
|
|
10
|
+
}
|
|
11
|
+
export async function fetchTarget(target) {
|
|
12
|
+
if (existsSync(target)) {
|
|
13
|
+
const c = readFileSync(target, 'utf-8');
|
|
14
|
+
return { content: c, type: detect(c, target), name: target.split('/').pop() || target };
|
|
15
|
+
}
|
|
16
|
+
if (/^https?:\/\//i.test(target)) {
|
|
17
|
+
const r = await fetch(target);
|
|
18
|
+
if (!r.ok)
|
|
19
|
+
throw new Error('HTTP ' + r.status);
|
|
20
|
+
const c = await r.text();
|
|
21
|
+
return { content: c, type: detect(c, target), name: target.split('/').pop()?.replace(/\?.*$/, '') || target };
|
|
22
|
+
}
|
|
23
|
+
if (/^[\w-]+\/[\w.-]+$/.test(target) && !target.startsWith('@')) {
|
|
24
|
+
const r = await fetch('https://raw.githubusercontent.com/' + target + '/main/README.md');
|
|
25
|
+
if (r.ok)
|
|
26
|
+
return { content: await r.text(), type: 'mcp', name: target.split('/')[1] };
|
|
27
|
+
throw new Error('GitHub fetch failed: ' + target);
|
|
28
|
+
}
|
|
29
|
+
if (/^@?[\w-]/.test(target)) {
|
|
30
|
+
const r = await fetch('https://registry.npmjs.org/' + encodeURIComponent(target));
|
|
31
|
+
if (r.ok) {
|
|
32
|
+
const d = await r.json();
|
|
33
|
+
return { content: d.readme || '', type: 'mcp', name: d.name || target };
|
|
34
|
+
}
|
|
35
|
+
throw new Error('npm not found: ' + target);
|
|
36
|
+
}
|
|
37
|
+
throw new Error('Unknown target: ' + target);
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AnalysisResult } from '../types.js';
|
|
2
|
+
interface PJ {
|
|
3
|
+
name?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
version?: string;
|
|
6
|
+
dependencies?: Record<string, string>;
|
|
7
|
+
devDependencies?: Record<string, string>;
|
|
8
|
+
engines?: {
|
|
9
|
+
node?: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export declare function analyzeMcp(input: {
|
|
13
|
+
npmPackage?: string;
|
|
14
|
+
githubUrl?: string;
|
|
15
|
+
readme?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
packageJson?: PJ;
|
|
18
|
+
}): Promise<AnalysisResult>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const TOOL_RULES = [
|
|
2
|
+
{ paramPattern: /\bcommand\b|\bcmd\b|\bshell\b/i, namePattern: /\bexec\b|\brun\b|\bshell\b/i, type: 'exec:shell', risk: 'critical' },
|
|
3
|
+
{ paramPattern: /\burl\b|\bendpoint\b|\bhref\b/i, namePattern: /\bfetch\b|\brequest\b|\bhttp\b/i, type: 'net:outbound', risk: 'medium' },
|
|
4
|
+
{ paramPattern: /\bpath\b|\bfile\b|\bfilename\b|\bdirectory\b/i, namePattern: /\bread\b|\bget\b|\blist\b|\bsearch\b/i, type: 'fs:read', risk: 'medium' },
|
|
5
|
+
{ paramPattern: /\bpath\b|\bfile\b|\bfilename\b/i, namePattern: /\bwrite\b|\bcreate\b|\bsave\b|\bupdate\b|\bedit\b/i, type: 'fs:write', risk: 'medium' },
|
|
6
|
+
{ paramPattern: /\bquery\b|\bsql\b|\bstatement\b/i, namePattern: /\bquery\b|\bsql\b|\bdb\b|\bdatabase\b/i, type: 'db:query', risk: 'medium' },
|
|
7
|
+
{ paramPattern: null, namePattern: /\bemail\b|\bmessage\b|\bsend\b|\bnotif/i, type: 'msg:send', risk: 'medium' },
|
|
8
|
+
{ paramPattern: null, namePattern: /\bdelete\b|\bremove\b|\bdrop\b|\bpurge\b|\bdestroy\b/i, type: 'data:delete', risk: 'high' },
|
|
9
|
+
];
|
|
10
|
+
const RISKY = [
|
|
11
|
+
{ pattern: /rm\s+-rf\b/g, message: 'Destructive file deletion (rm -rf)', severity: 'critical' },
|
|
12
|
+
{ pattern: /curl\s.*\|\s*bash/g, message: 'Remote code execution (curl | bash)', severity: 'critical' },
|
|
13
|
+
{ pattern: /\beval\s*\(/g, message: 'Dynamic code execution (eval)', severity: 'critical' },
|
|
14
|
+
{ pattern: /\bnew\s+Function\s*\(/g, message: 'Dynamic function creation', severity: 'high' },
|
|
15
|
+
{ pattern: /\bchild_process\b|\bspawn\b|\bexecSync\b/g, message: 'Shell execution via child_process', severity: 'high' },
|
|
16
|
+
{ pattern: /\bsudo\b/g, message: 'Sudo/elevated privilege usage', severity: 'critical' },
|
|
17
|
+
{ pattern: /\b(password|secret|token|api[_-]?key|private[_-]?key)\b/gi, message: 'Credential/secret pattern detected', severity: 'medium' },
|
|
18
|
+
];
|
|
19
|
+
const SENS_ENV = /key|token|secret|password|credential|auth/i;
|
|
20
|
+
function detectTransport(t) {
|
|
21
|
+
if (/\bsse\b|server.sent.event/i.test(t))
|
|
22
|
+
return 'sse';
|
|
23
|
+
if (/\bhttp\b.*transport|streamablehttp|\/mcp\b/i.test(t) && !/\bstdio\b/i.test(t))
|
|
24
|
+
return 'http';
|
|
25
|
+
return 'stdio';
|
|
26
|
+
}
|
|
27
|
+
function detectRuntime(pj, t) {
|
|
28
|
+
if (pj?.dependencies?.['@modelcontextprotocol/sdk'] || pj?.devDependencies?.['typescript'] || pj?.engines?.node)
|
|
29
|
+
return 'node';
|
|
30
|
+
if (/pyproject\.toml|requirements\.txt|\bpip\b|\bpython\b/i.test(t))
|
|
31
|
+
return 'python';
|
|
32
|
+
if (/\bgo\.mod\b/i.test(t))
|
|
33
|
+
return 'go';
|
|
34
|
+
if (/\bCargo\.toml\b/i.test(t))
|
|
35
|
+
return 'rust';
|
|
36
|
+
return 'node';
|
|
37
|
+
}
|
|
38
|
+
function parseTools(text) {
|
|
39
|
+
const tools = [];
|
|
40
|
+
for (const m of text.matchAll(/###\s+`?(\w[\w_-]*)`?\s*\n([\s\S]*?)(?=\n###\s|\n##\s|$)/g)) {
|
|
41
|
+
const name = m[1], body = m[2];
|
|
42
|
+
if (/^(installation|setup|configuration|usage|example|prerequisites|requirements|license|contributing)/i.test(name))
|
|
43
|
+
continue;
|
|
44
|
+
const desc = body.split('\n').find((l) => l.trim() && !l.startsWith('#') && !l.startsWith('|') && !l.startsWith('-'))?.trim() || '';
|
|
45
|
+
const params = [];
|
|
46
|
+
for (const r of body.matchAll(/\|\s*`?(\w+)`?\s*\|\s*(\w+)\s*\|\s*([^|]*)\|/g))
|
|
47
|
+
params.push({ name: r[1], type: r[2], description: r[3].trim() });
|
|
48
|
+
for (const b of body.matchAll(/-\s+[`*]*(\w+)[`*]*\s*(?:\((\w+)\))?[:\s-]+(.+)/g)) {
|
|
49
|
+
if (!params.find((p) => p.name === b[1]))
|
|
50
|
+
params.push({ name: b[1], type: b[2] || 'string', description: b[3].trim() });
|
|
51
|
+
}
|
|
52
|
+
if (params.length > 0 || /tool|function|method|action|command/i.test(body.slice(0, 200)))
|
|
53
|
+
tools.push({ name, description: desc.slice(0, 300), inputSchema: params });
|
|
54
|
+
}
|
|
55
|
+
if (tools.length === 0) {
|
|
56
|
+
for (const m of text.matchAll(/-\s+\*\*(\w[\w_-]*)\*\*\s*[-:\u2013]\s*(.+)/g)) {
|
|
57
|
+
if (!/install|setup|config|require|license|example|usage/i.test(m[1]))
|
|
58
|
+
tools.push({ name: m[1], description: m[2].trim().slice(0, 300), inputSchema: [] });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return tools;
|
|
62
|
+
}
|
|
63
|
+
function detectEnvVars(text) {
|
|
64
|
+
const vars = new Set();
|
|
65
|
+
for (const m of text.matchAll(/\b([A-Z][A-Z0-9_]{2,})\b/g)) {
|
|
66
|
+
const v = m[1];
|
|
67
|
+
if (/^(API|AWS|AZURE|GCP|GITHUB|OPENAI|ANTHROPIC|DATABASE|DB_|REDIS|MONGO|POSTGRES|MYSQL|SECRET|TOKEN|KEY|PASSWORD|AUTH|SMTP|SLACK|DISCORD|STRIPE)/.test(v) ||
|
|
68
|
+
/_KEY$|_TOKEN$|_SECRET$|_PASSWORD$|_URL$|_URI$|_HOST$|_PORT$/.test(v))
|
|
69
|
+
vars.add(v);
|
|
70
|
+
}
|
|
71
|
+
return [...vars];
|
|
72
|
+
}
|
|
73
|
+
function toolPerms(tool) {
|
|
74
|
+
const perms = [], seen = new Set();
|
|
75
|
+
for (const r of TOOL_RULES) {
|
|
76
|
+
let hit = false;
|
|
77
|
+
if (r.namePattern?.test(tool.name))
|
|
78
|
+
hit = true;
|
|
79
|
+
if (r.paramPattern && tool.inputSchema?.some((p) => r.paramPattern.test(p.name)))
|
|
80
|
+
hit = true;
|
|
81
|
+
if (hit && !seen.has(r.type)) {
|
|
82
|
+
seen.add(r.type);
|
|
83
|
+
perms.push({ type: r.type, risk: r.risk, detected_by: 'mcp-static', evidence: [tool.name] });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return perms;
|
|
87
|
+
}
|
|
88
|
+
export async function analyzeMcp(input) {
|
|
89
|
+
let text = input.readme || '', pj = input.packageJson || null, pkgName = input.name || input.npmPackage || '', desc = '', deps = 0;
|
|
90
|
+
if (input.npmPackage) {
|
|
91
|
+
try {
|
|
92
|
+
const r = await fetch(`https://registry.npmjs.org/${encodeURIComponent(input.npmPackage)}`);
|
|
93
|
+
if (r.ok) {
|
|
94
|
+
const d = await r.json();
|
|
95
|
+
const lat = d['dist-tags']?.latest;
|
|
96
|
+
const vers = d.versions;
|
|
97
|
+
const lv = lat && vers ? vers[lat] : null;
|
|
98
|
+
pkgName = d.name || input.npmPackage;
|
|
99
|
+
desc = d.description || '';
|
|
100
|
+
text = d.readme || text;
|
|
101
|
+
if (lv) {
|
|
102
|
+
pj = lv;
|
|
103
|
+
deps = Object.keys(lv.dependencies || {}).length + Object.keys(lv.devDependencies || {}).length;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch { /* continue */ }
|
|
108
|
+
}
|
|
109
|
+
if (input.githubUrl) {
|
|
110
|
+
const m = input.githubUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
111
|
+
if (m) {
|
|
112
|
+
const owner = m[1], rn = m[2].replace(/\.git$/, '');
|
|
113
|
+
try {
|
|
114
|
+
const rr = await fetch(`https://raw.githubusercontent.com/${owner}/${rn}/main/README.md`, { headers: { 'User-Agent': 'Vet/1.0' } });
|
|
115
|
+
if (rr.ok)
|
|
116
|
+
text = await rr.text();
|
|
117
|
+
const pr = await fetch(`https://raw.githubusercontent.com/${owner}/${rn}/main/package.json`, { headers: { 'User-Agent': 'Vet/1.0' } });
|
|
118
|
+
if (pr.ok) {
|
|
119
|
+
pj = await pr.json();
|
|
120
|
+
pkgName = pj.name || pkgName || rn;
|
|
121
|
+
desc = desc || pj.description || '';
|
|
122
|
+
deps = Object.keys(pj.dependencies || {}).length + Object.keys(pj.devDependencies || {}).length;
|
|
123
|
+
}
|
|
124
|
+
if (!pkgName)
|
|
125
|
+
pkgName = rn;
|
|
126
|
+
}
|
|
127
|
+
catch { /* continue */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!pkgName)
|
|
131
|
+
pkgName = input.name || 'unknown-mcp';
|
|
132
|
+
const transport = detectTransport(text), runtime = detectRuntime(pj, text), tools = parseTools(text), envVars = detectEnvVars(text);
|
|
133
|
+
const allPerms = [], seenP = new Set();
|
|
134
|
+
for (const tool of tools) {
|
|
135
|
+
tool.permissions = toolPerms(tool);
|
|
136
|
+
for (const p of tool.permissions) {
|
|
137
|
+
if (!seenP.has(p.type)) {
|
|
138
|
+
seenP.add(p.type);
|
|
139
|
+
allPerms.push(p);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const issues = [];
|
|
144
|
+
for (const r of RISKY) {
|
|
145
|
+
const m = text.match(r.pattern);
|
|
146
|
+
if (m)
|
|
147
|
+
issues.push({ severity: r.severity, message: r.message, count: m.length, evidence: m.slice(0, 3) });
|
|
148
|
+
}
|
|
149
|
+
const sensEnv = envVars.filter(v => SENS_ENV.test(v));
|
|
150
|
+
if (sensEnv.length > 0)
|
|
151
|
+
issues.push({ severity: 'medium', message: `Requires sensitive env vars: ${sensEnv.join(', ')}`, count: sensEnv.length, evidence: sensEnv });
|
|
152
|
+
let ts = 75;
|
|
153
|
+
for (const p of allPerms) {
|
|
154
|
+
if (p.risk === 'critical')
|
|
155
|
+
ts -= 15;
|
|
156
|
+
else if (p.risk === 'high')
|
|
157
|
+
ts -= 10;
|
|
158
|
+
else if (p.risk === 'medium')
|
|
159
|
+
ts -= 5;
|
|
160
|
+
}
|
|
161
|
+
for (const i of issues) {
|
|
162
|
+
if (i.severity === 'critical')
|
|
163
|
+
ts -= 20;
|
|
164
|
+
else if (i.severity === 'high')
|
|
165
|
+
ts -= 10;
|
|
166
|
+
else if (i.severity === 'medium')
|
|
167
|
+
ts -= 3;
|
|
168
|
+
}
|
|
169
|
+
if (tools.length > 0)
|
|
170
|
+
ts += 5;
|
|
171
|
+
if (text.length > 500)
|
|
172
|
+
ts += 3;
|
|
173
|
+
ts = Math.max(0, Math.min(100, ts));
|
|
174
|
+
let badge = 'unverified';
|
|
175
|
+
if (issues.some(i => i.severity === 'critical'))
|
|
176
|
+
badge = 'flagged';
|
|
177
|
+
else if (ts >= 75)
|
|
178
|
+
badge = 'certified';
|
|
179
|
+
else if (ts >= 50)
|
|
180
|
+
badge = 'reviewed';
|
|
181
|
+
return {
|
|
182
|
+
name: pkgName, description: desc || text.split('\n').find(l => l.trim() && !l.startsWith('#'))?.trim()?.slice(0, 300) || '',
|
|
183
|
+
type: 'mcp', transport, runtime, tools, permissions: allPerms, issues, trustScore: ts, badge,
|
|
184
|
+
codeQuality: { hasTests: /\btest\b|\bspec\b/i.test(text), hasDocs: text.length > 300, hasLicense: /\blicense\b|\bMIT\b|\bApache\b/i.test(text), hasPermissionDeclaration: /\bpermission\b|\baccess\b/i.test(text), linesOfCode: text.split('\n').length, dependencyCount: deps },
|
|
185
|
+
riskFactors: issues.filter(i => i.severity === 'critical' || i.severity === 'high').map(i => i.message), envVars,
|
|
186
|
+
};
|
|
187
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getvetai/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Security audit CLI for AI skills and MCP servers — scan, audit, and score tools before you install them",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"vet": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/getvetai/vet.git",
|
|
18
|
+
"directory": "cli"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://getvet.ai",
|
|
21
|
+
"bugs": "https://github.com/getvetai/vet/issues",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"ai",
|
|
24
|
+
"security",
|
|
25
|
+
"audit",
|
|
26
|
+
"mcp",
|
|
27
|
+
"openclaw",
|
|
28
|
+
"skills",
|
|
29
|
+
"tools",
|
|
30
|
+
"trust",
|
|
31
|
+
"scanner"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc",
|
|
38
|
+
"dev": "tsx src/index.ts"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"chalk": "^5.3.0",
|
|
42
|
+
"commander": "^12.1.0",
|
|
43
|
+
"ora": "^8.1.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"tsx": "^4.19.0",
|
|
48
|
+
"typescript": "^5.6.0"
|
|
49
|
+
}
|
|
50
|
+
}
|