guardvibe 1.9.2 → 1.9.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/build/cli.js +66 -9
- package/build/data/rules/nextjs.js +24 -0
- package/build/tools/check-code.js +49 -9
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -110,6 +110,22 @@ function setupClaudeHooksAndGuide() {
|
|
|
110
110
|
writeFileSync(claudeMdPath, `# Project Guidelines\n${guardvibeBlock}`, "utf-8");
|
|
111
111
|
console.log(` [OK] Created CLAUDE.md with GuardVibe guidance`);
|
|
112
112
|
}
|
|
113
|
+
// Add generated files to .gitignore so they don't get committed
|
|
114
|
+
addToGitignore([".claude.json", ".claude/", "CLAUDE.md"]);
|
|
115
|
+
}
|
|
116
|
+
function addToGitignore(entries) {
|
|
117
|
+
const gitignorePath = join(process.cwd(), ".gitignore");
|
|
118
|
+
let content = "";
|
|
119
|
+
try {
|
|
120
|
+
content = readFileSync(gitignorePath, "utf-8");
|
|
121
|
+
}
|
|
122
|
+
catch { /* no .gitignore yet */ }
|
|
123
|
+
const missing = entries.filter(e => !content.split("\n").some(line => line.trim() === e));
|
|
124
|
+
if (missing.length === 0)
|
|
125
|
+
return;
|
|
126
|
+
const block = `\n# GuardVibe / Claude Code (auto-added by guardvibe init)\n${missing.join("\n")}\n`;
|
|
127
|
+
writeFileSync(gitignorePath, content.trimEnd() + block, "utf-8");
|
|
128
|
+
console.log(` [OK] Added ${missing.join(", ")} to .gitignore`);
|
|
113
129
|
}
|
|
114
130
|
// ── Pre-commit hook ──────────────────────────────────────────────────
|
|
115
131
|
const HOOK_SCRIPT = `#!/bin/sh
|
|
@@ -245,6 +261,36 @@ if (SCAN_SCRIPT_DETECTED) {
|
|
|
245
261
|
else {
|
|
246
262
|
main();
|
|
247
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Check if scan results should cause a non-zero exit based on --fail-on flag.
|
|
266
|
+
* Default: "critical" — only exit 1 on critical findings.
|
|
267
|
+
* Options: "critical", "high", "medium", "low", "none"
|
|
268
|
+
*/
|
|
269
|
+
function shouldFail(result, failOn) {
|
|
270
|
+
if (failOn === "none")
|
|
271
|
+
return false;
|
|
272
|
+
const levels = {
|
|
273
|
+
low: ["critical", "high", "medium", "low"],
|
|
274
|
+
medium: ["critical", "high", "medium"],
|
|
275
|
+
high: ["critical", "high"],
|
|
276
|
+
critical: ["critical"],
|
|
277
|
+
};
|
|
278
|
+
const failLevels = levels[failOn] || levels.critical;
|
|
279
|
+
// Try JSON format first
|
|
280
|
+
try {
|
|
281
|
+
const parsed = JSON.parse(result);
|
|
282
|
+
if (parsed.summary) {
|
|
283
|
+
return failLevels.some(level => (parsed.summary[level] ?? 0) > 0);
|
|
284
|
+
}
|
|
285
|
+
if (parsed.findings) {
|
|
286
|
+
return parsed.findings.some((f) => failLevels.includes(f.severity));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch { /* not JSON, try markdown tags */ }
|
|
290
|
+
// Markdown format: check for [SEVERITY] tags
|
|
291
|
+
const tags = failLevels.map(l => `[${l.toUpperCase()}]`);
|
|
292
|
+
return tags.some(tag => result.includes(tag));
|
|
293
|
+
}
|
|
248
294
|
function parseArgs(args) {
|
|
249
295
|
const flags = {};
|
|
250
296
|
const positional = [];
|
|
@@ -288,8 +334,8 @@ async function runScan() {
|
|
|
288
334
|
console.log(result);
|
|
289
335
|
}
|
|
290
336
|
if (format !== "sarif") {
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
337
|
+
const failOn = flags["fail-on"] ?? "high";
|
|
338
|
+
if (shouldFail(result, failOn))
|
|
293
339
|
process.exit(1);
|
|
294
340
|
}
|
|
295
341
|
}
|
|
@@ -325,8 +371,8 @@ async function runDirectoryScan(targetPath, flags) {
|
|
|
325
371
|
console.log(` [OK] Baseline saved to ${baselineFile}`);
|
|
326
372
|
}
|
|
327
373
|
if (format !== "sarif") {
|
|
328
|
-
const
|
|
329
|
-
if (
|
|
374
|
+
const failOn = flags["fail-on"] ?? "critical";
|
|
375
|
+
if (shouldFail(result, failOn))
|
|
330
376
|
process.exit(1);
|
|
331
377
|
}
|
|
332
378
|
}
|
|
@@ -405,9 +451,18 @@ async function runDiffScan(base, flags) {
|
|
|
405
451
|
else {
|
|
406
452
|
console.log(result);
|
|
407
453
|
}
|
|
408
|
-
const
|
|
409
|
-
if (
|
|
410
|
-
|
|
454
|
+
const failOn = flags["fail-on"] ?? "critical";
|
|
455
|
+
if (failOn !== "none") {
|
|
456
|
+
const failLevels = {
|
|
457
|
+
low: ["critical", "high", "medium", "low"],
|
|
458
|
+
medium: ["critical", "high", "medium"],
|
|
459
|
+
high: ["critical", "high"],
|
|
460
|
+
critical: ["critical"],
|
|
461
|
+
};
|
|
462
|
+
const levels = failLevels[failOn] || failLevels.critical;
|
|
463
|
+
if (allFindings.some(f => levels.includes(f.severity)))
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
411
466
|
}
|
|
412
467
|
async function runFileCheck(filePath, flags) {
|
|
413
468
|
const { checkCode } = await import("./tools/check-code.js");
|
|
@@ -443,8 +498,8 @@ async function runFileCheck(filePath, flags) {
|
|
|
443
498
|
else {
|
|
444
499
|
console.log(result);
|
|
445
500
|
}
|
|
446
|
-
const
|
|
447
|
-
if (
|
|
501
|
+
const failOn = flags["fail-on"] ?? "critical";
|
|
502
|
+
if (shouldFail(result, failOn))
|
|
448
503
|
process.exit(1);
|
|
449
504
|
}
|
|
450
505
|
// ── Main CLI ─────────────────────────────────────────────────────────
|
|
@@ -468,6 +523,8 @@ function printUsage() {
|
|
|
468
523
|
Options:
|
|
469
524
|
--format <type> Output format: markdown (default), json, sarif
|
|
470
525
|
--output <file> Write results to file instead of stdout
|
|
526
|
+
--fail-on <level> Exit 1 when findings at this level exist
|
|
527
|
+
Options: critical (default), high, medium, low, none
|
|
471
528
|
--baseline <file> Compare against a previous scan JSON for fix tracking
|
|
472
529
|
--save-baseline Save current scan as baseline (.guardvibe-baseline.json)
|
|
473
530
|
--version, -V Print version and exit
|
|
@@ -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
|
];
|
|
@@ -47,23 +47,38 @@ function isInsideStringLiteral(lines, lineNumber, code, matchIndex) {
|
|
|
47
47
|
const line = lines[lineNumber - 1];
|
|
48
48
|
if (!line)
|
|
49
49
|
return false;
|
|
50
|
-
|
|
51
|
-
// Line is part of a template literal or multi-line string
|
|
52
|
-
// Check if we're between backticks by counting unescaped backticks before this point
|
|
50
|
+
// 1. Template literal: count unescaped backticks before this point
|
|
53
51
|
const before = code.substring(0, matchIndex);
|
|
54
52
|
const backtickCount = (before.match(/(?<!\\)`/g) || []).length;
|
|
55
53
|
if (backtickCount % 2 === 1)
|
|
56
|
-
return true;
|
|
57
|
-
//
|
|
58
|
-
|
|
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.)
|
|
59
75
|
for (let i = lineNumber - 1; i >= Math.max(0, lineNumber - 20); i--) {
|
|
60
76
|
const prev = lines[i]?.trimStart() || "";
|
|
61
|
-
|
|
62
|
-
if (/^(?:fixCode|fix|description|exploit|audit|fixCode)\s*[:=]/.test(prev))
|
|
77
|
+
if (/^(?:fixCode|fix|description|exploit|audit)\s*[:=]/.test(prev))
|
|
63
78
|
return true;
|
|
64
79
|
if (/^(?:fixCode|fix|description|exploit|audit)\s*:\s*$/.test(prev))
|
|
65
80
|
return true;
|
|
66
|
-
// Hit a rule boundary
|
|
81
|
+
// Hit a rule boundary — stop looking
|
|
67
82
|
if (/^\s*id\s*:\s*["']VG/.test(prev))
|
|
68
83
|
break;
|
|
69
84
|
if (/^\s*\{/.test(prev) && i < lineNumber - 2)
|
|
@@ -90,7 +105,32 @@ function isHumanReadableString(lines, lineNumber) {
|
|
|
90
105
|
return true;
|
|
91
106
|
return false;
|
|
92
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
|
+
}
|
|
93
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 [];
|
|
94
134
|
const config = loadConfig(configDir);
|
|
95
135
|
const ignoreEntries = loadIgnoreFile(configDir || process.cwd());
|
|
96
136
|
const findings = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardvibe",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.4",
|
|
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": {
|