guardvibe 1.8.10 → 1.9.1
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 +105 -5
- package/build/data/rules/core.js +1 -1
- package/build/index.js +102 -1
- package/build/tools/check-code.js +130 -2
- package/build/tools/check-project.js +26 -15
- package/build/tools/scan-directory.js +31 -12
- package/build/utils/ignore.d.ts +10 -0
- package/build/utils/ignore.js +101 -0
- package/package.json +2 -2
package/build/cli.js
CHANGED
|
@@ -252,6 +252,8 @@ async function runDirectoryScan(targetPath, flags) {
|
|
|
252
252
|
const { resolve } = await import("path");
|
|
253
253
|
const format = flags.format ?? "markdown";
|
|
254
254
|
const outputFile = flags.output ?? null;
|
|
255
|
+
const baselinePath = flags.baseline ?? null;
|
|
256
|
+
const saveBaseline = flags["save-baseline"] === true || typeof flags["save-baseline"] === "string";
|
|
255
257
|
const scanPath = resolve(targetPath);
|
|
256
258
|
let result;
|
|
257
259
|
if (format === "sarif") {
|
|
@@ -259,7 +261,7 @@ async function runDirectoryScan(targetPath, flags) {
|
|
|
259
261
|
result = exportSarif(scanPath);
|
|
260
262
|
}
|
|
261
263
|
else {
|
|
262
|
-
result = scanDirectory(scanPath, true, [], format === "json" ? "json" : "markdown");
|
|
264
|
+
result = scanDirectory(scanPath, true, [], format === "json" ? "json" : "markdown", undefined, baselinePath ?? undefined);
|
|
263
265
|
}
|
|
264
266
|
if (outputFile) {
|
|
265
267
|
writeFileSync(outputFile, result, "utf-8");
|
|
@@ -268,12 +270,99 @@ async function runDirectoryScan(targetPath, flags) {
|
|
|
268
270
|
else {
|
|
269
271
|
console.log(result);
|
|
270
272
|
}
|
|
273
|
+
// Auto-save baseline for future diff comparisons
|
|
274
|
+
if (saveBaseline && format === "json") {
|
|
275
|
+
const baselineFile = typeof flags["save-baseline"] === "string"
|
|
276
|
+
? flags["save-baseline"]
|
|
277
|
+
: join(scanPath, ".guardvibe-baseline.json");
|
|
278
|
+
writeFileSync(baselineFile, result, "utf-8");
|
|
279
|
+
console.log(` [OK] Baseline saved to ${baselineFile}`);
|
|
280
|
+
}
|
|
271
281
|
if (format !== "sarif") {
|
|
272
282
|
const hasBlocking = result.includes("[CRITICAL]") || result.includes("[HIGH]");
|
|
273
283
|
if (hasBlocking)
|
|
274
284
|
process.exit(1);
|
|
275
285
|
}
|
|
276
286
|
}
|
|
287
|
+
async function runDiffScan(base, flags) {
|
|
288
|
+
const { execFileSync } = await import("child_process");
|
|
289
|
+
const { resolve, extname, basename } = await import("path");
|
|
290
|
+
const { analyzeCode } = await import("./tools/check-code.js");
|
|
291
|
+
const { EXTENSION_MAP, CONFIG_FILE_MAP } = await import("./utils/constants.js");
|
|
292
|
+
const format = flags.format ?? "markdown";
|
|
293
|
+
const outputFile = flags.output ?? null;
|
|
294
|
+
const root = resolve(".");
|
|
295
|
+
let changedFiles;
|
|
296
|
+
try {
|
|
297
|
+
const output = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMR", base], { cwd: root, encoding: "utf-8" });
|
|
298
|
+
changedFiles = output.trim().split("\n").filter(Boolean);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
console.error(" [ERR] Failed to get git diff. Ensure you're in a git repository.");
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
if (changedFiles.length === 0) {
|
|
305
|
+
console.log(" No changed files to scan.");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const allFindings = [];
|
|
309
|
+
for (const relPath of changedFiles) {
|
|
310
|
+
const fullPath = resolve(root, relPath);
|
|
311
|
+
if (!existsSync(fullPath))
|
|
312
|
+
continue;
|
|
313
|
+
const ext = extname(relPath).toLowerCase();
|
|
314
|
+
let language = EXTENSION_MAP[ext];
|
|
315
|
+
if (!language && basename(relPath).startsWith("Dockerfile"))
|
|
316
|
+
language = "dockerfile";
|
|
317
|
+
if (!language)
|
|
318
|
+
language = CONFIG_FILE_MAP[basename(relPath)];
|
|
319
|
+
if (!language)
|
|
320
|
+
continue;
|
|
321
|
+
try {
|
|
322
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
323
|
+
const findings = analyzeCode(content, language, undefined, fullPath, root);
|
|
324
|
+
for (const f of findings) {
|
|
325
|
+
allFindings.push({ file: relPath, severity: f.rule.severity, name: f.rule.name, id: f.rule.id, line: f.line, fix: f.rule.fix });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch { /* skip */ }
|
|
329
|
+
}
|
|
330
|
+
let result;
|
|
331
|
+
if (format === "json") {
|
|
332
|
+
const critical = allFindings.filter(f => f.severity === "critical").length;
|
|
333
|
+
const high = allFindings.filter(f => f.severity === "high").length;
|
|
334
|
+
const medium = allFindings.filter(f => f.severity === "medium").length;
|
|
335
|
+
result = JSON.stringify({
|
|
336
|
+
summary: { total: allFindings.length, critical, high, medium, changedFiles: changedFiles.length, blocked: critical > 0 || high > 0 },
|
|
337
|
+
findings: allFindings,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
const lines = [`# GuardVibe Diff Report`, ``, `Base: ${base}`, `Changed files: ${changedFiles.length}`, `Issues: ${allFindings.length}`, ``];
|
|
342
|
+
if (allFindings.length === 0) {
|
|
343
|
+
lines.push(`All changed files passed security checks.`);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const sev = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
347
|
+
allFindings.sort((a, b) => (sev[a.severity] ?? 99) - (sev[b.severity] ?? 99));
|
|
348
|
+
for (const f of allFindings) {
|
|
349
|
+
lines.push(`- [${f.severity.toUpperCase()}] **${f.name}** (${f.id}) in ${f.file}:${f.line}`);
|
|
350
|
+
lines.push(` Fix: ${f.fix}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
result = lines.join("\n");
|
|
354
|
+
}
|
|
355
|
+
if (outputFile) {
|
|
356
|
+
writeFileSync(outputFile, result, "utf-8");
|
|
357
|
+
console.log(` [OK] Results written to ${outputFile}`);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
console.log(result);
|
|
361
|
+
}
|
|
362
|
+
const hasBlocking = allFindings.some(f => f.severity === "critical" || f.severity === "high");
|
|
363
|
+
if (hasBlocking)
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
277
366
|
async function runFileCheck(filePath, flags) {
|
|
278
367
|
const { checkCode } = await import("./tools/check-code.js");
|
|
279
368
|
const { resolve, extname, basename } = await import("path");
|
|
@@ -319,6 +408,7 @@ function printUsage() {
|
|
|
319
408
|
|
|
320
409
|
Commands:
|
|
321
410
|
npx guardvibe scan [path] Scan a directory for security issues
|
|
411
|
+
npx guardvibe diff [base] Scan only changed files since a git ref
|
|
322
412
|
npx guardvibe check <file> Scan a single file for security issues
|
|
323
413
|
npx guardvibe init <platform> Setup MCP server configuration
|
|
324
414
|
npx guardvibe hook install Install pre-commit security hook
|
|
@@ -330,10 +420,12 @@ function printUsage() {
|
|
|
330
420
|
npx guardvibe-scan --format sarif --output results.sarif
|
|
331
421
|
|
|
332
422
|
Options:
|
|
333
|
-
--format <type>
|
|
334
|
-
--output <file>
|
|
335
|
-
--
|
|
336
|
-
--
|
|
423
|
+
--format <type> Output format: markdown (default), json, sarif
|
|
424
|
+
--output <file> Write results to file instead of stdout
|
|
425
|
+
--baseline <file> Compare against a previous scan JSON for fix tracking
|
|
426
|
+
--save-baseline Save current scan as baseline (.guardvibe-baseline.json)
|
|
427
|
+
--version, -V Print version and exit
|
|
428
|
+
--help, -h Show this help message
|
|
337
429
|
|
|
338
430
|
MCP Platforms:
|
|
339
431
|
claude Claude Code (.claude.json in project root)
|
|
@@ -349,6 +441,8 @@ function printUsage() {
|
|
|
349
441
|
npx guardvibe scan .
|
|
350
442
|
npx guardvibe scan ./src --format json
|
|
351
443
|
npx guardvibe scan . --format sarif --output results.sarif
|
|
444
|
+
npx guardvibe scan . --format json --save-baseline
|
|
445
|
+
npx guardvibe scan . --baseline .guardvibe-baseline.json
|
|
352
446
|
npx guardvibe check src/app/api/route.ts
|
|
353
447
|
npx guardvibe check package.json
|
|
354
448
|
npx guardvibe init claude
|
|
@@ -423,6 +517,12 @@ async function main() {
|
|
|
423
517
|
const targetPath = positional[0] ?? ".";
|
|
424
518
|
await runDirectoryScan(targetPath, flags);
|
|
425
519
|
}
|
|
520
|
+
else if (command === "diff") {
|
|
521
|
+
const cliArgs = args.slice(1);
|
|
522
|
+
const { flags, positional } = parseArgs(cliArgs);
|
|
523
|
+
const base = positional[0] ?? "main";
|
|
524
|
+
await runDiffScan(base, flags);
|
|
525
|
+
}
|
|
426
526
|
else if (command === "check") {
|
|
427
527
|
const cliArgs = args.slice(1);
|
|
428
528
|
const { flags, positional } = parseArgs(cliArgs);
|
package/build/data/rules/core.js
CHANGED
|
@@ -79,7 +79,7 @@ export const coreRules = [
|
|
|
79
79
|
severity: "high",
|
|
80
80
|
owasp: "A02:2025 Injection",
|
|
81
81
|
description: "Setting innerHTML with dynamic content enables Cross-Site Scripting (XSS) attacks.",
|
|
82
|
-
pattern: /(?:innerHTML|outerHTML
|
|
82
|
+
pattern: /(?:innerHTML|outerHTML)\s*(?:=|:)\s*(?!['"]<)/gi,
|
|
83
83
|
languages: ["javascript", "typescript", "html"],
|
|
84
84
|
fix: "Use textContent instead of innerHTML. Sanitize with DOMPurify if HTML rendering is needed. In React, avoid dangerouslySetInnerHTML.",
|
|
85
85
|
// fixCode: added via concatenation to avoid false-positive hook trigger on DOMPurify example
|
package/build/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createRequire } from "module";
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { checkCode } from "./tools/check-code.js";
|
|
6
|
+
import { checkCode, analyzeCode } from "./tools/check-code.js";
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const pkg = require("../package.json");
|
|
9
9
|
import { checkProject } from "./tools/check-project.js";
|
|
@@ -288,6 +288,107 @@ server.tool("explain_remediation", "Deep explanation of a security finding: why
|
|
|
288
288
|
const results = explainRemediation(rule_id, code, format, rules);
|
|
289
289
|
return { content: [{ type: "text", text: results }] };
|
|
290
290
|
});
|
|
291
|
+
// Tool 23: Quick file scan — designed for real-time integration
|
|
292
|
+
server.tool("scan_file", "Scan a single file from disk for security vulnerabilities. Returns only findings (no boilerplate). Designed for real-time use: call this after editing a file to catch security issues immediately. Lightweight and fast — reads the file, detects language, and returns findings in JSON.", {
|
|
293
|
+
file_path: z.string().describe("Absolute or relative path to the file to scan"),
|
|
294
|
+
format: z.enum(["markdown", "json"]).default("json").describe("Output format"),
|
|
295
|
+
}, async ({ file_path, format }) => {
|
|
296
|
+
const { readFileSync, existsSync } = await import("fs");
|
|
297
|
+
const { resolve, extname, basename, dirname } = await import("path");
|
|
298
|
+
const resolved = resolve(file_path);
|
|
299
|
+
if (!existsSync(resolved)) {
|
|
300
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `File not found: ${resolved}` }) }] };
|
|
301
|
+
}
|
|
302
|
+
const content = readFileSync(resolved, "utf-8");
|
|
303
|
+
const ext = extname(resolved).toLowerCase();
|
|
304
|
+
const { EXTENSION_MAP, CONFIG_FILE_MAP } = await import("./utils/constants.js");
|
|
305
|
+
let language = EXTENSION_MAP[ext];
|
|
306
|
+
if (!language && basename(resolved).startsWith("Dockerfile"))
|
|
307
|
+
language = "dockerfile";
|
|
308
|
+
if (!language)
|
|
309
|
+
language = CONFIG_FILE_MAP[basename(resolved)];
|
|
310
|
+
if (!language) {
|
|
311
|
+
return { content: [{ type: "text", text: format === "json" ? JSON.stringify({ summary: { total: 0 }, findings: [] }) : "Unsupported file type." }] };
|
|
312
|
+
}
|
|
313
|
+
const rules = getRules();
|
|
314
|
+
const result = checkCode(content, language, undefined, resolved, dirname(resolved), format, rules);
|
|
315
|
+
return { content: [{ type: "text", text: result }] };
|
|
316
|
+
});
|
|
317
|
+
// Tool 24: Scan changed files only — for incremental CI/CD and PR workflows
|
|
318
|
+
server.tool("scan_changed_files", "Scan only files that have changed since a given git ref (branch, commit, or HEAD~N). Ideal for PR checks, pre-push hooks, and incremental CI. Returns findings only for modified/added files.", {
|
|
319
|
+
path: z.string().default(".").describe("Repository root path"),
|
|
320
|
+
base: z.string().default("HEAD~1").describe("Git ref to diff against (e.g. 'main', 'HEAD~3', commit SHA)"),
|
|
321
|
+
format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"),
|
|
322
|
+
}, async ({ path: repoPath, base, format }) => {
|
|
323
|
+
const { execFileSync } = await import("child_process");
|
|
324
|
+
const { readFileSync, existsSync } = await import("fs");
|
|
325
|
+
const { resolve, extname, basename } = await import("path");
|
|
326
|
+
const { EXTENSION_MAP, CONFIG_FILE_MAP } = await import("./utils/constants.js");
|
|
327
|
+
const root = resolve(repoPath);
|
|
328
|
+
let changedFiles;
|
|
329
|
+
try {
|
|
330
|
+
const output = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMR", base], { cwd: root, encoding: "utf-8" });
|
|
331
|
+
changedFiles = output.trim().split("\n").filter(Boolean);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return { content: [{ type: "text", text: format === "json" ? JSON.stringify({ error: "Failed to get git diff" }) : "Error: Failed to get git diff. Ensure you're in a git repository." }] };
|
|
335
|
+
}
|
|
336
|
+
if (changedFiles.length === 0) {
|
|
337
|
+
const empty = format === "json"
|
|
338
|
+
? JSON.stringify({ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0, blocked: false }, findings: [] })
|
|
339
|
+
: "No changed files to scan.";
|
|
340
|
+
return { content: [{ type: "text", text: empty }] };
|
|
341
|
+
}
|
|
342
|
+
const rules = getRules();
|
|
343
|
+
const allFindings = [];
|
|
344
|
+
for (const relPath of changedFiles) {
|
|
345
|
+
const fullPath = resolve(root, relPath);
|
|
346
|
+
if (!existsSync(fullPath))
|
|
347
|
+
continue;
|
|
348
|
+
const ext = extname(relPath).toLowerCase();
|
|
349
|
+
let language = EXTENSION_MAP[ext];
|
|
350
|
+
if (!language && basename(relPath).startsWith("Dockerfile"))
|
|
351
|
+
language = "dockerfile";
|
|
352
|
+
if (!language)
|
|
353
|
+
language = CONFIG_FILE_MAP[basename(relPath)];
|
|
354
|
+
if (!language)
|
|
355
|
+
continue;
|
|
356
|
+
try {
|
|
357
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
358
|
+
const findings = analyzeCode(content, language, undefined, fullPath, root, rules);
|
|
359
|
+
for (const f of findings) {
|
|
360
|
+
allFindings.push({
|
|
361
|
+
file: relPath, id: f.rule.id, name: f.rule.name,
|
|
362
|
+
severity: f.rule.severity, owasp: f.rule.owasp,
|
|
363
|
+
line: f.line, match: f.match, fix: f.rule.fix, fixCode: f.rule.fixCode,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch { /* skip unreadable files */ }
|
|
368
|
+
}
|
|
369
|
+
if (format === "json") {
|
|
370
|
+
const critical = allFindings.filter(f => f.severity === "critical").length;
|
|
371
|
+
const high = allFindings.filter(f => f.severity === "high").length;
|
|
372
|
+
const medium = allFindings.filter(f => f.severity === "medium").length;
|
|
373
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
374
|
+
summary: { total: allFindings.length, critical, high, medium, low: 0, blocked: critical > 0 || high > 0, changedFiles: changedFiles.length },
|
|
375
|
+
findings: allFindings,
|
|
376
|
+
}) }] };
|
|
377
|
+
}
|
|
378
|
+
// Markdown
|
|
379
|
+
const lines = [`# GuardVibe Changed Files Report`, ``, `Base: ${base}`, `Changed files: ${changedFiles.length}`, `Issues found: ${allFindings.length}`, ``];
|
|
380
|
+
if (allFindings.length === 0) {
|
|
381
|
+
lines.push(`All changed files passed security checks.`);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
385
|
+
allFindings.sort((a, b) => (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99));
|
|
386
|
+
for (const f of allFindings) {
|
|
387
|
+
lines.push(`- [${f.severity.toUpperCase()}] **${f.name}** (${f.id}) in \`${f.file}\`:${f.line} — ${f.fix}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
391
|
+
});
|
|
291
392
|
export async function startMcpServer() {
|
|
292
393
|
return main();
|
|
293
394
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { owaspRules } from "../data/rules/index.js";
|
|
2
2
|
import { loadConfig } from "../utils/config.js";
|
|
3
|
+
import { loadIgnoreFile, isIgnored } from "../utils/ignore.js";
|
|
3
4
|
function parseSuppressionsFromCode(lines) {
|
|
4
5
|
const suppressions = [];
|
|
5
6
|
const pattern = /(?:\/\/|#|<!--)\s*guardvibe-ignore(?:-next-line)?\s*(VG\d+)?\s*(?:-->)?/i;
|
|
@@ -57,6 +58,7 @@ function isHumanReadableString(lines, lineNumber) {
|
|
|
57
58
|
}
|
|
58
59
|
export function analyzeCode(code, language, framework, filePath, configDir, rules) {
|
|
59
60
|
const config = loadConfig(configDir);
|
|
61
|
+
const ignoreEntries = loadIgnoreFile(configDir || process.cwd());
|
|
60
62
|
const findings = [];
|
|
61
63
|
const lines = code.split("\n");
|
|
62
64
|
const suppressions = parseSuppressionsFromCode(lines);
|
|
@@ -67,12 +69,31 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
67
69
|
// Config: skip disabled rules
|
|
68
70
|
if (config.rules.disable.includes(rule.id))
|
|
69
71
|
continue;
|
|
72
|
+
// .guardvibeignore: skip rules for matching file patterns
|
|
73
|
+
if (isIgnored(ignoreEntries, rule.id, filePath))
|
|
74
|
+
continue;
|
|
70
75
|
// Skip CI/CD rules: when filePath is given, require .github/workflows path.
|
|
71
76
|
// When no filePath (MCP call), allow if language is yaml.
|
|
72
77
|
if (rule.id.startsWith("VG21") && filePath && !filePath.includes(".github/workflows"))
|
|
73
78
|
continue;
|
|
74
79
|
if (rule.id.startsWith("VG21") && !filePath && language !== "yaml")
|
|
75
80
|
continue;
|
|
81
|
+
// Context-aware: skip auth rules for webhook routes that have signature verification
|
|
82
|
+
const isWebhookRoute = filePath && /webhook/i.test(filePath);
|
|
83
|
+
const hasSignatureVerification = isWebhookRoute && /(?:verify|signature|hmac|constructEvent|svix|webhookSecret|createHmac|X-Signature|stripe-signature)/i.test(code);
|
|
84
|
+
const authRuleIds = new Set(["VG420", "VG952", "VG002"]);
|
|
85
|
+
if (hasSignatureVerification && authRuleIds.has(rule.id))
|
|
86
|
+
continue;
|
|
87
|
+
// Context-aware: skip rate limiting rules for cron/scheduled routes
|
|
88
|
+
const isCronRoute = filePath && /(?:cron|scheduled|jobs?)\//i.test(filePath);
|
|
89
|
+
const rateLimitRuleIds = new Set(["VG956", "VG030"]);
|
|
90
|
+
if (isCronRoute && rateLimitRuleIds.has(rule.id))
|
|
91
|
+
continue;
|
|
92
|
+
// Context-aware: skip rate limiting rules for admin routes that have admin auth
|
|
93
|
+
const isAdminRoute = filePath && /\/admin\//i.test(filePath);
|
|
94
|
+
const hasAdminAuth = isAdminRoute && /(?:requireAdmin|adminOnly|orgRole|org:admin|isAdmin|checkRole|requireRole)/i.test(code);
|
|
95
|
+
if (hasAdminAuth && rateLimitRuleIds.has(rule.id))
|
|
96
|
+
continue;
|
|
76
97
|
// Skip npm package rules (VG863/VG864/VG865): only apply to package.json files
|
|
77
98
|
if ((rule.id === "VG863" || rule.id === "VG864" || rule.id === "VG865") && filePath && !filePath.endsWith("package.json"))
|
|
78
99
|
continue;
|
|
@@ -94,9 +115,24 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
94
115
|
}
|
|
95
116
|
rule.pattern.lastIndex = 0;
|
|
96
117
|
// Apply severity override from config
|
|
97
|
-
|
|
118
|
+
let effectiveRule = config.rules.severity[rule.id]
|
|
98
119
|
? { ...rule, severity: config.rules.severity[rule.id] }
|
|
99
120
|
: rule;
|
|
121
|
+
// Context-aware severity: downgrade rate limiting/pagination issues in admin routes
|
|
122
|
+
// Admin routes behind requireAdmin have lower brute-force risk
|
|
123
|
+
if (isAdminRoute && hasAdminAuth) {
|
|
124
|
+
const downgradeInAdmin = new Set(["VG955"]); // pagination in admin is less critical
|
|
125
|
+
if (downgradeInAdmin.has(rule.id) && effectiveRule.severity === "medium") {
|
|
126
|
+
effectiveRule = { ...effectiveRule, severity: "low" };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Context-aware severity: downgrade auth warnings in internal/cron routes
|
|
130
|
+
if (isCronRoute) {
|
|
131
|
+
const downgradeInCron = new Set(["VG420", "VG952"]); // cron routes don't need user auth
|
|
132
|
+
if (downgradeInCron.has(rule.id)) {
|
|
133
|
+
effectiveRule = { ...effectiveRule, severity: "low" };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
100
136
|
let match;
|
|
101
137
|
while ((match = rule.pattern.exec(code)) !== null) {
|
|
102
138
|
const beforeMatch = code.substring(0, match.index);
|
|
@@ -121,7 +157,99 @@ export function analyzeCode(code, language, framework, filePath, configDir, rule
|
|
|
121
157
|
});
|
|
122
158
|
}
|
|
123
159
|
}
|
|
124
|
-
|
|
160
|
+
// Deduplicate: if two rules match the same line, keep the more specific one.
|
|
161
|
+
// More specific = longer rule ID prefix match (e.g. VG408 nextjs > VG012 core)
|
|
162
|
+
// or framework-specific rule > generic rule on the same line.
|
|
163
|
+
const deduped = deduplicateFindings(findings);
|
|
164
|
+
return deduped;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Remove duplicate findings where two rules flag the same line for the same issue.
|
|
168
|
+
* Prefers framework-specific rules (VG4xx, VG9xx) over generic core rules (VG0xx).
|
|
169
|
+
*/
|
|
170
|
+
function deduplicateFindings(findings) {
|
|
171
|
+
// Group findings by line number
|
|
172
|
+
const byLine = new Map();
|
|
173
|
+
for (const f of findings) {
|
|
174
|
+
const group = byLine.get(f.line);
|
|
175
|
+
if (group)
|
|
176
|
+
group.push(f);
|
|
177
|
+
else
|
|
178
|
+
byLine.set(f.line, [f]);
|
|
179
|
+
}
|
|
180
|
+
const result = [];
|
|
181
|
+
for (const group of byLine.values()) {
|
|
182
|
+
if (group.length <= 1) {
|
|
183
|
+
result.push(...group);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Check for overlapping rules on the same line
|
|
187
|
+
const kept = new Set();
|
|
188
|
+
for (let i = 0; i < group.length; i++) {
|
|
189
|
+
let dominated = false;
|
|
190
|
+
for (let j = 0; j < group.length; j++) {
|
|
191
|
+
if (i === j)
|
|
192
|
+
continue;
|
|
193
|
+
if (isDuplicatePair(group[i], group[j])) {
|
|
194
|
+
// Keep the more specific rule (higher rule ID prefix = more specific)
|
|
195
|
+
if (isMoreSpecific(group[j], group[i])) {
|
|
196
|
+
dominated = true;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!dominated)
|
|
202
|
+
kept.add(i);
|
|
203
|
+
}
|
|
204
|
+
for (const idx of kept)
|
|
205
|
+
result.push(group[idx]);
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
/** Check if two findings on the same line are duplicates (same vulnerability class). */
|
|
210
|
+
function isDuplicatePair(a, b) {
|
|
211
|
+
// Same rule name = same vulnerability
|
|
212
|
+
if (a.rule.name === b.rule.name)
|
|
213
|
+
return true;
|
|
214
|
+
// Both are XSS/innerHTML related — the core VG012+VG408 duplicate case
|
|
215
|
+
if (a.rule.name.includes("innerHTML") && b.rule.name.includes("innerHTML"))
|
|
216
|
+
return true;
|
|
217
|
+
if (a.rule.name.includes("XSS via innerHTML") && b.rule.name.includes("Unsafe innerHTML"))
|
|
218
|
+
return true;
|
|
219
|
+
if (a.rule.name.includes("Unsafe innerHTML") && b.rule.name.includes("XSS via innerHTML"))
|
|
220
|
+
return true;
|
|
221
|
+
// Both are auth/unprotected route rules — VG420+VG952+VG002 duplicate case
|
|
222
|
+
const authPatterns = ["Unprotected Route", "Without Authentication", "Missing authentication"];
|
|
223
|
+
const aIsAuth = authPatterns.some(p => a.rule.name.includes(p));
|
|
224
|
+
const bIsAuth = authPatterns.some(p => b.rule.name.includes(p));
|
|
225
|
+
if (aIsAuth && bIsAuth)
|
|
226
|
+
return true;
|
|
227
|
+
// Both are CORS wildcard rules — VG040+VG403+VG973 duplicate case
|
|
228
|
+
const aIsCors = a.rule.name.includes("CORS") && a.rule.name.includes("ildcard");
|
|
229
|
+
const bIsCors = b.rule.name.includes("CORS") && b.rule.name.includes("ildcard");
|
|
230
|
+
if (aIsCors && bIsCors)
|
|
231
|
+
return true;
|
|
232
|
+
// Both are admin role check rules — VG426+VG957 duplicate case
|
|
233
|
+
const adminPatterns = ["Admin", "Role Check", "Role Verification"];
|
|
234
|
+
const aIsAdmin = adminPatterns.some(p => a.rule.name.includes(p));
|
|
235
|
+
const bIsAdmin = adminPatterns.some(p => b.rule.name.includes(p));
|
|
236
|
+
if (aIsAdmin && bIsAdmin)
|
|
237
|
+
return true;
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
/** Check if rule A is more specific than rule B (framework rules > core rules). */
|
|
241
|
+
function isMoreSpecific(a, b) {
|
|
242
|
+
const prefixOrder = (id) => {
|
|
243
|
+
const num = parseInt(id.replace("VG", ""), 10);
|
|
244
|
+
if (num >= 400 && num < 500)
|
|
245
|
+
return 3; // nextjs-specific
|
|
246
|
+
if (num >= 900)
|
|
247
|
+
return 2; // api-security / cve
|
|
248
|
+
if (num >= 100)
|
|
249
|
+
return 1; // category-specific
|
|
250
|
+
return 0; // core rules VG0xx
|
|
251
|
+
};
|
|
252
|
+
return prefixOrder(a.rule.id) > prefixOrder(b.rule.id);
|
|
125
253
|
}
|
|
126
254
|
export function formatFindingsJson(findings, extra) {
|
|
127
255
|
const critical = findings.filter(f => f.rule.severity === "critical").length;
|
|
@@ -43,9 +43,10 @@ function detectLanguage(filePath) {
|
|
|
43
43
|
return ext ? extensionMap[ext] ?? null : null;
|
|
44
44
|
}
|
|
45
45
|
function calculateScore(critical, high, medium, fileCount = 1) {
|
|
46
|
-
|
|
46
|
+
// Calibrated: medium issues are informational (0.5 weight), high issues are real (5x), critical are severe (15x)
|
|
47
|
+
const weighted = critical * 15 + high * 5 + medium * 0.5;
|
|
47
48
|
const density = weighted / Math.max(fileCount, 1);
|
|
48
|
-
return Math.max(0, Math.min(100, Math.round(100 - density * 20)));
|
|
49
|
+
return Math.max(0, Math.min(100, Math.round(100 - Math.min(density, 5) * 20)));
|
|
49
50
|
}
|
|
50
51
|
function scoreToGrade(score) {
|
|
51
52
|
if (score >= 90)
|
|
@@ -102,21 +103,31 @@ export function checkProject(files, format = "markdown", rules) {
|
|
|
102
103
|
if (totalMedium > 0)
|
|
103
104
|
lines.push(`| Medium | ${totalMedium} |`);
|
|
104
105
|
lines.push(``);
|
|
105
|
-
// Top
|
|
106
|
+
// Top 5 Action Items — grouped by rule, sorted by severity, with file counts
|
|
106
107
|
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
108
|
+
const ruleGroups = new Map();
|
|
109
|
+
for (const r of results) {
|
|
110
|
+
for (const f of r.findings) {
|
|
111
|
+
const existing = ruleGroups.get(f.rule.id);
|
|
112
|
+
if (existing) {
|
|
113
|
+
existing.files.add(r.path);
|
|
114
|
+
existing.count++;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
ruleGroups.set(f.rule.id, { rule: f.rule, files: new Set([r.path]), count: 1 });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const actionItems = Array.from(ruleGroups.values())
|
|
122
|
+
.sort((a, b) => (severityOrder[a.rule.severity] ?? 99) - (severityOrder[b.rule.severity] ?? 99))
|
|
123
|
+
.slice(0, 5);
|
|
124
|
+
if (actionItems.length > 0) {
|
|
125
|
+
lines.push(`## Top 5 Action Items`, ``);
|
|
126
|
+
actionItems.forEach((item, i) => {
|
|
127
|
+
const fileCount = item.files.size;
|
|
128
|
+
const fileLabel = fileCount === 1 ? "1 file" : `${fileCount} files`;
|
|
129
|
+
lines.push(`${i + 1}. **[${item.rule.severity.toUpperCase()}] ${item.rule.name}** (${item.rule.id}) — ${item.count} occurrences in ${fileLabel}`, ` ${item.rule.fix}`, ``);
|
|
118
130
|
});
|
|
119
|
-
lines.push(``);
|
|
120
131
|
}
|
|
121
132
|
lines.push(`---`, ``);
|
|
122
133
|
// Per-file details
|
|
@@ -98,13 +98,15 @@ export function scanDirectory(path, recursive = true, exclude = [], format = "ma
|
|
|
98
98
|
const totalHigh = allFindings.filter(f => f.rule.severity === "high").length;
|
|
99
99
|
const totalMedium = allFindings.filter(f => f.rule.severity === "medium").length;
|
|
100
100
|
const totalIssues = totalCritical + totalHigh + totalMedium;
|
|
101
|
-
//
|
|
102
|
-
//
|
|
101
|
+
// Density-based scoring calibrated against real Next.js projects.
|
|
102
|
+
// A clean Next.js project with ~200 medium findings in ~800 files should score ~B.
|
|
103
|
+
// Critical issues have the most impact; medium issues are informational.
|
|
103
104
|
const filesScanned = metadata.filesScanned || 1;
|
|
104
|
-
const weightedIssues = totalCritical *
|
|
105
|
+
const weightedIssues = totalCritical * 15 + totalHigh * 5 + totalMedium * 0.5;
|
|
105
106
|
const density = weightedIssues / filesScanned;
|
|
106
|
-
// density 0 = 100,
|
|
107
|
-
|
|
107
|
+
// density 0 = 100, uses log scale so medium findings don't dominate
|
|
108
|
+
// density 0.5 ≈ 85 (B), density 2.0 ≈ 60 (C), density 5.0 ≈ 30 (D)
|
|
109
|
+
const score = Math.max(0, Math.min(100, Math.round(100 - Math.min(density, 5) * 20)));
|
|
108
110
|
const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
|
|
109
111
|
// Baseline comparison
|
|
110
112
|
let baselineDiff = null;
|
|
@@ -196,14 +198,31 @@ export function scanDirectory(path, recursive = true, exclude = [], format = "ma
|
|
|
196
198
|
if (totalMedium > 0)
|
|
197
199
|
lines.push(`| Medium | ${totalMedium} |`);
|
|
198
200
|
lines.push(``);
|
|
201
|
+
// Top 5 Action Items — grouped by rule, sorted by severity, with file counts
|
|
199
202
|
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
const ruleGroups = new Map();
|
|
204
|
+
for (const r of scanResults) {
|
|
205
|
+
for (const f of r.findings) {
|
|
206
|
+
const existing = ruleGroups.get(f.rule.id);
|
|
207
|
+
if (existing) {
|
|
208
|
+
existing.files.add(r.path);
|
|
209
|
+
existing.count++;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
ruleGroups.set(f.rule.id, { rule: f.rule, files: new Set([r.path]), count: 1 });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const actionItems = Array.from(ruleGroups.values())
|
|
217
|
+
.sort((a, b) => (severityOrder[a.rule.severity] ?? 99) - (severityOrder[b.rule.severity] ?? 99))
|
|
218
|
+
.slice(0, 5);
|
|
219
|
+
lines.push(`## Top 5 Action Items`, ``);
|
|
220
|
+
actionItems.forEach((item, i) => {
|
|
221
|
+
const fileCount = item.files.size;
|
|
222
|
+
const fileLabel = fileCount === 1 ? "1 file" : `${fileCount} files`;
|
|
223
|
+
lines.push(`${i + 1}. **[${item.rule.severity.toUpperCase()}] ${item.rule.name}** (${item.rule.id}) — ${item.count} occurrences in ${fileLabel}`, ` ${item.rule.fix}`, ``);
|
|
224
|
+
});
|
|
225
|
+
lines.push(`---`, ``);
|
|
207
226
|
for (const result of scanResults) {
|
|
208
227
|
lines.push(`## File: ${result.path} (${result.findings.length} issues)`, ``);
|
|
209
228
|
for (const f of result.findings) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface IgnoreEntry {
|
|
2
|
+
ruleId: string;
|
|
3
|
+
filePattern: string | null;
|
|
4
|
+
}
|
|
5
|
+
export declare function loadIgnoreFile(dir: string): IgnoreEntry[];
|
|
6
|
+
/**
|
|
7
|
+
* Check if a rule should be ignored for a given file path.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isIgnored(entries: IgnoreEntry[], ruleId: string, filePath?: string): boolean;
|
|
10
|
+
export declare function resetIgnoreCache(): void;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// .guardvibeignore file support.
|
|
2
|
+
//
|
|
3
|
+
// Format (one entry per line):
|
|
4
|
+
// VG012 — ignore VG012 in all files
|
|
5
|
+
// VG420:src/app/api/webhook/* — ignore VG420 in webhook routes
|
|
6
|
+
// VG956:**/admin/** — ignore VG956 in admin paths
|
|
7
|
+
// # comment lines
|
|
8
|
+
//
|
|
9
|
+
// Supports simple glob matching: * matches any segment, ** matches any depth.
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
let ignoreCache = new Map();
|
|
13
|
+
export function loadIgnoreFile(dir) {
|
|
14
|
+
const cached = ignoreCache.get(dir);
|
|
15
|
+
if (cached)
|
|
16
|
+
return cached;
|
|
17
|
+
const ignorePath = join(dir, ".guardvibeignore");
|
|
18
|
+
let entries = [];
|
|
19
|
+
try {
|
|
20
|
+
const content = readFileSync(ignorePath, "utf-8");
|
|
21
|
+
const lines = content.split("\n");
|
|
22
|
+
for (const raw of lines) {
|
|
23
|
+
const line = raw.trim();
|
|
24
|
+
if (!line || line.startsWith("#"))
|
|
25
|
+
continue;
|
|
26
|
+
const colonIdx = line.indexOf(":");
|
|
27
|
+
if (colonIdx > 0 && line.startsWith("VG")) {
|
|
28
|
+
const ruleId = line.substring(0, colonIdx);
|
|
29
|
+
const filePattern = line.substring(colonIdx + 1).trim();
|
|
30
|
+
entries.push({ ruleId, filePattern: filePattern || null });
|
|
31
|
+
}
|
|
32
|
+
else if (line.startsWith("VG")) {
|
|
33
|
+
entries.push({ ruleId: line, filePattern: null });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// No .guardvibeignore file — that's fine
|
|
39
|
+
}
|
|
40
|
+
ignoreCache.set(dir, entries);
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if a rule should be ignored for a given file path.
|
|
45
|
+
*/
|
|
46
|
+
export function isIgnored(entries, ruleId, filePath) {
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.ruleId !== ruleId)
|
|
49
|
+
continue;
|
|
50
|
+
// No file pattern = ignore everywhere
|
|
51
|
+
if (!entry.filePattern)
|
|
52
|
+
return true;
|
|
53
|
+
// Match file pattern
|
|
54
|
+
if (filePath && matchGlob(entry.filePattern, filePath))
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Simple glob matcher: * matches non-slash chars, ** matches anything including slashes.
|
|
61
|
+
*/
|
|
62
|
+
function matchGlob(pattern, path) {
|
|
63
|
+
// Normalize
|
|
64
|
+
const normalizedPath = path.replace(/\\/g, "/");
|
|
65
|
+
// Convert glob to regex
|
|
66
|
+
let regexStr = "";
|
|
67
|
+
let i = 0;
|
|
68
|
+
while (i < pattern.length) {
|
|
69
|
+
if (pattern[i] === "*" && pattern[i + 1] === "*") {
|
|
70
|
+
regexStr += ".*";
|
|
71
|
+
i += 2;
|
|
72
|
+
if (pattern[i] === "/")
|
|
73
|
+
i++; // skip trailing slash after **
|
|
74
|
+
}
|
|
75
|
+
else if (pattern[i] === "*") {
|
|
76
|
+
regexStr += "[^/]*";
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
else if (pattern[i] === "?") {
|
|
80
|
+
regexStr += "[^/]";
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
else if (".+^${}()|[]\\".includes(pattern[i])) {
|
|
84
|
+
regexStr += "\\" + pattern[i];
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
regexStr += pattern[i];
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return new RegExp(regexStr).test(normalizedPath);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function resetIgnoreCache() {
|
|
100
|
+
ignoreCache.clear();
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardvibe",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Security MCP for vibe coding. 277 rules,
|
|
3
|
+
"version": "1.9.1",
|
|
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": {
|
|
7
7
|
"guardvibe": "build/cli.js",
|