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.
- package/build/cli/audit.js +2 -1
- package/build/cli/scan.js +2 -1
- package/build/tools/check-project.js +6 -24
- package/build/tools/full-audit.d.ts +1 -0
- package/build/tools/full-audit.js +8 -6
- package/build/tools/scan-directory.d.ts +1 -1
- package/build/tools/scan-directory.js +10 -13
- package/build/utils/config.d.ts +8 -0
- package/build/utils/config.js +3 -0
- package/build/utils/scoring.d.ts +25 -0
- package/build/utils/scoring.js +52 -0
- package/package.json +1 -1
package/build/cli/audit.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
package/build/utils/config.d.ts
CHANGED
|
@@ -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;
|
package/build/utils/config.js
CHANGED
|
@@ -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.
|
|
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",
|