@gianmarcomaz/vantyr 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +808 -0
- package/bin/vantyr.js +6 -0
- package/package.json +50 -0
- package/src/cli.js +148 -0
- package/src/config/localScanner.js +50 -0
- package/src/fetcher/github.js +155 -0
- package/src/output/json.js +64 -0
- package/src/output/sarif.js +243 -0
- package/src/output/terminal.js +130 -0
- package/src/scanner/commandInjection.js +427 -0
- package/src/scanner/credentialLeaks.js +187 -0
- package/src/scanner/index.js +62 -0
- package/src/scanner/inputValidation.js +243 -0
- package/src/scanner/networkExposure.js +302 -0
- package/src/scanner/specCompliance.js +243 -0
- package/src/scanner/toolPoisoning.js +248 -0
- package/src/scoring/trustScore.js +82 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SARIF 2.1.0 output formatter for vantyr.
|
|
3
|
+
*
|
|
4
|
+
* SARIF (Static Analysis Results Interchange Format) is the standard consumed
|
|
5
|
+
* by GitHub Code Scanning. Uploading this file as a GitHub Actions artifact
|
|
6
|
+
* (upload-sarif action) produces inline PR annotations and populates the repo's
|
|
7
|
+
* "Security → Code scanning" tab automatically.
|
|
8
|
+
*
|
|
9
|
+
* Severity mapping:
|
|
10
|
+
* critical / high → level "error" (blocks PR merge in strict configurations)
|
|
11
|
+
* medium → level "warning"
|
|
12
|
+
* low / info → level "note"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SARIF_SCHEMA =
|
|
16
|
+
'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json';
|
|
17
|
+
|
|
18
|
+
// One rule per analyzer category — each rule appears in the "Rules" panel of
|
|
19
|
+
// GitHub Code Scanning and links to the relevant OWASP MCP guidance.
|
|
20
|
+
const RULES = [
|
|
21
|
+
{
|
|
22
|
+
id: 'MCP-NE',
|
|
23
|
+
name: 'NetworkExposure',
|
|
24
|
+
shortDescription: { text: 'Network Exposure' },
|
|
25
|
+
fullDescription: {
|
|
26
|
+
text: 'Detects wildcard network bindings (0.0.0.0, ::, INADDR_ANY) and unencrypted external HTTP/WebSocket communication that expose MCP endpoints to unauthorized access.',
|
|
27
|
+
},
|
|
28
|
+
helpUri: 'https://owasp.org/www-project-top-10-for-large-language-model-applications/',
|
|
29
|
+
properties: { tags: ['security', 'network', 'mcp'] },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'MCP-CI',
|
|
33
|
+
name: 'CommandInjection',
|
|
34
|
+
shortDescription: { text: 'Command Injection' },
|
|
35
|
+
fullDescription: {
|
|
36
|
+
text: 'Detects shell execution functions (exec, spawn, os.system, subprocess, eval) that could allow remote code execution when driven by LLM-controlled input. Maps to OWASP MCP05.',
|
|
37
|
+
},
|
|
38
|
+
helpUri: 'https://owasp.org/www-project-top-10-for-large-language-model-applications/',
|
|
39
|
+
properties: { tags: ['security', 'injection', 'rce', 'mcp'] },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'MCP-CL',
|
|
43
|
+
name: 'CredentialLeaks',
|
|
44
|
+
shortDescription: { text: 'Credential Leaks' },
|
|
45
|
+
fullDescription: {
|
|
46
|
+
text: 'Detects hardcoded secrets, API keys, tokens, private keys, and database URLs with embedded passwords committed to source control. Maps to OWASP MCP01.',
|
|
47
|
+
},
|
|
48
|
+
helpUri: 'https://owasp.org/www-project-top-10-for-large-language-model-applications/',
|
|
49
|
+
properties: { tags: ['security', 'secrets', 'credentials', 'mcp'] },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'MCP-TP',
|
|
53
|
+
name: 'ToolPoisoning',
|
|
54
|
+
shortDescription: { text: 'Tool Poisoning / Prompt Injection' },
|
|
55
|
+
fullDescription: {
|
|
56
|
+
text: 'Detects prompt injection payloads embedded in MCP tool descriptions: instruction overrides, identity hijacks, zero-width Unicode characters, HTML comments, and shadowed tool names. Maps to OWASP MCP03.',
|
|
57
|
+
},
|
|
58
|
+
helpUri: 'https://owasp.org/www-project-top-10-for-large-language-model-applications/',
|
|
59
|
+
properties: { tags: ['security', 'prompt-injection', 'mcp'] },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'MCP-SC',
|
|
63
|
+
name: 'SpecCompliance',
|
|
64
|
+
shortDescription: { text: 'MCP Spec Compliance' },
|
|
65
|
+
fullDescription: {
|
|
66
|
+
text: 'Validates MCP protocol conformance: server metadata, tool schema declarations, error handling, transport security, and documentation completeness.',
|
|
67
|
+
},
|
|
68
|
+
helpUri: 'https://modelcontextprotocol.io/specification',
|
|
69
|
+
properties: { tags: ['compliance', 'protocol', 'mcp'] },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'MCP-IV',
|
|
73
|
+
name: 'InputValidation',
|
|
74
|
+
shortDescription: { text: 'Input Validation' },
|
|
75
|
+
fullDescription: {
|
|
76
|
+
text: 'Detects unsanitized tool input flowing into dangerous sinks: file operations (path traversal), network requests (SSRF), SQL queries (injection), and dynamic function dispatch.',
|
|
77
|
+
},
|
|
78
|
+
helpUri: 'https://owasp.org/www-project-top-10-for-large-language-model-applications/',
|
|
79
|
+
properties: { tags: ['security', 'input-validation', 'ssrf', 'sqli', 'mcp'] },
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** Map a finding category code to its SARIF rule ID. */
|
|
84
|
+
const CATEGORY_TO_RULE_ID = {
|
|
85
|
+
NE: 'MCP-NE',
|
|
86
|
+
CI: 'MCP-CI',
|
|
87
|
+
CL: 'MCP-CL',
|
|
88
|
+
TP: 'MCP-TP',
|
|
89
|
+
SC: 'MCP-SC',
|
|
90
|
+
IV: 'MCP-IV',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Map vantyr severity levels to SARIF result levels. */
|
|
94
|
+
function toSarifLevel(severity) {
|
|
95
|
+
switch (severity) {
|
|
96
|
+
case 'critical':
|
|
97
|
+
case 'high':
|
|
98
|
+
return 'error';
|
|
99
|
+
case 'medium':
|
|
100
|
+
return 'warning';
|
|
101
|
+
case 'low':
|
|
102
|
+
case 'info':
|
|
103
|
+
default:
|
|
104
|
+
return 'note';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build a SARIF 2.1.0 document from vantyr scan results.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} sourceUrl - The scanned GitHub URL or "Local Configuration & Rules"
|
|
112
|
+
* @param {{ scoreData: object|null, noFiles?: boolean }} results
|
|
113
|
+
* @returns {object} - A plain object ready for JSON.stringify()
|
|
114
|
+
*/
|
|
115
|
+
export function formatSarifResult(sourceUrl, { scoreData, noFiles = false }) {
|
|
116
|
+
if (noFiles || !scoreData) {
|
|
117
|
+
return {
|
|
118
|
+
$schema: SARIF_SCHEMA,
|
|
119
|
+
version: '2.1.0',
|
|
120
|
+
runs: [
|
|
121
|
+
{
|
|
122
|
+
tool: {
|
|
123
|
+
driver: { name: 'vantyr', version: '1.0.0', rules: RULES },
|
|
124
|
+
},
|
|
125
|
+
results: [],
|
|
126
|
+
properties: {
|
|
127
|
+
trustScore: null,
|
|
128
|
+
label: 'NO_FILES',
|
|
129
|
+
source: sourceUrl,
|
|
130
|
+
message: 'No local MCP configuration or rules files found.',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { categories, trustScore, scoreCapped } = scoreData;
|
|
138
|
+
|
|
139
|
+
// Flatten all findings across every category
|
|
140
|
+
const allFindings = Object.entries(categories).flatMap(([key, cat]) =>
|
|
141
|
+
cat.findings.map(f => ({ ...f, category: key }))
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const sarifResults = allFindings.map(finding => {
|
|
145
|
+
const ruleId = CATEGORY_TO_RULE_ID[finding.category] || finding.category;
|
|
146
|
+
const level = toSarifLevel(finding.severity);
|
|
147
|
+
|
|
148
|
+
const result = {
|
|
149
|
+
ruleId,
|
|
150
|
+
level,
|
|
151
|
+
message: {
|
|
152
|
+
text: finding.remediation
|
|
153
|
+
? `${finding.message} — ${finding.remediation}`
|
|
154
|
+
: finding.message,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Attach a physical location when we have a file and line number.
|
|
159
|
+
// Project-level findings (file === "project", no line) get a
|
|
160
|
+
// repository-root location so GitHub still displays them.
|
|
161
|
+
if (finding.file && finding.file !== 'project' && finding.line) {
|
|
162
|
+
result.locations = [
|
|
163
|
+
{
|
|
164
|
+
physicalLocation: {
|
|
165
|
+
artifactLocation: {
|
|
166
|
+
uri: finding.file,
|
|
167
|
+
uriBaseId: '%SRCROOT%',
|
|
168
|
+
},
|
|
169
|
+
region: {
|
|
170
|
+
startLine: finding.line,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
} else {
|
|
176
|
+
result.locations = [
|
|
177
|
+
{
|
|
178
|
+
physicalLocation: {
|
|
179
|
+
artifactLocation: {
|
|
180
|
+
uri: '.',
|
|
181
|
+
uriBaseId: '%SRCROOT%',
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Attach a code snippet when available
|
|
189
|
+
if (finding.snippet) {
|
|
190
|
+
result.locations[0].physicalLocation.region = {
|
|
191
|
+
...(result.locations[0].physicalLocation.region || {}),
|
|
192
|
+
snippet: { text: finding.snippet },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Embed Trust Score context as a property bag on each result
|
|
197
|
+
result.properties = {
|
|
198
|
+
severity: finding.severity,
|
|
199
|
+
trustScore,
|
|
200
|
+
scoreCapped: scoreCapped || false,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return result;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
$schema: SARIF_SCHEMA,
|
|
208
|
+
version: '2.1.0',
|
|
209
|
+
runs: [
|
|
210
|
+
{
|
|
211
|
+
tool: {
|
|
212
|
+
driver: {
|
|
213
|
+
name: 'vantyr',
|
|
214
|
+
version: '1.0.0',
|
|
215
|
+
informationUri: 'https://github.com/gianmarcomaz/vantyr',
|
|
216
|
+
rules: RULES,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
// Encode the scanned source as a conversion provenance note
|
|
220
|
+
conversion: {
|
|
221
|
+
tool: {
|
|
222
|
+
driver: {
|
|
223
|
+
name: 'vantyr',
|
|
224
|
+
version: '1.0.0',
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
invocation: {
|
|
228
|
+
commandLine: `vantyr scan ${sourceUrl}`,
|
|
229
|
+
executionSuccessful: true,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
results: sarifResults,
|
|
233
|
+
// Surface the Trust Score as a run-level property visible in dashboards
|
|
234
|
+
properties: {
|
|
235
|
+
trustScore,
|
|
236
|
+
label: trustScore >= 80 ? 'CERTIFIED' : trustScore >= 50 ? 'WARNING' : 'FAILED',
|
|
237
|
+
scoreCapped: scoreCapped || false,
|
|
238
|
+
source: sourceUrl,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prints the results to the terminal matching the exact required format.
|
|
5
|
+
* @param {string} sourceUrl - The GitHub URL or 'Local'
|
|
6
|
+
* @param {Object} results - { scoreData, checks }
|
|
7
|
+
*/
|
|
8
|
+
export function printTerminalResult(sourceUrl, results) {
|
|
9
|
+
const { trustScore, categories, totalFindings, stats, scoreCapped } = results.scoreData;
|
|
10
|
+
|
|
11
|
+
console.log();
|
|
12
|
+
let finalLabel = 'CERTIFIED';
|
|
13
|
+
if (trustScore < 50) {
|
|
14
|
+
finalLabel = 'FAILED';
|
|
15
|
+
} else if (trustScore < 80) {
|
|
16
|
+
finalLabel = 'WARNING';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Print header
|
|
20
|
+
console.log(`🔒 Vantyr — Trust Score: ${chalk.bold(trustScore)}/100 ${finalLabel}`);
|
|
21
|
+
if (scoreCapped) {
|
|
22
|
+
console.log(chalk.yellow(' ⚠ Score capped at 75: HIGH or CRITICAL findings present. Resolve all critical issues to achieve CERTIFIED.'));
|
|
23
|
+
}
|
|
24
|
+
console.log();
|
|
25
|
+
|
|
26
|
+
const allFindings = [];
|
|
27
|
+
|
|
28
|
+
const orderedChecks = [
|
|
29
|
+
{ name: "Network Exposure", key: "NE" },
|
|
30
|
+
{ name: "Command Injection", key: "CI" },
|
|
31
|
+
{ name: "Credential Leaks", key: "CL" },
|
|
32
|
+
{ name: "Tool Poisoning", key: "TP" },
|
|
33
|
+
{ name: "Spec Compliance", key: "SC" },
|
|
34
|
+
{ name: "Input Validation", key: "IV" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
let passedChecks = 0;
|
|
38
|
+
|
|
39
|
+
for (const check of orderedChecks) {
|
|
40
|
+
const cat = categories[check.key];
|
|
41
|
+
const pScore = cat.score;
|
|
42
|
+
const findingCount = cat.findings.length;
|
|
43
|
+
|
|
44
|
+
let icon = '✅';
|
|
45
|
+
let colorChalk = chalk.green;
|
|
46
|
+
|
|
47
|
+
if (pScore < 50) {
|
|
48
|
+
icon = '❌';
|
|
49
|
+
colorChalk = chalk.red;
|
|
50
|
+
} else if (pScore < 80) {
|
|
51
|
+
icon = '⚠️ ';
|
|
52
|
+
colorChalk = chalk.yellow;
|
|
53
|
+
} else {
|
|
54
|
+
passedChecks++;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let findingText = '';
|
|
58
|
+
if (findingCount > 0) {
|
|
59
|
+
findingText = ` (${findingCount} finding${findingCount > 1 ? 's' : ''})`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(`${colorChalk(icon)} ${check.name.padEnd(20)} — ${colorChalk(`${pScore}/100`)}${findingText}`);
|
|
63
|
+
|
|
64
|
+
allFindings.push(...cat.findings.map(f => ({ ...f, categoryName: check.name })));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log();
|
|
68
|
+
console.log(chalk.dim('─────────────────────────────────────────────────'));
|
|
69
|
+
console.log();
|
|
70
|
+
|
|
71
|
+
if (allFindings.length > 0) {
|
|
72
|
+
console.log('Remediations:');
|
|
73
|
+
console.log();
|
|
74
|
+
|
|
75
|
+
const seenRemediations = new Set();
|
|
76
|
+
|
|
77
|
+
for (const finding of allFindings) {
|
|
78
|
+
let isCrit = finding.severity === 'critical';
|
|
79
|
+
let isHigh = finding.severity === 'high';
|
|
80
|
+
let isMed = finding.severity === 'medium';
|
|
81
|
+
let isLow = finding.severity === 'low';
|
|
82
|
+
let isInfo = finding.severity === 'info';
|
|
83
|
+
|
|
84
|
+
let icon = 'ℹ ';
|
|
85
|
+
let headerColor = chalk.dim;
|
|
86
|
+
|
|
87
|
+
if (isCrit || isHigh) {
|
|
88
|
+
icon = '❌';
|
|
89
|
+
headerColor = chalk.red;
|
|
90
|
+
} else if (isMed) {
|
|
91
|
+
icon = '⚠️ ';
|
|
92
|
+
headerColor = chalk.yellow;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const badge = `[${finding.severity.toUpperCase()}]`;
|
|
96
|
+
const categoryBadge = `[${finding.categoryName}]`;
|
|
97
|
+
const filename = finding.file ? finding.file.split(/[\\/]/).pop() : 'project';
|
|
98
|
+
|
|
99
|
+
let header = `${icon} ${headerColor(badge)} ${chalk.cyan(categoryBadge)}`;
|
|
100
|
+
if (finding.line) {
|
|
101
|
+
header += ` Line ${finding.line} in ${filename}`;
|
|
102
|
+
} else {
|
|
103
|
+
header += ` In ${filename}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const messageStr = finding.message || '';
|
|
107
|
+
const remStr = finding.remediation || '';
|
|
108
|
+
|
|
109
|
+
// Deduplication logic
|
|
110
|
+
const uniqKey = `${finding.severity}-${finding.categoryName}-${finding.file}-${finding.line}-${messageStr}-${remStr}`;
|
|
111
|
+
if (seenRemediations.has(uniqKey)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
seenRemediations.add(uniqKey);
|
|
115
|
+
|
|
116
|
+
console.log(` ${header}`);
|
|
117
|
+
console.log(` ${messageStr}`);
|
|
118
|
+
if (remStr && remStr !== messageStr) {
|
|
119
|
+
console.log(` → ${remStr}`);
|
|
120
|
+
}
|
|
121
|
+
console.log();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(chalk.dim('─────────────────────────────────────────────────'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`Scanned: ${sourceUrl}`);
|
|
128
|
+
console.log(`Checks: 6 | Critical: ${stats.critical} | High: ${stats.high} | Medium: ${stats.medium} | Low: ${stats.low} | Pass: ${passedChecks}/6`);
|
|
129
|
+
console.log();
|
|
130
|
+
}
|