@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,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Injection Analyzer
|
|
3
|
+
* Scans for dangerous code execution patterns that could allow RCE via LLM input.
|
|
4
|
+
*
|
|
5
|
+
* Context-aware:
|
|
6
|
+
* - Test files are excluded (not production code)
|
|
7
|
+
* - Install-time scripts (preinstall, postinstall, setup) get reduced severity
|
|
8
|
+
* - CLI tools (cmd/, cli/, scripts/) are treated as lower risk
|
|
9
|
+
* - Go's exec.Command() is NOT shell execution (args are explicit, no shell)
|
|
10
|
+
* - Import/require statements are NOT findings (only actual calls matter)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/* ════════════════════════════════════════════════════
|
|
14
|
+
Test‑file & CLI‑tool detection
|
|
15
|
+
════════════════════════════════════════════════════ */
|
|
16
|
+
|
|
17
|
+
/** Matches test file paths across JS/TS/Go/Python */
|
|
18
|
+
const TEST_FILE_RE =
|
|
19
|
+
/(?:[\\/](?:__tests__|tests?|spec|__mocks__|mock|fixture|e2e|testdata|testutil)[\\/]|[\\/](?:.*_test|.*\.test|.*\.spec)\.[a-z]+$)/i;
|
|
20
|
+
|
|
21
|
+
/** Matches CLI‑tool / build‑script paths */
|
|
22
|
+
const CLI_TOOL_RE =
|
|
23
|
+
/(?:^|[\\/])(?:cmd|cli|scripts|tools|bin|hack|contrib)[\\/]/i;
|
|
24
|
+
|
|
25
|
+
/** Install-time script filenames — run at setup, not at runtime */
|
|
26
|
+
const INSTALL_TIME_FILENAMES = new Set([
|
|
27
|
+
"preinstall.js", "postinstall.js", "preinstall.ts", "postinstall.ts",
|
|
28
|
+
"setup.js", "setup.ts", "setup.sh", "setup.bat",
|
|
29
|
+
"install.js", "install.sh",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a file is an install-time script.
|
|
34
|
+
* Matches by filename or by being referenced in package.json scripts.
|
|
35
|
+
*/
|
|
36
|
+
function isInstallTimeFile(filePath, allFiles) {
|
|
37
|
+
// Check filename
|
|
38
|
+
const basename = filePath.replace(/^.*[\\/]/, "").toLowerCase();
|
|
39
|
+
if (INSTALL_TIME_FILENAMES.has(basename)) return true;
|
|
40
|
+
|
|
41
|
+
// Check if referenced in package.json install scripts
|
|
42
|
+
const pkgFile = allFiles.find((f) => /[\\/]?package\.json$/.test(f.path));
|
|
43
|
+
if (pkgFile) {
|
|
44
|
+
try {
|
|
45
|
+
const pkg = JSON.parse(pkgFile.content);
|
|
46
|
+
const installScripts = [
|
|
47
|
+
pkg.scripts?.preinstall,
|
|
48
|
+
pkg.scripts?.postinstall,
|
|
49
|
+
pkg.scripts?.prepare,
|
|
50
|
+
].filter(Boolean);
|
|
51
|
+
// Check if this file is referenced in any install script
|
|
52
|
+
if (installScripts.some((s) => s.includes(basename))) return true;
|
|
53
|
+
} catch { /* invalid json */ }
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ════════════════════════════════════════════════════
|
|
59
|
+
Dangerous function patterns
|
|
60
|
+
════════════════════════════════════════════════════ */
|
|
61
|
+
|
|
62
|
+
const DANGEROUS_FUNCTIONS = [
|
|
63
|
+
// JavaScript / TypeScript — these use a shell by default
|
|
64
|
+
{ regex: /(?:^|[^a-zA-Z0-9_.])exec\s*\(|child_process\.exec\s*\(/, lang: "js", shellBased: true },
|
|
65
|
+
{ regex: /(?:^|[^a-zA-Z0-9_.])execSync\s*\(|child_process\.execSync\s*\(/, lang: "js", shellBased: true },
|
|
66
|
+
{ regex: /(?:^|[^a-zA-Z0-9_.])execFile\s*\(|child_process\.execFile\s*\(/, lang: "js", shellBased: false },
|
|
67
|
+
{ regex: /(?:^|[^a-zA-Z0-9_.])execFileSync\s*\(|child_process\.execFileSync\s*\(/, lang: "js", shellBased: false },
|
|
68
|
+
{ regex: /(?:^|[^a-zA-Z0-9_.])spawn\s*\(|child_process\.spawn\s*\(/, lang: "js", shellBased: false },
|
|
69
|
+
{ regex: /(?:^|[^a-zA-Z0-9_.])spawnSync\s*\(|child_process\.spawnSync\s*\(/, lang: "js", shellBased: false },
|
|
70
|
+
{ regex: /(?:^|[^a-zA-Z0-9_.])eval\s*\(/, lang: "js", shellBased: true },
|
|
71
|
+
{ regex: /new\s+Function\s*\(/, lang: "js", shellBased: true },
|
|
72
|
+
{ regex: /vm\.runIn(?:New|This)?Context\s*\(/, lang: "js", shellBased: true },
|
|
73
|
+
// Python — os.system uses shell; subprocess can go either way
|
|
74
|
+
{ regex: /os\.system\s*\(/, lang: "py", shellBased: true },
|
|
75
|
+
{ regex: /subprocess\.(?:run|Popen|call|check_output|check_call)\s*\(/, lang: "py", shellBased: false }, // Will check for shell=True dynamically
|
|
76
|
+
{ regex: /(?:^|[^a-zA-Z0-9_.])exec\s*\(/, lang: "py", shellBased: true },
|
|
77
|
+
{ regex: /(?:^|[^a-zA-Z0-9_.])eval\s*\(/, lang: "py", shellBased: true },
|
|
78
|
+
{ regex: /os\.popen\s*\(/, lang: "py", shellBased: true },
|
|
79
|
+
{ regex: /commands\.getoutput\s*\(/, lang: "py", shellBased: true },
|
|
80
|
+
// Go — exec.Command runs directly, NO shell involved
|
|
81
|
+
{ regex: /exec\.Command\s*\(/, lang: "go", shellBased: false },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// Heuristic: check if the first argument is a string literal (safer) or variable
|
|
85
|
+
const LITERAL_ARG = /\(\s*['"`][^'"`${}]*['"`]\s*[,)]/;
|
|
86
|
+
const TEMPLATE_LITERAL = /\(\s*`[^`]*\$\{/;
|
|
87
|
+
|
|
88
|
+
/** Import/require statements — NOT a finding, just a declaration */
|
|
89
|
+
const IMPORT_RE = /^\s*(?:import\s+|const\s+.*=\s*require\s*\(|from\s+['"]|require\s*\(\s*['"])/;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scan for locally-defined constants assigned string literals.
|
|
93
|
+
* If a variable is const X = 'literal' and then used in exec(X),
|
|
94
|
+
* that's effectively a literal — LOW not CRITICAL.
|
|
95
|
+
*/
|
|
96
|
+
function findLocalConstants(lines) {
|
|
97
|
+
const constants = new Set();
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const match = line.match(/(?:const|let|var)\s+(\w+)\s*=\s*['"`][^'"`${}]*['"`]/);
|
|
100
|
+
if (match) constants.add(match[1]);
|
|
101
|
+
}
|
|
102
|
+
return constants;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Check if the first arg passed to a function call is a known constant */
|
|
106
|
+
function isArgLocalConstant(line, constants) {
|
|
107
|
+
// Covers JS/TS, Python, and Go dangerous function names so that a call
|
|
108
|
+
// like os.system(CMD) where CMD is a const string is correctly rated low,
|
|
109
|
+
// not critical.
|
|
110
|
+
const match = line.match(
|
|
111
|
+
/(?:(?:child_process\.)?(?:exec(?:Sync|File|FileSync)?|spawn(?:Sync)?|eval)|os\.(?:system|popen)|commands\.getoutput|subprocess\.(?:run|Popen|call|check_output|check_call)|exec\.Command)\s*\(\s*(\w+)\s*[,)]/
|
|
112
|
+
);
|
|
113
|
+
if (match && constants.has(match[1])) return true;
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ════════════════════════════════════════════════════
|
|
118
|
+
Import-alias bypass detection
|
|
119
|
+
════════════════════════════════════════════════════ */
|
|
120
|
+
|
|
121
|
+
/** Escape a string for safe use inside a RegExp constructor. */
|
|
122
|
+
function escapeRegex(s) {
|
|
123
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Detect per-file import aliases that would bypass the static DANGEROUS_FUNCTIONS
|
|
128
|
+
* patterns, then return equivalent pattern objects in the same shape.
|
|
129
|
+
*
|
|
130
|
+
* Covers:
|
|
131
|
+
* Python: import subprocess as sp → sp.run/Popen/call/check_output/check_call
|
|
132
|
+
* Python: from subprocess import run/Popen/… → bare run/Popen/…
|
|
133
|
+
* Python: import os as o → o.system / o.popen
|
|
134
|
+
* Python: from os import system/popen → bare system/popen
|
|
135
|
+
* JS/TS: const cp = require('child_process') → cp.exec/execSync/spawn/…
|
|
136
|
+
* JS/TS: import * as cp from 'child_process' → cp.exec/execSync/spawn/…
|
|
137
|
+
*
|
|
138
|
+
* @param {string} content Full file text
|
|
139
|
+
* @param {string} filePath File path (used to determine language)
|
|
140
|
+
* @returns {Array} Extra pattern objects shaped like DANGEROUS_FUNCTIONS entries
|
|
141
|
+
*/
|
|
142
|
+
function detectImportAliases(content, filePath) {
|
|
143
|
+
const extra = [];
|
|
144
|
+
const isPy = /\.py$/.test(filePath);
|
|
145
|
+
const isJs = /\.[jt]sx?$/.test(filePath);
|
|
146
|
+
|
|
147
|
+
if (isPy) {
|
|
148
|
+
// import subprocess as <alias>
|
|
149
|
+
const spAlias = content.match(/\bimport\s+subprocess\s+as\s+(\w+)/);
|
|
150
|
+
if (spAlias) {
|
|
151
|
+
const a = escapeRegex(spAlias[1]);
|
|
152
|
+
extra.push({
|
|
153
|
+
regex: new RegExp(`(?:^|[^a-zA-Z0-9_.])${a}\\.(?:run|Popen|call|check_output|check_call)\\s*\\(`),
|
|
154
|
+
lang: 'py', shellBased: false,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// from subprocess import run, Popen, call, …
|
|
159
|
+
const spFrom = content.match(/\bfrom\s+subprocess\s+import\s+([\w,\s]+)/);
|
|
160
|
+
if (spFrom) {
|
|
161
|
+
for (const name of spFrom[1].split(',').map(s => s.trim())) {
|
|
162
|
+
if (/^(?:run|Popen|call|check_output|check_call)$/.test(name)) {
|
|
163
|
+
const n = escapeRegex(name);
|
|
164
|
+
extra.push({
|
|
165
|
+
regex: new RegExp(`(?:^|[^a-zA-Z0-9_.])${n}\\s*\\(`),
|
|
166
|
+
lang: 'py', shellBased: false,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// import os as <alias>
|
|
173
|
+
const osAlias = content.match(/\bimport\s+os\s+as\s+(\w+)/);
|
|
174
|
+
if (osAlias) {
|
|
175
|
+
const a = escapeRegex(osAlias[1]);
|
|
176
|
+
extra.push({
|
|
177
|
+
regex: new RegExp(`(?:^|[^a-zA-Z0-9_.])${a}\\.(?:system|popen)\\s*\\(`),
|
|
178
|
+
lang: 'py', shellBased: true,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// from os import system / popen
|
|
183
|
+
const osFrom = content.match(/\bfrom\s+os\s+import\s+([\w,\s]+)/);
|
|
184
|
+
if (osFrom) {
|
|
185
|
+
for (const name of osFrom[1].split(',').map(s => s.trim())) {
|
|
186
|
+
if (/^(?:system|popen)$/.test(name)) {
|
|
187
|
+
const n = escapeRegex(name);
|
|
188
|
+
extra.push({
|
|
189
|
+
regex: new RegExp(`(?:^|[^a-zA-Z0-9_.])${n}\\s*\\(`),
|
|
190
|
+
lang: 'py', shellBased: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (isJs) {
|
|
198
|
+
const CP_METHODS = '(?:exec|execSync|execFile|execFileSync|spawn|spawnSync)';
|
|
199
|
+
|
|
200
|
+
// const cp = require('child_process')
|
|
201
|
+
const reqAlias = content.match(/(?:const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*['"]child_process['"]\s*\)/);
|
|
202
|
+
// import * as cp from 'child_process'
|
|
203
|
+
const importAlias = content.match(/import\s+\*\s+as\s+(\w+)\s+from\s+['"]child_process['"]/);
|
|
204
|
+
|
|
205
|
+
const alias = reqAlias?.[1] ?? importAlias?.[1];
|
|
206
|
+
if (alias && alias !== 'child_process') {
|
|
207
|
+
const a = escapeRegex(alias);
|
|
208
|
+
extra.push({
|
|
209
|
+
regex: new RegExp(`(?:^|[^a-zA-Z0-9_.])${a}\\.${CP_METHODS}\\s*\\(`),
|
|
210
|
+
lang: 'js', shellBased: true,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return extra;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ════════════════════════════════════════════════════
|
|
219
|
+
Go-specific helpers
|
|
220
|
+
════════════════════════════════════════════════════ */
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Go's exec.Command(name, args...) executes directly without a shell.
|
|
224
|
+
* Only the first argument (the binary) being dynamic is a real concern.
|
|
225
|
+
* If the first arg is a literal string, the command itself is fixed.
|
|
226
|
+
*/
|
|
227
|
+
const GO_EXEC_LITERAL_BINARY = /exec\.Command\s*\(\s*"[^"]+"/;
|
|
228
|
+
const GO_EXEC_ARGS_SPREAD = /exec\.Command\s*\(\s*\w+\s*,\s*\w+\s*\.\.\.\s*\)/;
|
|
229
|
+
|
|
230
|
+
function isGoExecSafe(line) {
|
|
231
|
+
// First arg is a literal string → binary is fixed, much safer
|
|
232
|
+
if (GO_EXEC_LITERAL_BINARY.test(line)) return true;
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isGoExecFirstArgDynamic(line) {
|
|
237
|
+
// exec.Command(someVar, ...) or exec.Command(parts[0], parts[1:]...)
|
|
238
|
+
if (/exec\.Command\s*\(\s*[a-zA-Z_]\w*(?:\s*[,)])|\[\d+\]/.test(line) && !GO_EXEC_LITERAL_BINARY.test(line)) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* ════════════════════════════════════════════════════
|
|
245
|
+
Main Analyzer
|
|
246
|
+
════════════════════════════════════════════════════ */
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {Array<{path: string, content: string}>} files
|
|
250
|
+
* @returns {{ score: number, status: string, findings: Array }}
|
|
251
|
+
*/
|
|
252
|
+
function analyzeCommandInjection(files) {
|
|
253
|
+
const findings = [];
|
|
254
|
+
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
const lines = file.content.split("\n");
|
|
257
|
+
const isTestFile = TEST_FILE_RE.test(file.path);
|
|
258
|
+
const isCliTool = CLI_TOOL_RE.test(file.path);
|
|
259
|
+
const isGoFile = /\.go$/.test(file.path);
|
|
260
|
+
const isInstallTime = isInstallTimeFile(file.path, files);
|
|
261
|
+
const localConstants = findLocalConstants(lines);
|
|
262
|
+
|
|
263
|
+
// Merge static patterns with any alias-specific patterns found in this file
|
|
264
|
+
const aliasPatterns = detectImportAliases(file.content, file.path);
|
|
265
|
+
const effectivePatterns = aliasPatterns.length > 0
|
|
266
|
+
? [...DANGEROUS_FUNCTIONS, ...aliasPatterns]
|
|
267
|
+
: DANGEROUS_FUNCTIONS;
|
|
268
|
+
|
|
269
|
+
for (let i = 0; i < lines.length; i++) {
|
|
270
|
+
const line = lines[i];
|
|
271
|
+
|
|
272
|
+
// Skip comments
|
|
273
|
+
const trimmed = line.trim();
|
|
274
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*")) continue;
|
|
275
|
+
|
|
276
|
+
// ── SKIP: Import/require statements are NOT findings ──
|
|
277
|
+
if (IMPORT_RE.test(trimmed)) continue;
|
|
278
|
+
|
|
279
|
+
for (const pattern of effectivePatterns) {
|
|
280
|
+
if (!pattern.regex.test(line)) continue;
|
|
281
|
+
|
|
282
|
+
/* ── Context 1: Test files → skip entirely ── */
|
|
283
|
+
if (isTestFile) {
|
|
284
|
+
// Don't even report test-file findings — they are not prod code
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ── Context 2: Go exec.Command — NOT shell execution ── */
|
|
289
|
+
if (isGoFile && pattern.lang === "go") {
|
|
290
|
+
if (isGoExecSafe(line)) {
|
|
291
|
+
// Literal binary name + explicit args → benign
|
|
292
|
+
// Don't flag at all — Go's exec.Command with literal is safe
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (isGoExecFirstArgDynamic(line)) {
|
|
297
|
+
// Dynamic binary name — the real risk in Go
|
|
298
|
+
const severity = isCliTool ? "medium" : "high";
|
|
299
|
+
findings.push({
|
|
300
|
+
severity,
|
|
301
|
+
file: file.path,
|
|
302
|
+
line: i + 1,
|
|
303
|
+
snippet: trimmed,
|
|
304
|
+
message: isCliTool
|
|
305
|
+
? "CLI tool uses dynamic command name in exec.Command. Verify user input is validated."
|
|
306
|
+
: "Dynamic command name in exec.Command — the executed binary is determined at runtime.",
|
|
307
|
+
remediation:
|
|
308
|
+
"Use an allowlist of permitted binaries. Validate the command name before passing to exec.Command().",
|
|
309
|
+
});
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Go exec.Command with args spread (e.g., exec.Command("docker", args...))
|
|
314
|
+
// The binary is literal but args are dynamic — lower risk than shell
|
|
315
|
+
if (GO_EXEC_ARGS_SPREAD.test(line)) {
|
|
316
|
+
findings.push({
|
|
317
|
+
severity: isCliTool ? "low" : "medium",
|
|
318
|
+
file: file.path,
|
|
319
|
+
line: i + 1,
|
|
320
|
+
snippet: trimmed,
|
|
321
|
+
message: "exec.Command with spread args — binary is fixed but arguments come from a variable.",
|
|
322
|
+
remediation:
|
|
323
|
+
"Validate argument values before passing to exec.Command. Go's exec.Command does NOT use a shell, so injection risk is limited to argument manipulation.",
|
|
324
|
+
});
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* ── Context 3: Install-time scripts → reduced severity ── */
|
|
330
|
+
if (isInstallTime) {
|
|
331
|
+
const isLiteral = LITERAL_ARG.test(line) && !TEMPLATE_LITERAL.test(line);
|
|
332
|
+
const isConst = isArgLocalConstant(line, localConstants);
|
|
333
|
+
if (isLiteral || isConst) {
|
|
334
|
+
findings.push({
|
|
335
|
+
severity: "low",
|
|
336
|
+
file: file.path,
|
|
337
|
+
line: i + 1,
|
|
338
|
+
snippet: trimmed,
|
|
339
|
+
message: "Install-time script — runs at setup only, not at runtime. Verify this never receives dynamic input.",
|
|
340
|
+
remediation:
|
|
341
|
+
"If this command is only for project setup, this is acceptable. Ensure it never receives user or LLM input.",
|
|
342
|
+
});
|
|
343
|
+
} else {
|
|
344
|
+
findings.push({
|
|
345
|
+
severity: "medium",
|
|
346
|
+
file: file.path,
|
|
347
|
+
line: i + 1,
|
|
348
|
+
snippet: trimmed,
|
|
349
|
+
message: "Install-time script uses dynamic argument — verify input source.",
|
|
350
|
+
remediation:
|
|
351
|
+
"Install scripts should only use hardcoded commands. Dynamic arguments may indicate supply-chain risk.",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ── Context 4: CLI tools → downgrade severity ── */
|
|
358
|
+
if (isCliTool) {
|
|
359
|
+
const isLiteral = LITERAL_ARG.test(line) && !TEMPLATE_LITERAL.test(line);
|
|
360
|
+
findings.push({
|
|
361
|
+
severity: isLiteral ? "low" : "medium",
|
|
362
|
+
file: file.path,
|
|
363
|
+
line: i + 1,
|
|
364
|
+
snippet: trimmed,
|
|
365
|
+
message: isLiteral
|
|
366
|
+
? "CLI utility uses shell command with literal argument."
|
|
367
|
+
: "CLI utility uses shell command with dynamic argument — verify input is validated.",
|
|
368
|
+
remediation:
|
|
369
|
+
"CLI tools should validate and sanitize all user input before passing to shell commands.",
|
|
370
|
+
});
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* ── Context 5: Regular production code ── */
|
|
375
|
+
const isLiteral = LITERAL_ARG.test(line) && !TEMPLATE_LITERAL.test(line);
|
|
376
|
+
const isConst = isArgLocalConstant(line, localConstants);
|
|
377
|
+
|
|
378
|
+
// Check for shell metacharacters inside literal strings (e.g. exec("rm -rf / && something"))
|
|
379
|
+
let hasDangerousMetachars = false;
|
|
380
|
+
if (isLiteral) {
|
|
381
|
+
const argMatch = line.match(/\(\s*['"`]([^'"`${}]*)['"`]/);
|
|
382
|
+
if (argMatch && /[&|;<>$`\n]|>\s*&/.test(argMatch[1])) {
|
|
383
|
+
hasDangerousMetachars = true;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Check for Python shell=True
|
|
388
|
+
const isPythonShellTrue = pattern.lang === "py" && /shell\s*=\s*True/i.test(line);
|
|
389
|
+
const isShellBased = pattern.shellBased || isPythonShellTrue || hasDangerousMetachars;
|
|
390
|
+
|
|
391
|
+
if ((isLiteral || isConst) && !hasDangerousMetachars) {
|
|
392
|
+
findings.push({
|
|
393
|
+
severity: "low",
|
|
394
|
+
file: file.path,
|
|
395
|
+
line: i + 1,
|
|
396
|
+
snippet: trimmed,
|
|
397
|
+
message: isConst
|
|
398
|
+
? "Shell command uses a locally-defined constant. Verify it is never reassigned from external input."
|
|
399
|
+
: "Shell command with literal argument. Verify this is intentional.",
|
|
400
|
+
remediation:
|
|
401
|
+
"If this command is intentional and never receives LLM input, consider documenting why it's needed.",
|
|
402
|
+
});
|
|
403
|
+
} else {
|
|
404
|
+
findings.push({
|
|
405
|
+
severity: isShellBased ? "critical" : "high",
|
|
406
|
+
file: file.path,
|
|
407
|
+
line: i + 1,
|
|
408
|
+
snippet: trimmed,
|
|
409
|
+
message: hasDangerousMetachars
|
|
410
|
+
? "Shell metacharacters detected in literal command string — explicit command injection or risky behavior."
|
|
411
|
+
: isShellBased
|
|
412
|
+
? "Potential command injection — shell command with variable/dynamic argument."
|
|
413
|
+
: "Command execution with dynamic argument — verify input is not from LLM.",
|
|
414
|
+
remediation:
|
|
415
|
+
"Avoid passing LLM-provided input directly to shell commands. Use allowlists for permitted commands and validate/sanitize all input parameters.",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
break; // Only flag once per line
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return findings.map(f => ({ ...f, category: 'CI' }));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export { analyzeCommandInjection };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential Leaks Analyzer
|
|
3
|
+
* Scans for hardcoded secrets, API keys, tokens, and credential patterns.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SECRET_PATTERNS = [
|
|
7
|
+
{
|
|
8
|
+
name: "Generic API Key",
|
|
9
|
+
regex: /(?:api[_-]?key|token|secret|password|passwd|pwd)\s*[:=]\s*['"][A-Za-z0-9_\-/+]{20,}['"]/i,
|
|
10
|
+
severity: "high",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: "AWS Access Key",
|
|
14
|
+
regex: /AKIA[0-9A-Z]{16}/,
|
|
15
|
+
severity: "critical",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "GitHub Token",
|
|
19
|
+
regex: /ghp_[A-Za-z0-9]{36}/,
|
|
20
|
+
severity: "critical",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "GitHub OAuth",
|
|
24
|
+
regex: /gho_[A-Za-z0-9]{36}/,
|
|
25
|
+
severity: "critical",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "JWT String",
|
|
29
|
+
regex: /eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+/,
|
|
30
|
+
severity: "critical",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "JWT / Bearer Token Assignment",
|
|
34
|
+
regex: /(?:jwt|bearer)\s*[:=]\s*['"][^'"]{10,}['"]/i,
|
|
35
|
+
severity: "high",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "Database URL with Password",
|
|
39
|
+
regex: /(?:mongodb|postgres|postgresql|mysql|redis):\/\/[^:]+:[^@]+@/,
|
|
40
|
+
severity: "critical",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "Private Key",
|
|
44
|
+
regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
|
|
45
|
+
severity: "critical",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "Slack Token",
|
|
49
|
+
regex: /xox[bpors]-[A-Za-z0-9-]+/,
|
|
50
|
+
severity: "high",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "Stripe Key",
|
|
54
|
+
regex: /sk_(?:live|test)_[A-Za-z0-9]{24,}/,
|
|
55
|
+
severity: "critical",
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const PLACEHOLDER_VALUES = [
|
|
60
|
+
"YOUR_API_KEY", "your_api_key", "xxx", "changeme", "CHANGEME",
|
|
61
|
+
"<token>", "<api_key>", "TODO", "FIXME", "replace_me", "placeholder",
|
|
62
|
+
"example", "test_key", "dummy", "sample",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const TEST_FILE_PATTERNS = /(?:test|spec|mock|example|sample|template|fixture)/i;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {Array<{path: string, content: string}>} files
|
|
69
|
+
* @returns {{ score: number, status: string, findings: Array }}
|
|
70
|
+
*/
|
|
71
|
+
function analyzeCredentialLeaks(files) {
|
|
72
|
+
const findings = [];
|
|
73
|
+
let hasGitignore = false;
|
|
74
|
+
let gitignoreCoversSecrets = false;
|
|
75
|
+
let hasEnvFile = false;
|
|
76
|
+
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
// Check for .env files in the repo (they shouldn't be committed)
|
|
79
|
+
if (/^\.env(\.local|\.production|\.development)?$/.test(file.path.split("/").pop())) {
|
|
80
|
+
hasEnvFile = true;
|
|
81
|
+
findings.push({
|
|
82
|
+
severity: "high",
|
|
83
|
+
file: file.path,
|
|
84
|
+
line: null,
|
|
85
|
+
snippet: "",
|
|
86
|
+
message: "Environment file committed to repository. This may contain secrets.",
|
|
87
|
+
remediation:
|
|
88
|
+
"Remove .env files from version control and add them to .gitignore. Use environment variables in your deployment platform.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check .gitignore
|
|
93
|
+
if (file.path === ".gitignore" || file.path.endsWith("/.gitignore")) {
|
|
94
|
+
hasGitignore = true;
|
|
95
|
+
const content = file.content.toLowerCase();
|
|
96
|
+
if (content.includes(".env") || content.includes("*.key") || content.includes("*.pem")) {
|
|
97
|
+
gitignoreCoversSecrets = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Scan for secrets
|
|
102
|
+
const lines = file.content.split("\n");
|
|
103
|
+
const isTestFile = TEST_FILE_PATTERNS.test(file.path);
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < lines.length; i++) {
|
|
106
|
+
const line = lines[i];
|
|
107
|
+
|
|
108
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
109
|
+
const match = line.match(pattern.regex);
|
|
110
|
+
if (!match) continue;
|
|
111
|
+
|
|
112
|
+
// Avoid false positives on environment variable key lookups
|
|
113
|
+
// e.g., process.env['VERY_LONG_ENVIRONMENT_VARIABLE_NAME']
|
|
114
|
+
if (/(?:process\.env|os\.environ)\[\s*['"][^'"]+['"]\s*\]/.test(line)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check for Stripe test keys
|
|
119
|
+
if (pattern.name === "Stripe Key" && match[0].includes("_test_")) {
|
|
120
|
+
findings.push({
|
|
121
|
+
severity: "info",
|
|
122
|
+
file: file.path,
|
|
123
|
+
line: i + 1,
|
|
124
|
+
snippet: line.trim().slice(0, 200),
|
|
125
|
+
message: "Stripe test key detected. Test keys do not grant access to live payment data.",
|
|
126
|
+
remediation: "Stripe test keys are safe for version control if intended for sandbox environments.",
|
|
127
|
+
});
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check for placeholders
|
|
132
|
+
const isPlaceholder = PLACEHOLDER_VALUES.some((p) =>
|
|
133
|
+
match[0].toLowerCase().includes(p.toLowerCase())
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
let severity = pattern.severity;
|
|
137
|
+
if (isPlaceholder) {
|
|
138
|
+
severity = "info";
|
|
139
|
+
} else if (isTestFile) {
|
|
140
|
+
// Reduce severity by one level for test files
|
|
141
|
+
if (severity === "critical") severity = "high";
|
|
142
|
+
else if (severity === "high") severity = "medium";
|
|
143
|
+
else severity = "low";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
findings.push({
|
|
147
|
+
severity,
|
|
148
|
+
file: file.path,
|
|
149
|
+
line: i + 1,
|
|
150
|
+
snippet: line.trim().slice(0, 200),
|
|
151
|
+
message: `${pattern.name} detected${isPlaceholder ? " (placeholder value)" : isTestFile ? " in test fixture" : ""}.`,
|
|
152
|
+
remediation:
|
|
153
|
+
"Remove hardcoded credentials and use environment variables instead. Add .env to your .gitignore and use a secrets manager for production deployments.",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
break; // One finding per line
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Add .gitignore warning if missing or incomplete
|
|
162
|
+
if (!hasGitignore) {
|
|
163
|
+
findings.push({
|
|
164
|
+
severity: "low",
|
|
165
|
+
file: ".gitignore",
|
|
166
|
+
line: null,
|
|
167
|
+
snippet: "",
|
|
168
|
+
message: "No .gitignore file found. Secrets may be accidentally committed.",
|
|
169
|
+
remediation:
|
|
170
|
+
"Create a .gitignore file that includes: .env, .env.local, *.key, *.pem, *.secret",
|
|
171
|
+
});
|
|
172
|
+
} else if (!gitignoreCoversSecrets) {
|
|
173
|
+
findings.push({
|
|
174
|
+
severity: "low",
|
|
175
|
+
file: ".gitignore",
|
|
176
|
+
line: null,
|
|
177
|
+
snippet: "",
|
|
178
|
+
message: ".gitignore exists but doesn't cover common secret file patterns.",
|
|
179
|
+
remediation:
|
|
180
|
+
"Add .env, *.key, *.pem, and *.secret to your .gitignore file.",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return findings.map(f => ({ ...f, category: 'CL' }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export { analyzeCredentialLeaks };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { analyzeNetworkExposure } from "./networkExposure.js";
|
|
2
|
+
import { analyzeCommandInjection } from "./commandInjection.js";
|
|
3
|
+
import { analyzeCredentialLeaks } from "./credentialLeaks.js";
|
|
4
|
+
import { analyzeToolPoisoning } from "./toolPoisoning.js";
|
|
5
|
+
import { analyzeSpecCompliance } from "./specCompliance.js";
|
|
6
|
+
import { analyzeInputValidation } from "./inputValidation.js";
|
|
7
|
+
|
|
8
|
+
// Matches any of the supported ignore-comment forms:
|
|
9
|
+
// // vantyr-ignore
|
|
10
|
+
// # vantyr-ignore
|
|
11
|
+
const IGNORE_RE = /vantyr-ignore/i;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Filter out findings whose source line (or the line immediately above it)
|
|
15
|
+
* carries a vantyr-ignore comment. Project-level findings (no line number)
|
|
16
|
+
* are never suppressed this way.
|
|
17
|
+
*
|
|
18
|
+
* Supported comment styles:
|
|
19
|
+
* // vantyr-ignore (JS / TS / Go)
|
|
20
|
+
* # vantyr-ignore (Python / YAML / shell)
|
|
21
|
+
*
|
|
22
|
+
* The comment can appear on the flagged line itself or on the line above —
|
|
23
|
+
* either placement is conventional and both are honoured.
|
|
24
|
+
*/
|
|
25
|
+
function suppressIgnoredFindings(findings, files) {
|
|
26
|
+
const fileLines = new Map();
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
fileLines.set(file.path, file.content.split('\n'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return findings.filter(finding => {
|
|
32
|
+
if (!finding.file || finding.line == null) return true;
|
|
33
|
+
|
|
34
|
+
const lines = fileLines.get(finding.file);
|
|
35
|
+
if (!lines) return true;
|
|
36
|
+
|
|
37
|
+
const idx = finding.line - 1; // findings use 1-based line numbers
|
|
38
|
+
if (lines[idx] && IGNORE_RE.test(lines[idx])) return false;
|
|
39
|
+
if (idx > 0 && lines[idx - 1] && IGNORE_RE.test(lines[idx - 1])) return false;
|
|
40
|
+
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Runs all 6 security checks against a list of files and returns a flat
|
|
47
|
+
* findings array with any vantyr-ignore suppressions applied.
|
|
48
|
+
* @param {Array<{path: string, content: string}>} files
|
|
49
|
+
* @returns {Array}
|
|
50
|
+
*/
|
|
51
|
+
export function runAllChecks(files) {
|
|
52
|
+
const allFindings = [
|
|
53
|
+
...analyzeNetworkExposure(files),
|
|
54
|
+
...analyzeCommandInjection(files),
|
|
55
|
+
...analyzeCredentialLeaks(files),
|
|
56
|
+
...analyzeToolPoisoning(files),
|
|
57
|
+
...analyzeSpecCompliance(files),
|
|
58
|
+
...analyzeInputValidation(files),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
return suppressIgnoredFindings(allFindings, files);
|
|
62
|
+
}
|