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 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*\([^)]*\)\s*;?\s*\n\s*return/g,
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
- if (rule.id === "VG964" && filePath && /(?:\/api\/|middleware\.|instrumentation\.|next\.config\.)/.test(filePath))
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))
@@ -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 = join(configDir, ".guardviberc");
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.1",
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": {