guardvibe 3.7.0 → 3.9.0
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/CHANGELOG.md +20 -0
- package/README.md +2 -2
- package/build/cli/scan.js +1 -1
- package/build/index.js +17 -7
- package/build/tools/auth-coverage.d.ts +6 -3
- package/build/tools/auth-coverage.js +111 -27
- package/build/tools/scan-staged.d.ts +3 -1
- package/build/tools/scan-staged.js +17 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ All notable changes to GuardVibe are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.9.0] - 2026-06-07
|
|
9
|
+
|
|
10
|
+
### Changed — diff-aware is now the default across every gating surface (441 rules / 37 tools)
|
|
11
|
+
- FAZ 2a made `guardvibe diff` diff-aware; this extends it to the surfaces that actually gate commits and PRs:
|
|
12
|
+
- **Pre-commit (`scan_staged` / `guardvibe-scan`)** now reports only findings on **newly-staged lines** by default — the hook blocks what you just wrote, not pre-existing debt in a file you touched. Opt out with `--all-lines` (CLI) or `diff_aware:false` (MCP).
|
|
13
|
+
- **`scan_changed_files` (MCP)** now reports only findings on **newly-added lines** vs the base by default (`diff_aware:false` for whole changed files).
|
|
14
|
+
- **Transparent, never silent:** both report how many pre-existing findings on unchanged lines were hidden (`preExistingHidden`; a note in the pre-commit markdown). Reuses the FAZ 2a git-free hunk parser and `getAddedLinesStaged`/`getAddedLinesForDiff`. Verified end-to-end in a temp repo. No rule or tool changes (441 / 37).
|
|
15
|
+
|
|
16
|
+
Gate green (build / lint / test / self-audit PASS / A / 0).
|
|
17
|
+
|
|
18
|
+
## [3.8.0] - 2026-06-07
|
|
19
|
+
|
|
20
|
+
### Fixed — auth-coverage no longer crashes on (and now understands) Clerk/Next.js middleware (441 rules / 37 tools)
|
|
21
|
+
- **Crash fix:** the Next.js/Clerk catch-all `config.matcher` contains `]` inside character classes (e.g. `[^?]`), which truncated the old matcher parser and then made `matcherToRegex` throw "Unterminated character class" — so `auth_coverage` errored out on essentially every Clerk app. The matcher array is now parsed string-aware (brackets/commas/escapes inside a pattern are preserved) and matcher-to-regex never throws (it tries path-style and regex-style forms, skipping any it can't compile).
|
|
22
|
+
- **Precision:** when the middleware uses Clerk's `createRouteMatcher([...])`, those patterns are used as the precise protected-route set (a sensitive route outside the list is correctly still reported unprotected) instead of the broad `config.matcher` run-scope.
|
|
23
|
+
- **Fewer false negatives:** a recognizably non-auth middleware (next-intl / i18n / analytics) with a catch-all matcher no longer marks routes as protected. Default remains lenient for everything else, so custom auth middleware still counts.
|
|
24
|
+
- New exported `parseProtectedRouteMatchers`; verified on the corpus (5 real middleware files, 0 crashes). No rule or tool changes (441 / 37).
|
|
25
|
+
|
|
26
|
+
Gate green (build / lint / test / self-audit PASS / A / 0).
|
|
27
|
+
|
|
8
28
|
## [3.7.0] - 2026-06-07
|
|
9
29
|
|
|
10
30
|
### Added — 3 fresh CVE rules from daily intel (438 → 441 rules / 37 tools)
|
package/README.md
CHANGED
|
@@ -218,7 +218,7 @@ Malicious postinstall scripts, unpinned GitHub Actions, CI `npm` provenance / `-
|
|
|
218
218
|
| `check_code` | Analyze a code snippet for security issues |
|
|
219
219
|
| `check_project` | Scan multiple files with security scoring (A-F) |
|
|
220
220
|
| `scan_directory` | Scan a project directory from disk |
|
|
221
|
-
| `scan_staged` | Pre-commit scan of git-staged files |
|
|
221
|
+
| `scan_staged` | Pre-commit scan of git-staged files — **diff-aware** (blocks only newly-staged lines; `diff_aware:false` for whole files) |
|
|
222
222
|
| `scan_dependencies` | Check all dependencies for known CVEs (OSV) — annotates each vulnerable package with **reachability** (is it actually imported in your source?) |
|
|
223
223
|
| `scan_secrets` | Detect leaked secrets, API keys, tokens |
|
|
224
224
|
| `check_dependencies` | Check individual packages against OSV |
|
|
@@ -240,7 +240,7 @@ Malicious postinstall scripts, unpinned GitHub Actions, CI `npm` provenance / `-
|
|
|
240
240
|
| `repo_security_posture` | Assess overall repository security posture and map sensitive areas |
|
|
241
241
|
| `explain_remediation` | Get detailed remediation guidance with exploit scenarios and fix strategies |
|
|
242
242
|
| `scan_file` | Real-time single-file scan — designed for post-edit hooks |
|
|
243
|
-
| `scan_changed_files` | Scan only git-changed files — for PRs and incremental CI |
|
|
243
|
+
| `scan_changed_files` | Scan only git-changed files — for PRs and incremental CI; **diff-aware** (only newly-added lines; `diff_aware:false` for whole files) |
|
|
244
244
|
| `security_stats` | Cumulative security dashboard — scans, fixes, grade trend over time |
|
|
245
245
|
| `guardvibe_doctor` | **Host security audit** — CVE-2025-59536, CVE-2026-21852, MCP config, env scanner |
|
|
246
246
|
| `audit_mcp_config` | Audit MCP server configurations for hook injection, file:// abuse, sensitive paths |
|
package/build/cli/scan.js
CHANGED
|
@@ -26,7 +26,7 @@ export async function runScan() {
|
|
|
26
26
|
}
|
|
27
27
|
else {
|
|
28
28
|
const { scanStaged } = await import("../tools/scan-staged.js");
|
|
29
|
-
result = scanStaged(process.cwd(), format === "json" ? "json" : "markdown");
|
|
29
|
+
result = scanStaged(process.cwd(), format === "json" ? "json" : "markdown", undefined, { diffAware: flags["all-lines"] !== true });
|
|
30
30
|
}
|
|
31
31
|
if (outputFile) {
|
|
32
32
|
safeWriteOutput(outputFile, result);
|
package/build/index.js
CHANGED
|
@@ -238,12 +238,13 @@ server.tool("scan_secrets", "Scan files and directories for leaked secrets, API
|
|
|
238
238
|
return { content: [{ type: "text", text: results }] };
|
|
239
239
|
});
|
|
240
240
|
// Tool 8: Scan git-staged files before committing
|
|
241
|
-
server.tool("scan_staged", "Scan git-staged files for security vulnerabilities before committing. Run this before every commit to catch issues early. No input needed — automatically reads staged files.", {
|
|
241
|
+
server.tool("scan_staged", "Scan git-staged files for security vulnerabilities before committing. Run this before every commit to catch issues early. No input needed — automatically reads staged files. Diff-aware by default: reports only issues on newly-staged lines (set diff_aware:false for whole staged files).", {
|
|
242
242
|
format: z.enum(["markdown", "json"]).default("markdown").describe("Output format: markdown (human) or json (machine-readable for agents)"),
|
|
243
|
-
|
|
243
|
+
diff_aware: z.boolean().default(true).describe("Report only findings on newly-staged lines (true, default) vs. all lines in staged files (false)"),
|
|
244
|
+
}, async ({ format, diff_aware }) => {
|
|
244
245
|
const rules = getRules();
|
|
245
246
|
const cwd = process.cwd();
|
|
246
|
-
const results = scanStaged(cwd, format, rules);
|
|
247
|
+
const results = scanStaged(cwd, format, rules, { diffAware: diff_aware });
|
|
247
248
|
let findingCount = 0;
|
|
248
249
|
try {
|
|
249
250
|
const parsed = JSON.parse(results);
|
|
@@ -556,15 +557,17 @@ server.tool("scan_file", "Scan a single file on disk by path for security vulner
|
|
|
556
557
|
return { content: [{ type: "text", text: mergeStatsIntoOutput(result, summary, format) }] };
|
|
557
558
|
});
|
|
558
559
|
// Tool 24: Scan changed files only — for incremental CI/CD and PR workflows
|
|
559
|
-
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.
|
|
560
|
+
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. Diff-aware by default: returns only findings on newly-added lines (set diff_aware:false for whole changed files).", {
|
|
560
561
|
path: z.string().default(".").describe("Repository root path"),
|
|
561
562
|
base: z.string().default("HEAD~1").describe("Git ref to diff against (e.g. 'main', 'HEAD~3', commit SHA)"),
|
|
562
563
|
format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"),
|
|
563
|
-
|
|
564
|
+
diff_aware: z.boolean().default(true).describe("Report only newly-introduced findings on added lines (true, default) vs. all findings in changed files (false)"),
|
|
565
|
+
}, async ({ path: repoPath, base, format, diff_aware }) => {
|
|
564
566
|
const { execFileSync } = await import("child_process");
|
|
565
567
|
const { readFileSync, existsSync } = await import("fs");
|
|
566
568
|
const { resolve, extname, basename } = await import("path");
|
|
567
569
|
const { EXTENSION_MAP, CONFIG_FILE_MAP } = await import("./utils/constants.js");
|
|
570
|
+
const { getAddedLinesForDiff, filterToAddedLines } = await import("./tools/diff-aware.js");
|
|
568
571
|
const root = resolve(repoPath);
|
|
569
572
|
let changedFiles;
|
|
570
573
|
try {
|
|
@@ -582,6 +585,7 @@ server.tool("scan_changed_files", "Scan only files that have changed since a giv
|
|
|
582
585
|
}
|
|
583
586
|
const rules = getRules();
|
|
584
587
|
const allFindings = [];
|
|
588
|
+
let preExistingHidden = 0;
|
|
585
589
|
for (const relPath of changedFiles) {
|
|
586
590
|
const fullPath = resolve(root, relPath);
|
|
587
591
|
if (!existsSync(fullPath))
|
|
@@ -596,7 +600,13 @@ server.tool("scan_changed_files", "Scan only files that have changed since a giv
|
|
|
596
600
|
continue;
|
|
597
601
|
try {
|
|
598
602
|
const content = readFileSync(fullPath, "utf-8");
|
|
599
|
-
|
|
603
|
+
let findings = analyzeFileSecurity(content, language, undefined, fullPath, root, rules);
|
|
604
|
+
if (diff_aware) {
|
|
605
|
+
const added = getAddedLinesForDiff(base, relPath, root);
|
|
606
|
+
const kept = filterToAddedLines(findings, added);
|
|
607
|
+
preExistingHidden += findings.length - kept.length;
|
|
608
|
+
findings = kept;
|
|
609
|
+
}
|
|
600
610
|
for (const f of findings) {
|
|
601
611
|
allFindings.push({
|
|
602
612
|
file: relPath, id: f.rule.id, name: f.rule.name,
|
|
@@ -615,7 +625,7 @@ server.tool("scan_changed_files", "Scan only files that have changed since a giv
|
|
|
615
625
|
const high = allFindings.filter(f => f.severity === "high").length;
|
|
616
626
|
const medium = allFindings.filter(f => f.severity === "medium").length;
|
|
617
627
|
return { content: [{ type: "text", text: mergeStatsIntoOutput(JSON.stringify({
|
|
618
|
-
summary: { total: allFindings.length, critical, high, medium, low: 0, blocked: critical > 0 || high > 0, changedFiles: changedFiles.length },
|
|
628
|
+
summary: { total: allFindings.length, critical, high, medium, low: 0, blocked: critical > 0 || high > 0, changedFiles: changedFiles.length, diffAware: diff_aware, preExistingHidden },
|
|
619
629
|
findings: allFindings,
|
|
620
630
|
}), statsSummary, format) }] };
|
|
621
631
|
}
|
|
@@ -18,11 +18,14 @@ export interface FileEntry {
|
|
|
18
18
|
* Enumerate all routes from a set of app directory files.
|
|
19
19
|
*/
|
|
20
20
|
export declare function enumerateRoutes(files: FileEntry[]): RouteInfo[];
|
|
21
|
+
export declare function parseMiddlewareMatchers(content: string): string[];
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* Clerk-style protect lists: `createRouteMatcher([...])`. When present these are the
|
|
24
|
+
* precise routes the middleware enforces auth on — more accurate than config.matcher
|
|
25
|
+
* (which only says where the middleware *runs*), so a sensitive route outside the
|
|
26
|
+
* protect list is correctly still reported as unprotected.
|
|
24
27
|
*/
|
|
25
|
-
export declare function
|
|
28
|
+
export declare function parseProtectedRouteMatchers(content: string): string[];
|
|
26
29
|
/**
|
|
27
30
|
* Check if a route URL path matches any of the middleware matchers.
|
|
28
31
|
* Empty matchers = middleware covers all routes.
|
|
@@ -82,35 +82,108 @@ export function enumerateRoutes(files) {
|
|
|
82
82
|
* Parse Next.js middleware config.matcher from middleware file content.
|
|
83
83
|
* Returns array of matcher patterns.
|
|
84
84
|
*/
|
|
85
|
+
function stripComments(content) {
|
|
86
|
+
return content
|
|
87
|
+
.replace(/\\n/g, "\n").replace(/\\t/g, "\t")
|
|
88
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
89
|
+
.replace(/\/\/.*$/gm, "");
|
|
90
|
+
}
|
|
91
|
+
/** Inner text of the array starting at `[` at `openIdx`, scanning string-aware so
|
|
92
|
+
* a `]` inside a string literal (e.g. the catch-all `[^?]`) doesn't end it early. */
|
|
93
|
+
function bracketInner(s, openIdx) {
|
|
94
|
+
let depth = 0;
|
|
95
|
+
for (let i = openIdx; i < s.length; i++) {
|
|
96
|
+
const ch = s[i];
|
|
97
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
98
|
+
const q = ch;
|
|
99
|
+
i++;
|
|
100
|
+
while (i < s.length && s[i] !== q) {
|
|
101
|
+
if (s[i] === "\\")
|
|
102
|
+
i++;
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (ch === "[") {
|
|
107
|
+
depth++;
|
|
108
|
+
}
|
|
109
|
+
else if (ch === "]") {
|
|
110
|
+
depth--;
|
|
111
|
+
if (depth === 0)
|
|
112
|
+
return s.slice(openIdx + 1, i);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** A matcher written in JS source escapes regex backslashes (`\\.`); collapse one
|
|
118
|
+
* level so the extracted pattern is a usable regex (`\.`). */
|
|
119
|
+
function unescapeMatcher(s) {
|
|
120
|
+
return s.replace(/\\\\/g, "\\");
|
|
121
|
+
}
|
|
122
|
+
/** Every quoted string literal inside a region (handles `]`, `,`, escapes within). */
|
|
123
|
+
function extractStringLiterals(region) {
|
|
124
|
+
const out = [];
|
|
125
|
+
const re = /(["'`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
|
|
126
|
+
let m;
|
|
127
|
+
while ((m = re.exec(region)) !== null)
|
|
128
|
+
out.push(unescapeMatcher(m[2]));
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
85
131
|
export function parseMiddlewareMatchers(content) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return arrayMatch[1]
|
|
99
|
-
.split(",")
|
|
100
|
-
.map(s => s.trim().replace(/^["']|["']$/g, ""))
|
|
101
|
-
.filter(Boolean);
|
|
132
|
+
const normalized = stripComments(content);
|
|
133
|
+
// Array form: matcher: [ ... ] — bound the array string-aware so a catch-all
|
|
134
|
+
// pattern containing `]`/`,` (e.g. Clerk's `[^?]`) isn't truncated.
|
|
135
|
+
const arrM = /matcher\s*:\s*\[/.exec(normalized);
|
|
136
|
+
if (arrM) {
|
|
137
|
+
const openIdx = normalized.indexOf("[", arrM.index);
|
|
138
|
+
const inner = bracketInner(normalized, openIdx);
|
|
139
|
+
if (inner !== null) {
|
|
140
|
+
const lits = extractStringLiterals(inner);
|
|
141
|
+
if (lits.length)
|
|
142
|
+
return lits;
|
|
143
|
+
}
|
|
102
144
|
}
|
|
145
|
+
// String form: matcher: "..."
|
|
146
|
+
const strM = /matcher\s*:\s*(["'`])((?:\\.|(?!\1).)*)\1/.exec(normalized);
|
|
147
|
+
if (strM)
|
|
148
|
+
return [unescapeMatcher(strM[2])];
|
|
103
149
|
return [];
|
|
104
150
|
}
|
|
105
151
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
152
|
+
* Clerk-style protect lists: `createRouteMatcher([...])`. When present these are the
|
|
153
|
+
* precise routes the middleware enforces auth on — more accurate than config.matcher
|
|
154
|
+
* (which only says where the middleware *runs*), so a sensitive route outside the
|
|
155
|
+
* protect list is correctly still reported as unprotected.
|
|
156
|
+
*/
|
|
157
|
+
export function parseProtectedRouteMatchers(content) {
|
|
158
|
+
const normalized = stripComments(content);
|
|
159
|
+
const out = [];
|
|
160
|
+
const callRe = /createRouteMatcher\s*\(\s*\[/g;
|
|
161
|
+
let m;
|
|
162
|
+
while ((m = callRe.exec(normalized)) !== null) {
|
|
163
|
+
const openIdx = normalized.indexOf("[", m.index);
|
|
164
|
+
const inner = bracketInner(normalized, openIdx);
|
|
165
|
+
if (inner !== null)
|
|
166
|
+
out.push(...extractStringLiterals(inner));
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Convert a Next.js matcher to a regex. Path-style matchers (`/x/:id`, `/y/:path*`)
|
|
172
|
+
* get token conversion; regex-style matchers (Clerk catch-all, `(.*)`, char classes)
|
|
173
|
+
* are used raw. Tries the likely form first, falls back to the other, and NEVER throws
|
|
174
|
+
* on a malformed pattern (returns null, which callers skip).
|
|
108
175
|
*/
|
|
109
176
|
function matcherToRegex(pattern) {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
177
|
+
const pathConvert = (p) => p.replace(/\/:[\w]+\*/g, "(?:/.*)?").replace(/:[\w]+/g, "[^/]+");
|
|
178
|
+
const looksRegex = /[(|]|\.\*|\\[dwsDWS]|\[\^?/.test(pattern);
|
|
179
|
+
const candidates = looksRegex ? [pattern, pathConvert(pattern)] : [pathConvert(pattern), pattern];
|
|
180
|
+
for (const c of candidates) {
|
|
181
|
+
try {
|
|
182
|
+
return new RegExp("^" + c + "$");
|
|
183
|
+
}
|
|
184
|
+
catch { /* try next form */ }
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
114
187
|
}
|
|
115
188
|
/**
|
|
116
189
|
* Check if a route URL path matches any of the middleware matchers.
|
|
@@ -121,7 +194,7 @@ export function routeMatchesMatcher(urlPath, matchers) {
|
|
|
121
194
|
return true;
|
|
122
195
|
for (const pattern of matchers) {
|
|
123
196
|
const regex = matcherToRegex(pattern);
|
|
124
|
-
if (regex.test(urlPath))
|
|
197
|
+
if (regex && regex.test(urlPath))
|
|
125
198
|
return true;
|
|
126
199
|
}
|
|
127
200
|
return false;
|
|
@@ -154,8 +227,19 @@ function hasAuthGuard(code) {
|
|
|
154
227
|
*/
|
|
155
228
|
export function analyzeAuthCoverage(routeFiles, middlewareContent, layoutFiles, authExceptions) {
|
|
156
229
|
const routes = enumerateRoutes(routeFiles);
|
|
157
|
-
const matchers = parseMiddlewareMatchers(middlewareContent);
|
|
158
230
|
const hasMiddleware = middlewareContent.length > 0;
|
|
231
|
+
// Default-lenient: a middleware with a matcher counts as protection — EXCEPT when it
|
|
232
|
+
// is recognizably a non-auth middleware (i18n / analytics) with no auth signal, which
|
|
233
|
+
// must not mark routes protected (that would hide genuinely unprotected routes).
|
|
234
|
+
const hasAuthSignal = hasAuthGuard(middlewareContent) ||
|
|
235
|
+
/\b(?:clerkMiddleware|authMiddleware|withAuth|createRouteMatcher|NextAuth|auth0|betterAuth|supabaseMiddleware|updateSession|createServerClient|getToken)\b/.test(middlewareContent) ||
|
|
236
|
+
/auth\s*\.\s*protect\s*\(/.test(middlewareContent);
|
|
237
|
+
const isNonAuthMiddleware = /\b(?:next-intl|createI18nMiddleware|next-international|paraglide|@vercel\/analytics|posthog)\b/.test(middlewareContent)
|
|
238
|
+
|| /from\s+["']next-intl\/middleware["']/.test(middlewareContent);
|
|
239
|
+
const middlewareCountsAsAuth = hasMiddleware && (hasAuthSignal || !isNonAuthMiddleware);
|
|
240
|
+
// Prefer the precise Clerk protect list; fall back to where the (auth) middleware runs.
|
|
241
|
+
const protectMatchers = parseProtectedRouteMatchers(middlewareContent);
|
|
242
|
+
const coverageMatchers = protectMatchers.length ? protectMatchers : parseMiddlewareMatchers(middlewareContent);
|
|
159
243
|
// Map file content by path for auth detection
|
|
160
244
|
const contentByPath = new Map();
|
|
161
245
|
for (const f of routeFiles)
|
|
@@ -167,9 +251,9 @@ export function analyzeAuthCoverage(routeFiles, middlewareContent, layoutFiles,
|
|
|
167
251
|
route.hasAuthGuard = hasAuthGuard(content);
|
|
168
252
|
if (route.hasAuthGuard)
|
|
169
253
|
route.protectionSource = "auth-guard";
|
|
170
|
-
// Middleware coverage
|
|
171
|
-
if (
|
|
172
|
-
route.middlewareCovered = routeMatchesMatcher(route.urlPath,
|
|
254
|
+
// Middleware coverage (skipped for recognizably non-auth middleware)
|
|
255
|
+
if (middlewareCountsAsAuth) {
|
|
256
|
+
route.middlewareCovered = routeMatchesMatcher(route.urlPath, coverageMatchers);
|
|
173
257
|
if (route.middlewareCovered) {
|
|
174
258
|
middlewareCoveredCount++;
|
|
175
259
|
if (route.protectionSource === "none")
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { SecurityRule } from "../data/rules/types.js";
|
|
2
|
-
export declare function scanStaged(cwd?: string, format?: "markdown" | "json", rules?: SecurityRule[]
|
|
2
|
+
export declare function scanStaged(cwd?: string, format?: "markdown" | "json", rules?: SecurityRule[], opts?: {
|
|
3
|
+
diffAware?: boolean;
|
|
4
|
+
}): string;
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from "child_process";
|
|
|
2
2
|
import { extname, basename } from "path";
|
|
3
3
|
import { formatFindingsJson } from "./check-code.js";
|
|
4
4
|
import { analyzeFileSecurity } from "./file-security.js";
|
|
5
|
+
import { getAddedLinesStaged, filterToAddedLines } from "./diff-aware.js";
|
|
5
6
|
import { securityBanner } from "../utils/banner.js";
|
|
6
7
|
const EXTENSION_MAP = {
|
|
7
8
|
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
@@ -58,7 +59,10 @@ function detectLanguage(filePath) {
|
|
|
58
59
|
return configLang;
|
|
59
60
|
return null;
|
|
60
61
|
}
|
|
61
|
-
export function scanStaged(cwd = process.cwd(), format = "markdown", rules) {
|
|
62
|
+
export function scanStaged(cwd = process.cwd(), format = "markdown", rules, opts = {}) {
|
|
63
|
+
// Diff-aware by default: the pre-commit gate blocks issues on lines you just
|
|
64
|
+
// staged, not pre-existing debt in files you happened to touch. --all-lines opts out.
|
|
65
|
+
const diffAware = opts.diffAware !== false;
|
|
62
66
|
const stagedFiles = getStagedFiles(cwd);
|
|
63
67
|
if (stagedFiles.length === 0) {
|
|
64
68
|
return [
|
|
@@ -69,6 +73,7 @@ export function scanStaged(cwd = process.cwd(), format = "markdown", rules) {
|
|
|
69
73
|
}
|
|
70
74
|
const results = [];
|
|
71
75
|
const skippedFiles = [];
|
|
76
|
+
let preExistingHidden = 0;
|
|
72
77
|
for (const filePath of stagedFiles) {
|
|
73
78
|
const language = detectLanguage(filePath);
|
|
74
79
|
if (!language) {
|
|
@@ -80,7 +85,13 @@ export function scanStaged(cwd = process.cwd(), format = "markdown", rules) {
|
|
|
80
85
|
skippedFiles.push(filePath);
|
|
81
86
|
continue;
|
|
82
87
|
}
|
|
83
|
-
|
|
88
|
+
let findings = analyzeFileSecurity(content, language, undefined, filePath, cwd, rules);
|
|
89
|
+
if (diffAware) {
|
|
90
|
+
const added = getAddedLinesStaged(filePath, cwd);
|
|
91
|
+
const kept = filterToAddedLines(findings, added);
|
|
92
|
+
preExistingHidden += findings.length - kept.length;
|
|
93
|
+
findings = kept;
|
|
94
|
+
}
|
|
84
95
|
if (findings.length > 0) {
|
|
85
96
|
results.push({ path: filePath, findings });
|
|
86
97
|
}
|
|
@@ -102,10 +113,14 @@ export function scanStaged(cwd = process.cwd(), format = "markdown", rules) {
|
|
|
102
113
|
"# GuardVibe Pre-Commit Report",
|
|
103
114
|
"",
|
|
104
115
|
`Staged files scanned: ${scannedCount}`,
|
|
116
|
+
`Mode: ${diffAware ? "newly-staged lines only" : "all lines"}`,
|
|
105
117
|
`Total issues: ${totalIssues}`,
|
|
106
118
|
`Security Score: ${grade} (${score}/100)`,
|
|
107
119
|
"",
|
|
108
120
|
];
|
|
121
|
+
if (diffAware && preExistingHidden > 0) {
|
|
122
|
+
lines.push(`> ${preExistingHidden} pre-existing finding(s) on unchanged lines hidden — re-run with \`--all-lines\` to see them.`, "");
|
|
123
|
+
}
|
|
109
124
|
if (totalIssues > 0) {
|
|
110
125
|
lines.push("## Summary", "", "| Severity | Count |", "|----------|-------|");
|
|
111
126
|
if (totalCritical > 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardvibe",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"mcpName": "io.github.goklab/guardvibe",
|
|
5
5
|
"description": "Security infrastructure your AI can't be — deterministic, current past your model's training cutoff, whole-repo-aware, author-independent. Security MCP for vibe coding. 441 rules, 37 tools, CLI + doctor. Host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. 70 CVE rules refreshed daily from GHSA/OSV/CISA KEV — React Router 7 cluster, DOMPurify XSS, Better Auth bypass, Miasma @redhat-cloud-services compromise, Next.js May 2026 13-advisory cluster, Drizzle/MikroORM/Kysely SQL injection, Axios proxy-auth redirect leak, Hono setCookie attribute injection, Clerk SSRF, tRPC prototype pollution, @tanstack supply-chain, node-ipc protestware, OpenClaude sandbox bypass, plus the full AI-generated stack (Supabase, Stripe, Prisma, Hono, GraphQL, Convex, Turso, Uploadthing, AI SDK). 68 AI-native rules including OWASP MCP Top 10 tool-description prompt injection (VG1068), model-controlled sandbox-disable flag detection (VG1063), Session messenger exfil endpoint IOC (VG1075), and CI/CD supply-chain hardening (VG1070 npm --expect-provenance / --ignore-scripts enforcement).",
|
|
6
6
|
"type": "module",
|