guardvibe 3.0.48 → 3.0.50

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.
@@ -16,11 +16,12 @@ export async function runAudit(args) {
16
16
  const failOn = getStringFlag(flags, "fail-on") ?? "critical";
17
17
  const skipDeps = flags["skip-deps"] === true;
18
18
  const skipSecrets = flags["skip-secrets"] === true;
19
+ const full = flags["full"] === true;
19
20
  setRules(builtinRules);
20
21
  // Terminal format by default when outputting to TTY, unless --format is specified
21
22
  const isTerminal = !outputFile && process.stdout.isTTY && !flags["format"];
22
23
  const format = isTerminal ? "terminal" : rawFormat;
23
- const result = await runFullAudit(targetPath, { skipDeps, skipSecrets });
24
+ const result = await runFullAudit(targetPath, { skipDeps, skipSecrets, full });
24
25
  const output = formatAuditResult(result, format);
25
26
  // For shouldFail, always use JSON-parseable format
26
27
  const failCheckOutput = formatAuditResult(result, "json");
package/build/cli/scan.js CHANGED
@@ -46,6 +46,7 @@ export async function runDirectoryScan(targetPath, flags) {
46
46
  const outputFile = getOutputPath(flags);
47
47
  const baselinePath = getStringFlag(flags, "baseline");
48
48
  const saveBaseline = flags["save-baseline"] === true || typeof flags["save-baseline"] === "string";
49
+ const full = flags["full"] === true;
49
50
  const scanPath = resolve(targetPath);
50
51
  let result;
51
52
  if (format === "sarif") {
@@ -53,7 +54,7 @@ export async function runDirectoryScan(targetPath, flags) {
53
54
  result = exportSarif(scanPath);
54
55
  }
55
56
  else {
56
- result = scanDirectory(scanPath, true, [], format === "json" ? "json" : "markdown", undefined, baselinePath ?? undefined);
57
+ result = scanDirectory(scanPath, true, [], format === "json" ? "json" : "markdown", undefined, baselinePath ?? undefined, full);
57
58
  }
58
59
  if (outputFile) {
59
60
  safeWriteOutput(outputFile, result);
@@ -1,4 +1,6 @@
1
1
  import { analyzeCode, formatFindingsJson } from "./check-code.js";
2
+ import { calculateScore, scoreToGrade } from "../utils/scoring.js";
3
+ import { loadConfig } from "../utils/config.js";
2
4
  const extensionMap = {
3
5
  ".js": "javascript",
4
6
  ".jsx": "javascript",
@@ -42,29 +44,6 @@ function detectLanguage(filePath) {
42
44
  const ext = filePath.match(/\.[^.]+$/)?.[0]?.toLowerCase();
43
45
  return ext ? extensionMap[ext] ?? null : null;
44
46
  }
45
- function calculateScore(critical, high, medium, fileCount = 1) {
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;
48
- const density = weighted / Math.max(fileCount, 1);
49
- let score = Math.max(0, Math.min(100, Math.round(100 - Math.min(density, 5) * 20)));
50
- // Severity caps: CRITICAL findings can never get A/B, HIGH can never get A
51
- if (critical > 0)
52
- score = Math.min(score, 60); // cap at C
53
- if (high > 0)
54
- score = Math.min(score, 75); // cap at B
55
- return score;
56
- }
57
- function scoreToGrade(score) {
58
- if (score >= 90)
59
- return "A";
60
- if (score >= 75)
61
- return "B";
62
- if (score >= 60)
63
- return "C";
64
- if (score >= 40)
65
- return "D";
66
- return "F";
67
- }
68
47
  export function checkProject(files, format = "markdown", rules) {
69
48
  const results = [];
70
49
  const skippedFiles = [];
@@ -85,7 +64,10 @@ export function checkProject(files, format = "markdown", rules) {
85
64
  const totalHigh = allFindings.filter((f) => f.rule.severity === "high").length;
86
65
  const totalMedium = allFindings.filter((f) => f.rule.severity === "medium").length;
87
66
  const totalIssues = totalCritical + totalHigh + totalMedium;
88
- const score = calculateScore(totalCritical, totalHigh, totalMedium, scannedCount);
67
+ const config = loadConfig();
68
+ const score = calculateScore(totalCritical, totalHigh, totalMedium, scannedCount, {
69
+ densityModel: config.scoring?.densityModel,
70
+ });
89
71
  const grade = scoreToGrade(score);
90
72
  if (format === "json") {
91
73
  return formatFindingsJson(allFindings, { grade, score });
@@ -89,6 +89,7 @@ export declare function computeResultHash(findings: FindingRef[]): string;
89
89
  export declare function runFullAudit(path: string, options?: {
90
90
  skipDeps?: boolean;
91
91
  skipSecrets?: boolean;
92
+ full?: boolean;
92
93
  }): Promise<AuditResult>;
93
94
  /**
94
95
  * Format audit result as markdown, JSON, or terminal-friendly output.
@@ -72,6 +72,7 @@ function collectJsFiles(dir, maxFiles = 200) {
72
72
  "node_modules", ".git", ".next", "build", "dist", ".turbo", "coverage",
73
73
  ...config.scan.exclude,
74
74
  ]);
75
+ // `maxFiles = Infinity` is the contract for full mode (CLI --full flag): scan everything.
75
76
  function walk(d) {
76
77
  if (files.length >= maxFiles)
77
78
  return;
@@ -124,19 +125,20 @@ export async function runFullAudit(path, options) {
124
125
  const rules = getRules();
125
126
  const allFindings = [];
126
127
  const sections = [];
128
+ const full = options?.full === true;
127
129
  let filesScanned = 0;
128
130
  let filesSkipped = 0;
129
131
  let score = 100;
130
132
  let grade = "A";
131
- // Truncation tracking
133
+ // Truncation tracking — `full` mode (CLI --full flag) bypasses caps for local debugging.
132
134
  let scanTruncated = false;
133
135
  let scanTotalFindings = 0;
134
- let scanMaxFindings = 50; // MAX_JSON_FINDINGS from scan-directory
136
+ let scanMaxFindings = full ? Number.POSITIVE_INFINITY : 50;
135
137
  let taintFilesProcessed = 0;
136
- const taintFileCap = 200;
138
+ const taintFileCap = full ? Number.POSITIVE_INFINITY : 200;
137
139
  // --- Section 1: Code scan ---
138
140
  try {
139
- const codeJson = scanDirectory(projectRoot, true, [], "json", rules.length > 0 ? rules : undefined);
141
+ const codeJson = scanDirectory(projectRoot, true, [], "json", rules.length > 0 ? rules : undefined, undefined, full);
140
142
  const parsed = safeJsonParse(codeJson);
141
143
  if (parsed) {
142
144
  const counts = parseSectionCounts(parsed);
@@ -296,7 +298,7 @@ export async function runFullAudit(path, options) {
296
298
  }
297
299
  // --- Section 5: Taint analysis ---
298
300
  try {
299
- const jsFiles = collectJsFiles(projectRoot);
301
+ const jsFiles = collectJsFiles(projectRoot, taintFileCap);
300
302
  taintFilesProcessed = jsFiles.length;
301
303
  if (jsFiles.length > 0) {
302
304
  const { crossFileFindings, perFileFindings } = analyzeCrossFileTaint(jsFiles);
@@ -340,7 +342,7 @@ export async function runFullAudit(path, options) {
340
342
  }
341
343
  // --- Section 6: Auth coverage ---
342
344
  try {
343
- const jsFiles = collectJsFiles(projectRoot);
345
+ const jsFiles = collectJsFiles(projectRoot, taintFileCap);
344
346
  const routeFiles = jsFiles.filter(f => /\/(route|page)\.(ts|tsx|js|jsx)$/.test(f.path));
345
347
  const layoutFiles = jsFiles.filter(f => /\/layout\.(ts|tsx|js|jsx)$/.test(f.path));
346
348
  if (routeFiles.length > 0) {
@@ -1,2 +1,2 @@
1
1
  import type { SecurityRule } from "../data/rules/types.js";
2
- export declare function scanDirectory(path: string, recursive?: boolean, exclude?: string[], format?: "markdown" | "json", rules?: SecurityRule[], baselinePath?: string): string;
2
+ export declare function scanDirectory(path: string, recursive?: boolean, exclude?: string[], format?: "markdown" | "json", rules?: SecurityRule[], baselinePath?: string, full?: boolean): string;
@@ -7,6 +7,7 @@ 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
9
  import { securityBanner } from "../utils/banner.js";
10
+ import { calculateScore, scoreToGrade } from "../utils/scoring.js";
10
11
  const require = createRequire(import.meta.url);
11
12
  const pkg = require("../../package.json");
12
13
  // GuardVibe version — used in scan metadata
@@ -41,7 +42,7 @@ function computeBaselineDiff(current, previous) {
41
42
  unchanged: current.filter(e => prevSet.has(currKey(e))),
42
43
  };
43
44
  }
44
- export function scanDirectory(path, recursive = true, exclude = [], format = "markdown", rules, baselinePath) {
45
+ export function scanDirectory(path, recursive = true, exclude = [], format = "markdown", rules, baselinePath, full = false) {
45
46
  const startTime = performance.now();
46
47
  const scanId = randomUUID();
47
48
  const scanRoot = resolve(path);
@@ -110,17 +111,11 @@ export function scanDirectory(path, recursive = true, exclude = [], format = "ma
110
111
  const totalHigh = allFindings.filter(f => f.rule.severity === "high").length;
111
112
  const totalMedium = allFindings.filter(f => f.rule.severity === "medium").length;
112
113
  const totalIssues = totalCritical + totalHigh + totalMedium;
113
- // Density-based scoring calibrated against real Next.js projects.
114
- // A clean Next.js project with ~200 medium findings in ~800 files should score ~B.
115
- // Critical issues have the most impact; medium issues are informational.
116
114
  const filesScanned = metadata.filesScanned || 1;
117
- const weightedIssues = totalCritical * 15 + totalHigh * 5 + totalMedium * 0.5;
118
- const density = weightedIssues / filesScanned;
119
- // density 0 = 100, uses log scale so medium findings don't dominate
120
- // density 0.5 ≈ 85 (B), density 2.0 ≈ 60 (C), density 5.0 ≈ 30 (D)
121
- const score = Math.max(0, Math.min(100, Math.round(100 - Math.min(density, 5) * 20)));
122
- // Grade boundaries match full-audit so the section sub-grade and overall verdict agree.
123
- const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 50 ? "C" : score >= 25 ? "D" : "F";
115
+ const score = calculateScore(totalCritical, totalHigh, totalMedium, filesScanned, {
116
+ densityModel: config.scoring?.densityModel,
117
+ });
118
+ const grade = scoreToGrade(score);
124
119
  // Baseline comparison
125
120
  let baselineDiff = null;
126
121
  let previousBaseline = null;
@@ -141,8 +136,10 @@ export function scanDirectory(path, recursive = true, exclude = [], format = "ma
141
136
  }
142
137
  // MCP output size limit — large projects can produce 300K+ characters which
143
138
  // exceeds Claude Code's max allowed tokens for tool results.
144
- const MAX_JSON_FINDINGS = 50;
145
- const MAX_MD_FINDINGS = 30;
139
+ // `full=true` (CLI --full flag) bypasses these caps for local debugging where
140
+ // size budget doesn't apply.
141
+ const MAX_JSON_FINDINGS = full ? Number.POSITIVE_INFINITY : 50;
142
+ const MAX_MD_FINDINGS = full ? Number.POSITIVE_INFINITY : 30;
146
143
  if (format === "json") {
147
144
  const findingsWithFiles = scanResults.flatMap(r => r.findings.map(f => ({ ...f, rule: f.rule, file: r.path })));
148
145
  // Sort by severity: critical first, then high, then medium
@@ -33,6 +33,14 @@ export interface GuardVibeConfig {
33
33
  path: string;
34
34
  reason: string;
35
35
  }>;
36
+ /** Score calculation tweaks. Default density model is "linear" (matches
37
+ * pre-v3.0.50 behavior). Set to "exponential" for smoother decay past
38
+ * density 5 — projects with many medium findings will see slightly higher
39
+ * scores under exponential, projects with concentrated criticals will see
40
+ * lower. CI gates set on absolute score should keep the default. */
41
+ scoring?: {
42
+ densityModel?: "linear" | "exponential";
43
+ };
36
44
  }
37
45
  export declare function loadConfig(dir?: string): GuardVibeConfig;
38
46
  export declare function resetConfigCache(): void;
@@ -69,6 +69,9 @@ export function loadConfig(dir) {
69
69
  } : undefined,
70
70
  authFunctions: Array.isArray(parsed.authFunctions) ? parsed.authFunctions : undefined,
71
71
  authExceptions: Array.isArray(parsed.authExceptions) ? parsed.authExceptions : undefined,
72
+ scoring: parsed.scoring && typeof parsed.scoring === "object" ? {
73
+ densityModel: parsed.scoring.densityModel === "exponential" ? "exponential" : "linear",
74
+ } : undefined,
72
75
  };
73
76
  }
74
77
  catch { }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Single source of truth for security score / grade calculation.
3
+ *
4
+ * Goals:
5
+ * 1. Same finding counts produce the same score across scan-directory,
6
+ * check-project, full-audit, and CLI output.
7
+ * 2. Severity caps so a critical finding cannot ever look like a clean run
8
+ * (1+ critical → max C/60, 1+ high → max B/75).
9
+ * 3. Optional exponential density decay (`scoring.densityModel: "exponential"`
10
+ * in .guardviberc) for projects that want resolution past density 5
11
+ * instead of the linear cliff.
12
+ *
13
+ * Default density formula stays linear so existing CI thresholds don't shift.
14
+ */
15
+ export type DensityModel = "linear" | "exponential";
16
+ export interface ScoreOptions {
17
+ /** Density curve. "linear" (default) is `100 - min(density, 5) * 20`.
18
+ * "exponential" is `100 * exp(-density / 3)` — smoother, no cliff. */
19
+ densityModel?: DensityModel;
20
+ }
21
+ /** Severity caps applied AFTER the density-derived score. */
22
+ export declare const CRITICAL_SCORE_CAP = 60;
23
+ export declare const HIGH_SCORE_CAP = 75;
24
+ export declare function calculateScore(critical: number, high: number, medium: number, fileCount?: number, options?: ScoreOptions): number;
25
+ export declare function scoreToGrade(score: number): string;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Single source of truth for security score / grade calculation.
3
+ *
4
+ * Goals:
5
+ * 1. Same finding counts produce the same score across scan-directory,
6
+ * check-project, full-audit, and CLI output.
7
+ * 2. Severity caps so a critical finding cannot ever look like a clean run
8
+ * (1+ critical → max C/60, 1+ high → max B/75).
9
+ * 3. Optional exponential density decay (`scoring.densityModel: "exponential"`
10
+ * in .guardviberc) for projects that want resolution past density 5
11
+ * instead of the linear cliff.
12
+ *
13
+ * Default density formula stays linear so existing CI thresholds don't shift.
14
+ */
15
+ /** Severity caps applied AFTER the density-derived score. */
16
+ export const CRITICAL_SCORE_CAP = 60; // 1+ critical → cannot exceed C
17
+ export const HIGH_SCORE_CAP = 75; // 1+ high → cannot exceed B
18
+ /** Severity weights for density. Calibrated against real Next.js projects:
19
+ * a clean Next.js app with ~200 medium findings in ~800 files lands near B. */
20
+ const WEIGHT_CRITICAL = 15;
21
+ const WEIGHT_HIGH = 5;
22
+ const WEIGHT_MEDIUM = 0.5;
23
+ /** Exponential decay constant — density = 3 produces ~37 (D). */
24
+ const EXPONENTIAL_K = 3;
25
+ export function calculateScore(critical, high, medium, fileCount = 1, options) {
26
+ const weighted = critical * WEIGHT_CRITICAL + high * WEIGHT_HIGH + medium * WEIGHT_MEDIUM;
27
+ const density = weighted / Math.max(fileCount, 1);
28
+ let score;
29
+ if (options?.densityModel === "exponential") {
30
+ score = Math.round(100 * Math.exp(-density / EXPONENTIAL_K));
31
+ }
32
+ else {
33
+ score = Math.round(100 - Math.min(density, 5) * 20);
34
+ }
35
+ score = Math.max(0, Math.min(100, score));
36
+ if (critical > 0)
37
+ score = Math.min(score, CRITICAL_SCORE_CAP);
38
+ if (high > 0)
39
+ score = Math.min(score, HIGH_SCORE_CAP);
40
+ return score;
41
+ }
42
+ export function scoreToGrade(score) {
43
+ if (score >= 90)
44
+ return "A";
45
+ if (score >= 75)
46
+ return "B";
47
+ if (score >= 50)
48
+ return "C";
49
+ if (score >= 25)
50
+ return "D";
51
+ return "F";
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "3.0.48",
3
+ "version": "3.0.50",
4
4
  "mcpName": "io.github.goklab/guardvibe",
5
5
  "description": "Security MCP for vibe coding. 365 rules, 36 tools, CLI + doctor. Host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. Plus Next.js, Supabase, Clerk, Stripe, Prisma, tRPC, Hono, GraphQL, Convex, Turso, Uploadthing, AI SDK, and the full AI-generated stack.",
6
6
  "type": "module",