guardvibe 1.9.1 → 1.9.3
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/build/cli.js +46 -0
- package/build/data/rules/database.js +12 -0
- package/build/data/rules/nextjs.js +27 -3
- package/build/tools/check-code.js +83 -2
- package/build/utils/config.js +27 -2
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -63,8 +63,54 @@ function setupPlatform(name) {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
console.log(` [OK] Added MCP server to ${platform.description}`);
|
|
66
|
+
// For Claude Code: also set up hooks and CLAUDE.md guidance
|
|
67
|
+
if (name === "claude") {
|
|
68
|
+
setupClaudeHooksAndGuide();
|
|
69
|
+
}
|
|
66
70
|
return true;
|
|
67
71
|
}
|
|
72
|
+
function setupClaudeHooksAndGuide() {
|
|
73
|
+
// Add hook to .claude/settings.json for auto-scan after file edits
|
|
74
|
+
const claudeSettingsDir = join(process.cwd(), ".claude");
|
|
75
|
+
if (!existsSync(claudeSettingsDir)) {
|
|
76
|
+
mkdirSync(claudeSettingsDir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
const claudeSettingsPath = join(claudeSettingsDir, "settings.json");
|
|
79
|
+
const existingSettings = readJsonFile(claudeSettingsPath) || {};
|
|
80
|
+
if (!existingSettings.hooks) {
|
|
81
|
+
existingSettings.hooks = {};
|
|
82
|
+
}
|
|
83
|
+
// Don't overwrite existing hooks
|
|
84
|
+
if (!existingSettings.hooks.PostToolUse) {
|
|
85
|
+
existingSettings.hooks.PostToolUse = [
|
|
86
|
+
{
|
|
87
|
+
matcher: "Edit|Write",
|
|
88
|
+
hooks: [
|
|
89
|
+
{
|
|
90
|
+
type: "command",
|
|
91
|
+
command: "echo 'Run guardvibe scan_file on edited files for security check'"
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
writeJsonFile(claudeSettingsPath, existingSettings);
|
|
98
|
+
console.log(` [OK] Claude Code hooks configured (.claude/settings.json)`);
|
|
99
|
+
// Add CLAUDE.md guidance if not present
|
|
100
|
+
const claudeMdPath = join(process.cwd(), "CLAUDE.md");
|
|
101
|
+
const guardvibeBlock = `\n## GuardVibe Security\n\n- After editing security-sensitive files (API routes, auth, middleware), use the \`scan_file\` MCP tool to check for vulnerabilities\n- Before committing, run \`scan_staged\` to catch issues in staged files\n- Use \`scan_changed_files\` with the base branch to review all changes\n- When you find issues, use \`explain_remediation\` for detailed fix guidance\n`;
|
|
102
|
+
if (existsSync(claudeMdPath)) {
|
|
103
|
+
const content = readFileSync(claudeMdPath, "utf-8");
|
|
104
|
+
if (!content.includes("GuardVibe")) {
|
|
105
|
+
writeFileSync(claudeMdPath, content + guardvibeBlock, "utf-8");
|
|
106
|
+
console.log(` [OK] GuardVibe guidance added to CLAUDE.md`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
writeFileSync(claudeMdPath, `# Project Guidelines\n${guardvibeBlock}`, "utf-8");
|
|
111
|
+
console.log(` [OK] Created CLAUDE.md with GuardVibe guidance`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
68
114
|
// ── Pre-commit hook ──────────────────────────────────────────────────
|
|
69
115
|
const HOOK_SCRIPT = `#!/bin/sh
|
|
70
116
|
# GuardVibe pre-commit security hook
|
|
@@ -111,4 +111,16 @@ export const databaseRules = [
|
|
|
111
111
|
fixCode: '-- GOOD: view respects RLS\nCREATE VIEW user_orders\n WITH (security_invoker = true)\n AS SELECT * FROM orders;\n\n-- BAD: bypasses RLS\n-- CREATE VIEW user_orders AS SELECT * FROM orders;',
|
|
112
112
|
compliance: ["SOC2:CC6.1"],
|
|
113
113
|
},
|
|
114
|
+
{
|
|
115
|
+
id: "VG448",
|
|
116
|
+
name: "Supabase RPC Call May Bypass RLS",
|
|
117
|
+
severity: "high",
|
|
118
|
+
owasp: "A01:2025 Broken Access Control",
|
|
119
|
+
description: "supabase.rpc() calls execute PostgreSQL functions which may bypass Row Level Security if the function is defined as SECURITY DEFINER or if called with the service role key. Ensure the function uses SECURITY INVOKER or validate permissions manually.",
|
|
120
|
+
pattern: /supabase\.rpc\s*\(\s*["']\w+["']/g,
|
|
121
|
+
languages: ["javascript", "typescript"],
|
|
122
|
+
fix: "Ensure PostgreSQL functions called via rpc() use SECURITY INVOKER, or add explicit permission checks. Never call rpc() with the service role key from client-accessible code.",
|
|
123
|
+
fixCode: '-- PostgreSQL: use SECURITY INVOKER\nCREATE OR REPLACE FUNCTION get_user_data(user_id uuid)\nRETURNS TABLE (id uuid, name text)\nLANGUAGE sql\nSECURITY INVOKER -- respects RLS!\nAS $$\n SELECT id, name FROM users WHERE id = user_id;\n$$;\n\n// TypeScript: call with anon key (not service role)\nconst { data } = await supabase.rpc("get_user_data", { user_id: userId });',
|
|
124
|
+
compliance: ["SOC2:CC6.1"],
|
|
125
|
+
},
|
|
114
126
|
];
|
|
@@ -78,7 +78,7 @@ export const nextjsRules = [
|
|
|
78
78
|
severity: "high",
|
|
79
79
|
owasp: "A03:2025 Injection",
|
|
80
80
|
description: "Dynamic route parameters (params, searchParams) are used directly in database queries or operations without validation.",
|
|
81
|
-
pattern: /(?:params|searchParams)\s*[\.\[]\s*["']?\w+["']?\s*[\]\)]?\s*(?:;|\))\s*[\s\S]*?(?:query|execute|findUnique|findFirst|findMany|delete|update|create)\s*\(/g,
|
|
81
|
+
pattern: /(?:(?:await\s+)?(?:params|searchParams))\s*[\)\.\[]\s*["']?\w+["']?\s*[\]\)]?\s*(?:;|\))\s*[\s\S]*?(?:query|execute|findUnique|findFirst|findMany|delete|update|create)\s*\(/g,
|
|
82
82
|
languages: ["javascript", "typescript"],
|
|
83
83
|
fix: "Always validate and sanitize dynamic route parameters before using them in queries.",
|
|
84
84
|
fixCode: '// Validate params before use\nimport { z } from "zod";\nconst idSchema = z.string().uuid();\n\nexport default async function Page({ params }: { params: { id: string } }) {\n const id = idSchema.parse((await params).id);\n const item = await db.item.findUnique({ where: { id } });\n}',
|
|
@@ -90,7 +90,7 @@ export const nextjsRules = [
|
|
|
90
90
|
severity: "high",
|
|
91
91
|
owasp: "A01:2025 Broken Access Control",
|
|
92
92
|
description: "Sensitive data (tokens, secrets, internal IDs) appears to be passed from server to client component as props.",
|
|
93
|
-
pattern: /(?:secret|token|password|apiKey|privateKey|internalId|ssn|creditCard)\s*=\s*\{[\s\S]*?\}/g,
|
|
93
|
+
pattern: /(?:(?:^|[^a-zA-Z])(?:secret|token|password|apiKey|api_key|privateKey|private_key|internalId|ssn|creditCard|credit_card))\s*=\s*\{[\s\S]*?\}/g,
|
|
94
94
|
languages: ["javascript", "typescript"],
|
|
95
95
|
fix: "Never pass sensitive data as props to client components. Keep secrets server-side.",
|
|
96
96
|
fixCode: "// Keep sensitive data server-side\nexport default async function Page() {\n const secret = process.env.API_SECRET;\n const publicData = await fetchData(secret);\n return <ClientComponent data={publicData} />;\n}",
|
|
@@ -150,7 +150,7 @@ export const nextjsRules = [
|
|
|
150
150
|
severity: "high",
|
|
151
151
|
owasp: "A01:2025 Broken Access Control",
|
|
152
152
|
description: "Server Action returns a full database query result without field selection. Sensitive fields (passwordHash, internalNotes) get serialized to the client.",
|
|
153
|
-
pattern: /["']use server["'][\s\S]{0,800}?(?:findUnique|findFirst|findMany)\s*\([
|
|
153
|
+
pattern: /["']use server["'][\s\S]{0,800}?(?:return\s+\w+\.)?(?:findUnique|findFirst|findMany)\s*\((?:(?!select\s*:)[\s\S])*?\)/g,
|
|
154
154
|
languages: ["javascript", "typescript"],
|
|
155
155
|
fix: "Always use select to return only needed fields from Server Actions.",
|
|
156
156
|
fixCode: '"use server";\nexport async function getUser(id: string) {\n return prisma.user.findUnique({\n where: { id },\n select: { id: true, name: true, email: true },\n });\n}',
|
|
@@ -180,4 +180,28 @@ export const nextjsRules = [
|
|
|
180
180
|
fixCode: '<!-- EJS: use escaped output -->\n<p><%= userInput %></p> <!-- SAFE: HTML-escaped -->\n<!-- NOT: <%- userInput %> DANGEROUS: raw HTML -->\n\n<!-- Handlebars: use double braces -->\n<p>{{userInput}}</p> <!-- SAFE: escaped -->\n<!-- NOT: {{{userInput}}} DANGEROUS: raw HTML -->\n\n<!-- Pug: use = not != -->\np= userInput //- SAFE: escaped\n//- NOT: p!= userInput DANGEROUS: raw HTML',
|
|
181
181
|
compliance: ["SOC2:CC7.1", "PCI-DSS:Req6.5.1"],
|
|
182
182
|
},
|
|
183
|
+
{
|
|
184
|
+
id: "VG415",
|
|
185
|
+
name: "Cached Function Exposes User-Specific Data",
|
|
186
|
+
severity: "high",
|
|
187
|
+
owasp: "A01:2025 Broken Access Control",
|
|
188
|
+
description: "A function marked with 'use cache' accesses user-specific data (auth, session, cookies, headers) but caches the result. Cached data is shared across all users, leaking one user's data to others.",
|
|
189
|
+
pattern: /["']use cache["'][\s\S]{0,800}?(?:auth\s*\(|getServerSession|currentUser|getUser|cookies\s*\(|headers\s*\()/g,
|
|
190
|
+
languages: ["javascript", "typescript"],
|
|
191
|
+
fix: "Do not access user-specific data inside cached functions. Pass user-independent parameters only, or use cacheTag with user-specific tags.",
|
|
192
|
+
fixCode: '// BAD: caches user-specific data\n"use cache";\nasync function getData() {\n const { userId } = await auth(); // WRONG in cached fn!\n return db.items.findMany({ where: { userId } });\n}\n\n// GOOD: cache only shared data\n"use cache";\nasync function getPublicPosts() {\n return db.posts.findMany({ where: { published: true } });\n}',
|
|
193
|
+
compliance: ["SOC2:CC6.1"],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "VG416",
|
|
197
|
+
name: "Cached Function Without Revalidation Strategy",
|
|
198
|
+
severity: "medium",
|
|
199
|
+
owasp: "A05:2025 Security Misconfiguration",
|
|
200
|
+
description: "A function marked with 'use cache' does not specify a cacheLife or cacheTag for revalidation. Without explicit revalidation, stale data may be served indefinitely, including outdated security-sensitive information.",
|
|
201
|
+
pattern: /["']use cache["'](?:(?!cacheLife|cacheTag|unstable_cache)[\s\S]){10,}?(?:return|export)/g,
|
|
202
|
+
languages: ["javascript", "typescript"],
|
|
203
|
+
fix: "Add cacheLife() or cacheTag() inside cached functions to control revalidation.",
|
|
204
|
+
fixCode: '"use cache";\nimport { cacheLife, cacheTag } from "next/cache";\n\nasync function getCachedData() {\n cacheLife("hours");\n cacheTag("data-feed");\n return db.posts.findMany();\n}\n\n// Revalidate when data changes:\nimport { revalidateTag } from "next/cache";\nrevalidateTag("data-feed");',
|
|
205
|
+
compliance: ["SOC2:CC7.1"],
|
|
206
|
+
},
|
|
183
207
|
];
|
|
@@ -37,6 +37,55 @@ function isInComment(lines, lineNumber) {
|
|
|
37
37
|
trimmed.startsWith("<!--") ||
|
|
38
38
|
trimmed.startsWith("/*"));
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if a match is inside a multi-line string literal (template literal,
|
|
42
|
+
* fixCode/description property, or string concatenation).
|
|
43
|
+
* This prevents rule definition files, docs, and test fixtures from triggering
|
|
44
|
+
* false positives when they contain code examples as string values.
|
|
45
|
+
*/
|
|
46
|
+
function isInsideStringLiteral(lines, lineNumber, code, matchIndex) {
|
|
47
|
+
const line = lines[lineNumber - 1];
|
|
48
|
+
if (!line)
|
|
49
|
+
return false;
|
|
50
|
+
// 1. Template literal: count unescaped backticks before this point
|
|
51
|
+
const before = code.substring(0, matchIndex);
|
|
52
|
+
const backtickCount = (before.match(/(?<!\\)`/g) || []).length;
|
|
53
|
+
if (backtickCount % 2 === 1)
|
|
54
|
+
return true;
|
|
55
|
+
// 2. The match line itself is a string continuation (starts with quote + or ends with +quote)
|
|
56
|
+
const trimmed = line.trimStart();
|
|
57
|
+
if (/^["']/.test(trimmed) && /\+\s*$/.test(line))
|
|
58
|
+
return true; // "string" +
|
|
59
|
+
if (/^\s*\+\s*["']/.test(line))
|
|
60
|
+
return true; // + "string continuation"
|
|
61
|
+
// 3. Line contains escaped newlines (\n) suggesting it's inside a string value
|
|
62
|
+
const quotesBefore = line.substring(0, line.indexOf(trimmed.charAt(0)));
|
|
63
|
+
if (/\\n/.test(line) && /["'`].*\\n/.test(line)) {
|
|
64
|
+
// Extra check: is the match portion inside quotes on this line?
|
|
65
|
+
const matchEnd = matchIndex + 20; // approximate
|
|
66
|
+
const lineStart = code.lastIndexOf("\n", matchIndex) + 1;
|
|
67
|
+
const col = matchIndex - lineStart;
|
|
68
|
+
const beforeCol = line.substring(0, col);
|
|
69
|
+
const singleQuotes = (beforeCol.match(/(?<!\\)'/g) || []).length;
|
|
70
|
+
const doubleQuotes = (beforeCol.match(/(?<!\\)"/g) || []).length;
|
|
71
|
+
if (singleQuotes % 2 === 1 || doubleQuotes % 2 === 1)
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
// 4. Look backwards for property assignment context (fixCode, description, etc.)
|
|
75
|
+
for (let i = lineNumber - 1; i >= Math.max(0, lineNumber - 20); i--) {
|
|
76
|
+
const prev = lines[i]?.trimStart() || "";
|
|
77
|
+
if (/^(?:fixCode|fix|description|exploit|audit)\s*[:=]/.test(prev))
|
|
78
|
+
return true;
|
|
79
|
+
if (/^(?:fixCode|fix|description|exploit|audit)\s*:\s*$/.test(prev))
|
|
80
|
+
return true;
|
|
81
|
+
// Hit a rule boundary — stop looking
|
|
82
|
+
if (/^\s*id\s*:\s*["']VG/.test(prev))
|
|
83
|
+
break;
|
|
84
|
+
if (/^\s*\{/.test(prev) && i < lineNumber - 2)
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
40
89
|
/**
|
|
41
90
|
* Check if a match on a given line is inside a string value used as a
|
|
42
91
|
* human-readable message (UI label, error text) rather than an actual secret.
|
|
@@ -56,7 +105,32 @@ function isHumanReadableString(lines, lineNumber) {
|
|
|
56
105
|
return true;
|
|
57
106
|
return false;
|
|
58
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Detect if a file is a security rule definition file.
|
|
110
|
+
* These files intentionally contain vulnerable code patterns
|
|
111
|
+
* as regex matchers and fixCode examples — scanning them is meaningless.
|
|
112
|
+
*/
|
|
113
|
+
function isRuleDefinitionFile(code, filePath) {
|
|
114
|
+
// Path-based: known rule definition directories
|
|
115
|
+
if (filePath && /(?:\/rules\/|\/data\/rules\/)/.test(filePath)) {
|
|
116
|
+
// Confirm it actually exports SecurityRule objects
|
|
117
|
+
if (/SecurityRule\s*\[\]/.test(code) && /id:\s*["']VG\d+["']/.test(code)) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Content-based: file defines multiple VG rules with pattern: regex
|
|
122
|
+
if (/id:\s*["']VG\d+["']/g.test(code) && /pattern:\s*\//.test(code)) {
|
|
123
|
+
const ruleCount = (code.match(/id:\s*["']VG\d+["']/g) || []).length;
|
|
124
|
+
if (ruleCount >= 3)
|
|
125
|
+
return true; // 3+ rule definitions = rule file
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
59
129
|
export function analyzeCode(code, language, framework, filePath, configDir, rules) {
|
|
130
|
+
// Skip files that are security rule definitions (they intentionally contain
|
|
131
|
+
// vulnerable code patterns as regex matchers and fixCode examples)
|
|
132
|
+
if (isRuleDefinitionFile(code, filePath))
|
|
133
|
+
return [];
|
|
60
134
|
const config = loadConfig(configDir);
|
|
61
135
|
const ignoreEntries = loadIgnoreFile(configDir || process.cwd());
|
|
62
136
|
const findings = [];
|
|
@@ -101,8 +175,9 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
101
175
|
if (rule.id.startsWith("VG54") && filePath && /(?:migrations?|seeds?|fixtures)\//i.test(filePath))
|
|
102
176
|
continue;
|
|
103
177
|
// Skip server-only import rule (VG964) for files that are inherently server-only:
|
|
104
|
-
// Route Handlers (app/api/), middleware, instrumentation, next.config
|
|
105
|
-
|
|
178
|
+
// Route Handlers (app/api/), middleware, instrumentation, next.config,
|
|
179
|
+
// lib/, utils/, tools/, server/, scripts/, CLI files, config files
|
|
180
|
+
if (rule.id === "VG964" && filePath && /(?:\/api\/|middleware\.|instrumentation\.|next\.config\.|\/lib\/|\/utils\/|\/tools\/|\/server\/|\/scripts\/|\/src\/(?!app\/|pages\/|components\/)|\bcli\b|\.config\.)/.test(filePath))
|
|
106
181
|
continue;
|
|
107
182
|
// Skip React Native/mobile-only rules (VG70x) in web projects:
|
|
108
183
|
// only apply when framework is react-native/expo or path suggests mobile
|
|
@@ -145,6 +220,12 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
145
220
|
if (isInComment(lines, lineNumber))
|
|
146
221
|
continue;
|
|
147
222
|
}
|
|
223
|
+
// Skip matches inside string literals (fixCode, description, template strings)
|
|
224
|
+
// This prevents rule definition files and docs from triggering false positives
|
|
225
|
+
if (!rule.id.startsWith("VG9")) {
|
|
226
|
+
if (isInsideStringLiteral(lines, lineNumber, code, match.index))
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
148
229
|
// Skip hardcoded-credential rules when the value is a human-readable sentence
|
|
149
230
|
if (rule.id === "VG001" || rule.id === "VG062") {
|
|
150
231
|
if (isHumanReadableString(lines, lineNumber))
|
package/build/utils/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
-
import { join, resolve } from "path";
|
|
2
|
+
import { join, resolve, dirname } from "path";
|
|
3
3
|
const DEFAULT_CONFIG = {
|
|
4
4
|
rules: { disable: [], severity: {} },
|
|
5
5
|
scan: { exclude: [], maxFileSize: 500 * 1024 },
|
|
@@ -14,13 +14,38 @@ function cloneDefaultConfig() {
|
|
|
14
14
|
plugins: [...DEFAULT_CONFIG.plugins],
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Find .guardviberc by walking up from dir to filesystem root.
|
|
19
|
+
* Returns the path if found, null otherwise.
|
|
20
|
+
*/
|
|
21
|
+
function findConfigFile(startDir) {
|
|
22
|
+
let current = startDir;
|
|
23
|
+
const root = resolve("/");
|
|
24
|
+
while (true) {
|
|
25
|
+
const candidate = join(current, ".guardviberc");
|
|
26
|
+
try {
|
|
27
|
+
readFileSync(candidate, "utf-8"); // will throw if not found
|
|
28
|
+
return candidate;
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
const parent = dirname(current);
|
|
32
|
+
if (parent === current || current === root)
|
|
33
|
+
break;
|
|
34
|
+
current = parent;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
17
38
|
export function loadConfig(dir) {
|
|
18
39
|
const configDir = resolve(dir || process.cwd());
|
|
19
40
|
const cached = configCache.get(configDir);
|
|
20
41
|
if (cached)
|
|
21
42
|
return cached;
|
|
22
|
-
const configPath =
|
|
43
|
+
const configPath = findConfigFile(configDir);
|
|
23
44
|
let resolvedConfig = cloneDefaultConfig();
|
|
45
|
+
if (!configPath) {
|
|
46
|
+
configCache.set(configDir, resolvedConfig);
|
|
47
|
+
return resolvedConfig;
|
|
48
|
+
}
|
|
24
49
|
try {
|
|
25
50
|
const content = readFileSync(configPath, "utf-8");
|
|
26
51
|
const parsed = JSON.parse(content);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardvibe",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "Security MCP for vibe coding. 277 rules, 24 tools for Next.js, Supabase, Clerk, Stripe, Prisma, tRPC, Hono, GraphQL, Convex, Turso, Uploadthing, AI SDK, and the full AI-generated stack.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|