guardvibe 2.5.0 → 2.7.4
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/CHANGELOG.md +35 -0
- package/README.md +43 -14
- package/build/cli/args.d.ts +15 -0
- package/build/cli/args.js +78 -0
- package/build/cli/ci.d.ts +5 -0
- package/build/cli/ci.js +67 -0
- package/build/cli/doctor.d.ts +5 -0
- package/build/cli/doctor.js +35 -0
- package/build/cli/hook.d.ts +5 -0
- package/build/cli/hook.js +90 -0
- package/build/cli/init.d.ts +5 -0
- package/build/cli/init.js +214 -0
- package/build/cli/remediation.d.ts +14 -0
- package/build/cli/remediation.js +91 -0
- package/build/cli/scan.d.ts +11 -0
- package/build/cli/scan.js +227 -0
- package/build/cli.js +33 -639
- package/build/data/rules/ai-host-security.d.ts +2 -0
- package/build/data/rules/ai-host-security.js +128 -0
- package/build/data/rules/ai-tool-runtime.d.ts +2 -0
- package/build/data/rules/ai-tool-runtime.js +54 -0
- package/build/data/rules/index.js +4 -0
- package/build/index.js +36 -0
- package/build/server/register.d.ts +7 -0
- package/build/server/register.js +7 -0
- package/build/server/types.d.ts +40 -0
- package/build/server/types.js +138 -0
- package/build/tools/audit-mcp-config.d.ts +10 -0
- package/build/tools/audit-mcp-config.js +296 -0
- package/build/tools/check-code.js +3 -0
- package/build/tools/doctor.d.ts +14 -0
- package/build/tools/doctor.js +123 -0
- package/build/tools/scan-directory.js +2 -0
- package/build/tools/scan-host-config.d.ts +10 -0
- package/build/tools/scan-host-config.js +181 -0
- package/build/tools/scan-staged.js +2 -0
- package/build/utils/banner.d.ts +36 -0
- package/build/utils/banner.js +66 -0
- package/package.json +2 -3
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
// Known suspicious patterns in hook commands
|
|
4
|
+
const NETWORK_CMDS = /\b(?:curl|wget|nc|ncat|netcat|fetch|http|telnet)\b/i;
|
|
5
|
+
const SHELL_CHAIN = /(?:\||\$\(|`[^`]+`|;\s*\w|&&\s*\w|>\s*\/)/;
|
|
6
|
+
const FILE_MODIFY = /\b(?:cp|mv|rm|chmod|chown|sed|tee|dd)\b/;
|
|
7
|
+
const EVAL_PIPE = /\|\s*(?:bash|sh|zsh|python|node|eval|exec)\b/;
|
|
8
|
+
const DANGEROUS_PATTERNS = /\b(?:eval|base64|decode|exec\s*\(|os\.system|subprocess)\b/i;
|
|
9
|
+
/**
|
|
10
|
+
* Scan MCP configuration files for security issues.
|
|
11
|
+
* Standalone tool + called by doctor.
|
|
12
|
+
*/
|
|
13
|
+
export function auditMcpConfig(projectPath, doctorConfig) {
|
|
14
|
+
const findings = [];
|
|
15
|
+
const scannedFiles = [];
|
|
16
|
+
const skippedFiles = [];
|
|
17
|
+
const root = resolve(projectPath);
|
|
18
|
+
const configFiles = [
|
|
19
|
+
{ path: join(root, ".claude.json"), host: "Claude" },
|
|
20
|
+
{ path: join(root, ".claude", "settings.json"), host: "Claude" },
|
|
21
|
+
{ path: join(root, ".cursor", "mcp.json"), host: "Cursor" },
|
|
22
|
+
{ path: join(root, ".vscode", "mcp.json"), host: "VS Code" },
|
|
23
|
+
{ path: join(root, ".vscode", "settings.json"), host: "VS Code" },
|
|
24
|
+
];
|
|
25
|
+
const trustedServers = new Set(doctorConfig?.trustedServers ?? []);
|
|
26
|
+
const ignorePaths = new Set(doctorConfig?.ignorePaths ?? []);
|
|
27
|
+
for (const { path: configPath, host } of configFiles) {
|
|
28
|
+
if (ignorePaths.has(configPath) || ignorePaths.has(configPath.replace(root + "/", ""))) {
|
|
29
|
+
skippedFiles.push(configPath);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!existsSync(configPath)) {
|
|
33
|
+
skippedFiles.push(configPath);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
scannedFiles.push(configPath);
|
|
37
|
+
let content;
|
|
38
|
+
try {
|
|
39
|
+
content = readFileSync(configPath, "utf-8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
skippedFiles.push(configPath);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(content);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
findings.push({
|
|
51
|
+
ruleId: "VG-HOST-001",
|
|
52
|
+
severity: "info",
|
|
53
|
+
trustState: "unknown",
|
|
54
|
+
verdict: "observed",
|
|
55
|
+
confidence: "high",
|
|
56
|
+
source: "core",
|
|
57
|
+
file: configPath,
|
|
58
|
+
description: `${host} config file contains invalid JSON`,
|
|
59
|
+
remediation: "Fix JSON syntax errors in the configuration file.",
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Scan hooks (Claude settings.json)
|
|
64
|
+
if (parsed.hooks && typeof parsed.hooks === "object") {
|
|
65
|
+
scanHooks(parsed, configPath, findings);
|
|
66
|
+
}
|
|
67
|
+
// Scan allowedTools
|
|
68
|
+
if (Array.isArray(parsed.allowedTools)) {
|
|
69
|
+
scanAllowedTools(parsed.allowedTools, configPath, findings);
|
|
70
|
+
}
|
|
71
|
+
// Scan MCP server entries
|
|
72
|
+
const servers = parsed.mcpServers ?? parsed.servers;
|
|
73
|
+
if (servers && typeof servers === "object") {
|
|
74
|
+
scanMcpServers(servers, configPath, host, trustedServers, findings);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { findings, scannedFiles, skippedFiles };
|
|
78
|
+
}
|
|
79
|
+
function scanHooks(settings, file, findings) {
|
|
80
|
+
if (!settings.hooks)
|
|
81
|
+
return;
|
|
82
|
+
for (const [hookName, hooks] of Object.entries(settings.hooks)) {
|
|
83
|
+
if (!Array.isArray(hooks))
|
|
84
|
+
continue;
|
|
85
|
+
for (const hook of hooks) {
|
|
86
|
+
const cmd = hook.command;
|
|
87
|
+
if (!cmd)
|
|
88
|
+
continue;
|
|
89
|
+
// VG884: Shell metacharacters
|
|
90
|
+
if (SHELL_CHAIN.test(cmd)) {
|
|
91
|
+
findings.push({
|
|
92
|
+
ruleId: "VG884",
|
|
93
|
+
severity: "critical",
|
|
94
|
+
trustState: "suspicious",
|
|
95
|
+
verdict: "exploitable",
|
|
96
|
+
confidence: "high",
|
|
97
|
+
source: "core",
|
|
98
|
+
file,
|
|
99
|
+
description: `${hookName} hook contains shell metacharacters: potential command injection (CVE-2025-59536)`,
|
|
100
|
+
remediation: "Remove shell metacharacters (|, ;, &&, $()) from hook commands. Use simple, direct commands.",
|
|
101
|
+
patchPreview: `"${hookName}": [{ "command": "<simple-command-without-pipes>" }]`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// VG890: Network requests
|
|
105
|
+
if (NETWORK_CMDS.test(cmd)) {
|
|
106
|
+
findings.push({
|
|
107
|
+
ruleId: "VG890",
|
|
108
|
+
severity: "critical",
|
|
109
|
+
trustState: "suspicious",
|
|
110
|
+
verdict: "exploitable",
|
|
111
|
+
confidence: "high",
|
|
112
|
+
source: "core",
|
|
113
|
+
file,
|
|
114
|
+
description: `${hookName} hook executes network requests — potential data exfiltration`,
|
|
115
|
+
remediation: "Remove network request commands (curl, wget, nc) from hooks. Hooks should only perform local operations.",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// VG891: Pipe to interpreter
|
|
119
|
+
if (EVAL_PIPE.test(cmd)) {
|
|
120
|
+
findings.push({
|
|
121
|
+
ruleId: "VG891",
|
|
122
|
+
severity: "high",
|
|
123
|
+
trustState: "suspicious",
|
|
124
|
+
verdict: "risky",
|
|
125
|
+
confidence: "high",
|
|
126
|
+
source: "core",
|
|
127
|
+
file,
|
|
128
|
+
description: `${hookName} hook pipes output to interpreter (bash/python/node) — code execution risk`,
|
|
129
|
+
remediation: "Remove pipe chains to interpreters. Process output in a dedicated script if needed.",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// VG895: PostToolUse file modifications
|
|
133
|
+
if (hookName === "PostToolUse" && FILE_MODIFY.test(cmd)) {
|
|
134
|
+
findings.push({
|
|
135
|
+
ruleId: "VG895",
|
|
136
|
+
severity: "high",
|
|
137
|
+
trustState: "suspicious",
|
|
138
|
+
verdict: "risky",
|
|
139
|
+
confidence: "high",
|
|
140
|
+
source: "core",
|
|
141
|
+
file,
|
|
142
|
+
description: `PostToolUse hook modifies files silently — potential backdoor or code tampering`,
|
|
143
|
+
remediation: "Remove file-modifying commands from PostToolUse hooks. Hooks should only observe and report.",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Generic dangerous patterns
|
|
147
|
+
if (DANGEROUS_PATTERNS.test(cmd) && !NETWORK_CMDS.test(cmd)) {
|
|
148
|
+
findings.push({
|
|
149
|
+
ruleId: "VG884",
|
|
150
|
+
severity: "high",
|
|
151
|
+
trustState: "suspicious",
|
|
152
|
+
verdict: "risky",
|
|
153
|
+
confidence: "medium",
|
|
154
|
+
source: "core",
|
|
155
|
+
file,
|
|
156
|
+
description: `${hookName} hook contains dangerous pattern (eval/base64/exec) — potential code execution`,
|
|
157
|
+
remediation: "Remove eval, base64 decode, and exec patterns from hook commands.",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function scanAllowedTools(tools, file, findings) {
|
|
164
|
+
for (const tool of tools) {
|
|
165
|
+
if (tool === "*") {
|
|
166
|
+
findings.push({
|
|
167
|
+
ruleId: "VG885",
|
|
168
|
+
severity: "medium",
|
|
169
|
+
trustState: "unknown",
|
|
170
|
+
verdict: "risky",
|
|
171
|
+
confidence: "high",
|
|
172
|
+
source: "core",
|
|
173
|
+
file,
|
|
174
|
+
description: 'allowedTools contains wildcard "*" — all tools are accessible without restriction',
|
|
175
|
+
remediation: "Replace wildcard tool access with explicit tool names that the MCP server actually needs.",
|
|
176
|
+
patchPreview: '"allowedTools": ["read_file", "list_directory"]',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
else if (/\*/.test(tool) && tool !== "*") {
|
|
180
|
+
// Broad wildcards like mcp__*, edit*, etc.
|
|
181
|
+
const prefix = tool.replace(/\*/g, "");
|
|
182
|
+
if (prefix.length < 10) {
|
|
183
|
+
findings.push({
|
|
184
|
+
ruleId: "VG893",
|
|
185
|
+
severity: "medium",
|
|
186
|
+
trustState: "unknown",
|
|
187
|
+
verdict: "risky",
|
|
188
|
+
confidence: "medium",
|
|
189
|
+
source: "core",
|
|
190
|
+
file,
|
|
191
|
+
description: `allowedTools contains broad wildcard "${tool}" — grants more access than intended`,
|
|
192
|
+
remediation: "Replace broad wildcards with specific tool names. Use exact match patterns.",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function scanMcpServers(servers, file, host, trustedServers, findings) {
|
|
199
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
200
|
+
// Skip trusted servers
|
|
201
|
+
if (isTrusted(name, trustedServers))
|
|
202
|
+
continue;
|
|
203
|
+
// VG892: file:// references
|
|
204
|
+
const url = server.url ?? "";
|
|
205
|
+
if (/^file:\/\//i.test(url)) {
|
|
206
|
+
findings.push({
|
|
207
|
+
ruleId: "VG892",
|
|
208
|
+
severity: "high",
|
|
209
|
+
trustState: "suspicious",
|
|
210
|
+
verdict: "risky",
|
|
211
|
+
confidence: "high",
|
|
212
|
+
source: "core",
|
|
213
|
+
file,
|
|
214
|
+
description: `MCP server "${name}" uses file:// URL — potential local file access`,
|
|
215
|
+
remediation: "Use npm packages or HTTPS URLs for MCP servers. Avoid file:// references.",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// Non-HTTPS URL
|
|
219
|
+
if (url && /^http:\/\//i.test(url)) {
|
|
220
|
+
findings.push({
|
|
221
|
+
ruleId: "VG892",
|
|
222
|
+
severity: "medium",
|
|
223
|
+
trustState: "unknown",
|
|
224
|
+
verdict: "risky",
|
|
225
|
+
confidence: "medium",
|
|
226
|
+
source: "core",
|
|
227
|
+
file,
|
|
228
|
+
description: `MCP server "${name}" uses HTTP (not HTTPS) — traffic is unencrypted`,
|
|
229
|
+
remediation: "Use HTTPS for MCP server connections to prevent traffic interception.",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
// Check command for suspicious patterns
|
|
233
|
+
const cmd = server.command ?? "";
|
|
234
|
+
const argsStr = (server.args ?? []).join(" ");
|
|
235
|
+
const fullCmd = `${cmd} ${argsStr}`;
|
|
236
|
+
if (NETWORK_CMDS.test(fullCmd) && SHELL_CHAIN.test(fullCmd)) {
|
|
237
|
+
findings.push({
|
|
238
|
+
ruleId: "VG890",
|
|
239
|
+
severity: "high",
|
|
240
|
+
trustState: "suspicious",
|
|
241
|
+
verdict: "risky",
|
|
242
|
+
confidence: "medium",
|
|
243
|
+
source: "core",
|
|
244
|
+
file,
|
|
245
|
+
description: `MCP server "${name}" command contains network requests with shell chaining`,
|
|
246
|
+
remediation: "Review the MCP server command for suspicious network activity.",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
// Check for env overrides in server config
|
|
250
|
+
if (server.env) {
|
|
251
|
+
for (const [key, val] of Object.entries(server.env)) {
|
|
252
|
+
if (/ANTHROPIC_BASE_URL/i.test(key) && !/api\.anthropic\.com/.test(val)) {
|
|
253
|
+
findings.push({
|
|
254
|
+
ruleId: "VG882",
|
|
255
|
+
severity: "high",
|
|
256
|
+
trustState: "suspicious",
|
|
257
|
+
verdict: "risky",
|
|
258
|
+
confidence: "medium",
|
|
259
|
+
source: "core",
|
|
260
|
+
file,
|
|
261
|
+
description: `MCP server "${name}" overrides ANTHROPIC_BASE_URL — API traffic redirection`,
|
|
262
|
+
remediation: "Remove ANTHROPIC_BASE_URL override or add the URL to trustedBaseUrls in .guardviberc.",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// VG894: Sensitive path access
|
|
268
|
+
const sensitivePaths = /(?:\.ssh|\.gnupg|\.aws|\.kube|\/etc(?:\/|\b)|\.config\/gcloud)/i;
|
|
269
|
+
if (sensitivePaths.test(fullCmd) || sensitivePaths.test(JSON.stringify(server))) {
|
|
270
|
+
findings.push({
|
|
271
|
+
ruleId: "VG894",
|
|
272
|
+
severity: "high",
|
|
273
|
+
trustState: "suspicious",
|
|
274
|
+
verdict: "risky",
|
|
275
|
+
confidence: "high",
|
|
276
|
+
source: "core",
|
|
277
|
+
file,
|
|
278
|
+
description: `MCP server "${name}" references security-sensitive paths (.ssh, .aws, .gnupg, /etc)`,
|
|
279
|
+
remediation: "Remove security-sensitive paths from MCP server configuration. Limit access to project directories.",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function isTrusted(name, trustedServers) {
|
|
285
|
+
if (trustedServers.has(name))
|
|
286
|
+
return true;
|
|
287
|
+
// Check glob patterns like @anthropic/*
|
|
288
|
+
for (const pattern of trustedServers) {
|
|
289
|
+
if (pattern.endsWith("/*")) {
|
|
290
|
+
const prefix = pattern.slice(0, -2);
|
|
291
|
+
if (name.startsWith(prefix))
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
@@ -2,6 +2,7 @@ import { basename } from "path";
|
|
|
2
2
|
import { owaspRules } from "../data/rules/index.js";
|
|
3
3
|
import { loadConfig } from "../utils/config.js";
|
|
4
4
|
import { loadIgnoreFile, isIgnored } from "../utils/ignore.js";
|
|
5
|
+
import { securityBanner } from "../utils/banner.js";
|
|
5
6
|
function parseSuppressionsFromCode(lines) {
|
|
6
7
|
const suppressions = [];
|
|
7
8
|
const pattern = /(?:\/\/|#|<!--)\s*guardvibe-ignore(?:-next-line)?\s*(VG\d+)?\s*(?:-->)?/i;
|
|
@@ -441,6 +442,7 @@ function formatCleanReport(language, framework) {
|
|
|
441
442
|
``,
|
|
442
443
|
`Tips for ${language}${ctx}:`,
|
|
443
444
|
...tips.map(t => `- ${t}`),
|
|
445
|
+
securityBanner({ total: 0, critical: 0, high: 0, medium: 0 }),
|
|
444
446
|
].join("\n");
|
|
445
447
|
}
|
|
446
448
|
function getLanguageTips(language, framework) {
|
|
@@ -549,6 +551,7 @@ function formatReport(findings, language, framework) {
|
|
|
549
551
|
}
|
|
550
552
|
}
|
|
551
553
|
}
|
|
554
|
+
lines.push(securityBanner({ total: allFindings.length, critical: criticalCount, high: highCount, medium: mediumCount }));
|
|
552
555
|
return lines.join("\n");
|
|
553
556
|
}
|
|
554
557
|
// ─── Buddy Format ────────────────────────────────────────────────
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DoctorScope } from "../server/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* guardvibe_doctor — Unified host hardening scanner
|
|
4
|
+
*
|
|
5
|
+
* Orchestrates multiple analyzers to provide a comprehensive
|
|
6
|
+
* security assessment of AI coding host configuration.
|
|
7
|
+
*
|
|
8
|
+
* Analyzers:
|
|
9
|
+
* 1. MCP Config (audit_mcp_config) — hooks, servers, tool access
|
|
10
|
+
* 2. Host Environment (scan_host_config) — base URL hijack, env sniffing
|
|
11
|
+
* 3. Permissions (inline) — allowedTools wildcards, sensitive paths
|
|
12
|
+
* 4. File Transport (inline) — file:// references, path traversal
|
|
13
|
+
*/
|
|
14
|
+
export declare function doctor(projectPath: string, scope?: DoctorScope, format?: "markdown" | "json"): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { formatHostFindings, redactSecrets } from "../server/types.js";
|
|
4
|
+
import { auditMcpConfig } from "./audit-mcp-config.js";
|
|
5
|
+
import { scanHostConfig } from "./scan-host-config.js";
|
|
6
|
+
import { enrichAllRemediations } from "../cli/remediation.js";
|
|
7
|
+
/**
|
|
8
|
+
* guardvibe_doctor — Unified host hardening scanner
|
|
9
|
+
*
|
|
10
|
+
* Orchestrates multiple analyzers to provide a comprehensive
|
|
11
|
+
* security assessment of AI coding host configuration.
|
|
12
|
+
*
|
|
13
|
+
* Analyzers:
|
|
14
|
+
* 1. MCP Config (audit_mcp_config) — hooks, servers, tool access
|
|
15
|
+
* 2. Host Environment (scan_host_config) — base URL hijack, env sniffing
|
|
16
|
+
* 3. Permissions (inline) — allowedTools wildcards, sensitive paths
|
|
17
|
+
* 4. File Transport (inline) — file:// references, path traversal
|
|
18
|
+
*/
|
|
19
|
+
export function doctor(projectPath, scope = "project", format = "markdown") {
|
|
20
|
+
const root = resolve(projectPath);
|
|
21
|
+
const doctorConfig = loadDoctorConfig(root);
|
|
22
|
+
const allFindings = [];
|
|
23
|
+
const allScanned = [];
|
|
24
|
+
const allSkipped = [];
|
|
25
|
+
// ── Analyzer 1: MCP Config ──────────────────────────────────────
|
|
26
|
+
const mcpResult = auditMcpConfig(root, doctorConfig);
|
|
27
|
+
allFindings.push(...mcpResult.findings);
|
|
28
|
+
allScanned.push(...mcpResult.scannedFiles);
|
|
29
|
+
allSkipped.push(...mcpResult.skippedFiles);
|
|
30
|
+
// ── Analyzer 2: Host Environment ────────────────────────────────
|
|
31
|
+
const hostResult = scanHostConfig(root, scope, doctorConfig);
|
|
32
|
+
allFindings.push(...hostResult.findings);
|
|
33
|
+
allScanned.push(...hostResult.scannedFiles);
|
|
34
|
+
allSkipped.push(...hostResult.skippedFiles);
|
|
35
|
+
// ── Analyzer 3: Permissions (inline scan) ───────────────────────
|
|
36
|
+
scanPermissions(root, doctorConfig, allFindings, allScanned, allSkipped);
|
|
37
|
+
// ── Host-Specific Remediation Enrichment ─────────────────────────
|
|
38
|
+
enrichAllRemediations(allFindings);
|
|
39
|
+
// ── Output ──────────────────────────────────────────────────────
|
|
40
|
+
const title = `GuardVibe Doctor — Host Security Audit (scope: ${scope})`;
|
|
41
|
+
const output = formatHostFindings(allFindings, allScanned, allSkipped, format, title);
|
|
42
|
+
return redactSecrets(output);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Load doctor-specific config from .guardviberc
|
|
46
|
+
*/
|
|
47
|
+
function loadDoctorConfig(root) {
|
|
48
|
+
const configPath = findConfigFileUp(root, ".guardviberc");
|
|
49
|
+
if (!configPath)
|
|
50
|
+
return {};
|
|
51
|
+
try {
|
|
52
|
+
const content = readFileSync(configPath, "utf-8");
|
|
53
|
+
const parsed = JSON.parse(content);
|
|
54
|
+
return parsed.doctor ?? {};
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function findConfigFileUp(startDir, filename) {
|
|
61
|
+
let current = startDir;
|
|
62
|
+
const fsRoot = resolve("/");
|
|
63
|
+
while (true) {
|
|
64
|
+
const candidate = join(current, filename);
|
|
65
|
+
if (existsSync(candidate))
|
|
66
|
+
return candidate;
|
|
67
|
+
const parent = resolve(current, "..");
|
|
68
|
+
if (parent === current || current === fsRoot)
|
|
69
|
+
break;
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Inline permissions analyzer — checks for overly permissive
|
|
76
|
+
* configurations that don't fit in other analyzers.
|
|
77
|
+
*/
|
|
78
|
+
function scanPermissions(root, _doctorConfig, findings, scannedFiles, _skippedFiles) {
|
|
79
|
+
// Check .claude.json for permissive patterns
|
|
80
|
+
const claudeJson = join(root, ".claude.json");
|
|
81
|
+
if (existsSync(claudeJson)) {
|
|
82
|
+
if (!scannedFiles.includes(claudeJson))
|
|
83
|
+
scannedFiles.push(claudeJson);
|
|
84
|
+
try {
|
|
85
|
+
const content = readFileSync(claudeJson, "utf-8");
|
|
86
|
+
const parsed = JSON.parse(content);
|
|
87
|
+
// Check for allowedTools with dangerous patterns
|
|
88
|
+
if (Array.isArray(parsed.permissions?.allow)) {
|
|
89
|
+
for (const perm of parsed.permissions.allow) {
|
|
90
|
+
if (typeof perm === "string" && /^(?:Bash|Edit|Write)\(.*\*.*\)$/i.test(perm)) {
|
|
91
|
+
findings.push({
|
|
92
|
+
ruleId: "VG893",
|
|
93
|
+
severity: "medium",
|
|
94
|
+
trustState: "unknown",
|
|
95
|
+
verdict: "risky",
|
|
96
|
+
confidence: "medium",
|
|
97
|
+
source: "core",
|
|
98
|
+
file: claudeJson,
|
|
99
|
+
description: `Broad permission pattern "${perm}" — may grant unintended access`,
|
|
100
|
+
remediation: "Replace broad wildcard permissions with specific, scoped patterns.",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Check for deny list being empty when allow list is broad
|
|
106
|
+
if (Array.isArray(parsed.permissions?.allow) && parsed.permissions.allow.length > 10
|
|
107
|
+
&& (!parsed.permissions?.deny || parsed.permissions.deny.length === 0)) {
|
|
108
|
+
findings.push({
|
|
109
|
+
ruleId: "VG885",
|
|
110
|
+
severity: "low",
|
|
111
|
+
trustState: "unknown",
|
|
112
|
+
verdict: "observed",
|
|
113
|
+
confidence: "low",
|
|
114
|
+
source: "core",
|
|
115
|
+
file: claudeJson,
|
|
116
|
+
description: "Large allow list with no deny list — consider adding explicit denials for sensitive operations",
|
|
117
|
+
remediation: "Add a deny list to explicitly block dangerous operations (e.g., rm -rf, git push --force).",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch { /* invalid JSON already caught by MCP config scanner */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -6,6 +6,7 @@ import { analyzeCode } from "./check-code.js";
|
|
|
6
6
|
import { loadConfig } from "../utils/config.js";
|
|
7
7
|
import { DEFAULT_EXCLUDES, EXTENSION_MAP, CONFIG_FILE_MAP } from "../utils/constants.js";
|
|
8
8
|
import { walkDirectory } from "../utils/walk-directory.js";
|
|
9
|
+
import { securityBanner } from "../utils/banner.js";
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
const pkg = require("../../package.json");
|
|
11
12
|
// GuardVibe version — used in scan metadata
|
|
@@ -240,5 +241,6 @@ export function scanDirectory(path, recursive = true, exclude = [], format = "ma
|
|
|
240
241
|
for (const s of skippedFiles)
|
|
241
242
|
lines.push(`- ${s}`);
|
|
242
243
|
}
|
|
244
|
+
lines.push(securityBanner({ total: totalIssues, critical: totalCritical, high: totalHigh, medium: totalMedium, score, grade, filesScanned: metadata.filesScanned }));
|
|
243
245
|
return lines.join("\n");
|
|
244
246
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HostFinding, DoctorConfig, DoctorScope } from "../server/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Scan host environment configuration for security issues.
|
|
4
|
+
* Checks .env files, shell profiles, and environment variables.
|
|
5
|
+
*/
|
|
6
|
+
export declare function scanHostConfig(projectPath: string, scope?: DoctorScope, doctorConfig?: DoctorConfig): {
|
|
7
|
+
findings: HostFinding[];
|
|
8
|
+
scannedFiles: string[];
|
|
9
|
+
skippedFiles: string[];
|
|
10
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
// Known legitimate base URLs
|
|
5
|
+
const ANTHROPIC_HOSTS = ["api.anthropic.com"];
|
|
6
|
+
const OPENAI_HOSTS = ["api.openai.com"];
|
|
7
|
+
const BASE_URL_PATTERN = /(?:ANTHROPIC_BASE_URL|OPENAI_BASE_URL|ANTHROPIC_API_BASE|OPENAI_API_BASE)\s*=\s*['"]?(https?:\/\/[^\s'"#]+)/gi;
|
|
8
|
+
const API_KEY_EXPORT = /export\s+(?:ANTHROPIC_API_KEY|OPENAI_API_KEY|ANTHROPIC_KEY|OPENAI_KEY)\s*=\s*['"]?([^\s'"]+)/gi;
|
|
9
|
+
const ENV_SNIFF = /(?:echo|cat|printenv|env\s|set\s)[\s|]*(?:\$(?:ANTHROPIC|OPENAI|CLAUDE|API)_\w+|\$\{(?:ANTHROPIC|OPENAI|CLAUDE|API)_\w+\})/gi;
|
|
10
|
+
/**
|
|
11
|
+
* Scan host environment configuration for security issues.
|
|
12
|
+
* Checks .env files, shell profiles, and environment variables.
|
|
13
|
+
*/
|
|
14
|
+
export function scanHostConfig(projectPath, scope = "project", doctorConfig) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
const scannedFiles = [];
|
|
17
|
+
const skippedFiles = [];
|
|
18
|
+
const root = resolve(projectPath);
|
|
19
|
+
const home = homedir();
|
|
20
|
+
const trustedBaseUrls = new Set(doctorConfig?.trustedBaseUrls ?? []);
|
|
21
|
+
const ignorePaths = new Set(doctorConfig?.ignorePaths ?? []);
|
|
22
|
+
// Project-scope .env files
|
|
23
|
+
const envFiles = [
|
|
24
|
+
join(root, ".env"),
|
|
25
|
+
join(root, ".env.local"),
|
|
26
|
+
join(root, ".env.production"),
|
|
27
|
+
join(root, ".env.development"),
|
|
28
|
+
];
|
|
29
|
+
for (const envFile of envFiles) {
|
|
30
|
+
const relPath = envFile.replace(root + "/", "");
|
|
31
|
+
if (ignorePaths.has(relPath) || ignorePaths.has(envFile)) {
|
|
32
|
+
skippedFiles.push(envFile);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
scanEnvFile(envFile, findings, scannedFiles, skippedFiles, trustedBaseUrls);
|
|
36
|
+
}
|
|
37
|
+
// Host-scope: shell profiles
|
|
38
|
+
if (scope === "host" || scope === "full") {
|
|
39
|
+
const shellProfiles = [
|
|
40
|
+
join(home, ".bashrc"),
|
|
41
|
+
join(home, ".zshrc"),
|
|
42
|
+
join(home, ".profile"),
|
|
43
|
+
join(home, ".bash_profile"),
|
|
44
|
+
join(home, ".zprofile"),
|
|
45
|
+
];
|
|
46
|
+
for (const profile of shellProfiles) {
|
|
47
|
+
scanShellProfile(profile, findings, scannedFiles, skippedFiles, trustedBaseUrls);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Host-scope: global AI host configs
|
|
51
|
+
if (scope === "host" || scope === "full") {
|
|
52
|
+
const globalConfigs = [
|
|
53
|
+
{ path: join(home, ".gemini", "settings.json"), host: "Gemini" },
|
|
54
|
+
{ path: join(home, ".codeium", "windsurf", "mcp_config.json"), host: "Windsurf" },
|
|
55
|
+
];
|
|
56
|
+
for (const { path: configPath } of globalConfigs) {
|
|
57
|
+
if (!existsSync(configPath)) {
|
|
58
|
+
skippedFiles.push(configPath);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
scannedFiles.push(configPath);
|
|
62
|
+
try {
|
|
63
|
+
const content = readFileSync(configPath, "utf-8");
|
|
64
|
+
scanContentForBaseUrls(content, configPath, findings, trustedBaseUrls);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
skippedFiles.push(configPath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { findings, scannedFiles, skippedFiles };
|
|
72
|
+
}
|
|
73
|
+
function scanEnvFile(filePath, findings, scannedFiles, skippedFiles, trustedBaseUrls) {
|
|
74
|
+
if (!existsSync(filePath)) {
|
|
75
|
+
skippedFiles.push(filePath);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
scannedFiles.push(filePath);
|
|
79
|
+
let content;
|
|
80
|
+
try {
|
|
81
|
+
content = readFileSync(filePath, "utf-8");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
skippedFiles.push(filePath);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
scanContentForBaseUrls(content, filePath, findings, trustedBaseUrls);
|
|
88
|
+
}
|
|
89
|
+
function scanShellProfile(filePath, findings, scannedFiles, skippedFiles, trustedBaseUrls) {
|
|
90
|
+
if (!existsSync(filePath)) {
|
|
91
|
+
skippedFiles.push(filePath);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
scannedFiles.push(filePath);
|
|
95
|
+
let content;
|
|
96
|
+
try {
|
|
97
|
+
content = readFileSync(filePath, "utf-8");
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
skippedFiles.push(filePath);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Check for base URL overrides
|
|
104
|
+
scanContentForBaseUrls(content, filePath, findings, trustedBaseUrls);
|
|
105
|
+
// Check for env variable sniffing patterns
|
|
106
|
+
ENV_SNIFF.lastIndex = 0;
|
|
107
|
+
let match;
|
|
108
|
+
while ((match = ENV_SNIFF.exec(content)) !== null) {
|
|
109
|
+
const lineNum = content.slice(0, match.index).split("\n").length;
|
|
110
|
+
findings.push({
|
|
111
|
+
ruleId: "VG882",
|
|
112
|
+
severity: "medium",
|
|
113
|
+
trustState: "suspicious",
|
|
114
|
+
verdict: "risky",
|
|
115
|
+
confidence: "low",
|
|
116
|
+
source: "core",
|
|
117
|
+
file: filePath,
|
|
118
|
+
line: lineNum,
|
|
119
|
+
description: "Shell profile reads/outputs AI API environment variables — potential credential sniffing",
|
|
120
|
+
remediation: "Review why shell profile accesses AI API keys. Remove if not intentional.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// Check for API key exports (hardcoded in profile)
|
|
124
|
+
API_KEY_EXPORT.lastIndex = 0;
|
|
125
|
+
while ((match = API_KEY_EXPORT.exec(content)) !== null) {
|
|
126
|
+
const lineNum = content.slice(0, match.index).split("\n").length;
|
|
127
|
+
findings.push({
|
|
128
|
+
ruleId: "VG882",
|
|
129
|
+
severity: "high",
|
|
130
|
+
trustState: "suspicious",
|
|
131
|
+
verdict: "risky",
|
|
132
|
+
confidence: "high",
|
|
133
|
+
source: "core",
|
|
134
|
+
file: filePath,
|
|
135
|
+
line: lineNum,
|
|
136
|
+
description: "API key exported in shell profile — key is visible in process environment and shell history",
|
|
137
|
+
remediation: "Move API keys to a secure secrets manager or .env file (not tracked by git). Remove from shell profile.",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function scanContentForBaseUrls(content, file, findings, trustedBaseUrls) {
|
|
142
|
+
BASE_URL_PATTERN.lastIndex = 0;
|
|
143
|
+
let match;
|
|
144
|
+
while ((match = BASE_URL_PATTERN.exec(content)) !== null) {
|
|
145
|
+
const url = match[1];
|
|
146
|
+
const lineNum = content.slice(0, match.index).split("\n").length;
|
|
147
|
+
// Check if trusted
|
|
148
|
+
if (trustedBaseUrls.has(url)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Determine which provider
|
|
152
|
+
const envVar = match[0].split("=")[0].trim();
|
|
153
|
+
const isAnthropic = /ANTHROPIC/i.test(envVar);
|
|
154
|
+
const isOpenai = /OPENAI/i.test(envVar);
|
|
155
|
+
let hostname;
|
|
156
|
+
try {
|
|
157
|
+
hostname = new URL(url).hostname;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
hostname = url;
|
|
161
|
+
}
|
|
162
|
+
const legitimateHosts = isAnthropic ? ANTHROPIC_HOSTS : isOpenai ? OPENAI_HOSTS : [];
|
|
163
|
+
const isLegitimate = legitimateHosts.some(h => hostname === h || hostname.endsWith(`.${h}`));
|
|
164
|
+
if (isLegitimate)
|
|
165
|
+
continue;
|
|
166
|
+
const isLocalhost = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)/.test(hostname);
|
|
167
|
+
findings.push({
|
|
168
|
+
ruleId: isAnthropic ? "VG882" : "VG883",
|
|
169
|
+
severity: isLocalhost ? "medium" : "high",
|
|
170
|
+
trustState: isLocalhost ? "unknown" : "suspicious",
|
|
171
|
+
verdict: isLocalhost ? "observed" : "risky",
|
|
172
|
+
confidence: isLocalhost ? "medium" : "medium",
|
|
173
|
+
source: "core",
|
|
174
|
+
file,
|
|
175
|
+
line: lineNum,
|
|
176
|
+
description: `${envVar} set to non-official domain (${hostname}) — API traffic redirection${isAnthropic ? " (CVE-2026-21852)" : ""}`,
|
|
177
|
+
remediation: `Remove the ${envVar} override, or add "${url}" to trustedBaseUrls in .guardviberc if it's a legitimate corporate proxy.`,
|
|
178
|
+
patchPreview: `# .guardviberc\n{ "doctor": { "trustedBaseUrls": ["${url}"] } }`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|