guardvibe 2.4.4 → 2.5.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 CHANGED
@@ -5,6 +5,18 @@ 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
+ ## [2.5.0] - 2026-04-04
9
+
10
+ ### Added
11
+ - Cross-file taint analysis — tracks tainted data flowing across module boundaries (`analyze_cross_file_dataflow` tool)
12
+ - Import/export resolution, module graph building, and cross-file taint propagation
13
+ - Detects SQL injection, XSS, open redirect, code injection, and path traversal across files
14
+
15
+ ## [2.4.5] - 2026-04-04
16
+
17
+ ### Added
18
+ - Official MCP Registry support (`mcpName` in package.json, `server.json`)
19
+
8
20
  ## [2.4.4] - 2026-04-04
9
21
 
10
22
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm provenance](https://img.shields.io/badge/provenance-verified-brightgreen)](https://www.npmjs.com/package/guardvibe)
7
7
  [![codecov](https://codecov.io/gh/goklab/guardvibe/graph/badge.svg)](https://codecov.io/gh/goklab/guardvibe)
8
8
 
9
- **The security MCP built for vibe coding.** 313 security rules covering the entire AI-generated code journey — from first line to production deployment.
9
+ **The security MCP built for vibe coding.** 313 security rules, 26 tools covering the entire AI-generated code journey — from first line to production deployment.
10
10
 
11
11
  Works with **Claude Code, Cursor, Gemini CLI, Codex, VS Code (Copilot), Windsurf**, and any MCP-compatible coding agent.
12
12
 
@@ -14,7 +14,7 @@ Works with **Claude Code, Cursor, Gemini CLI, Codex, VS Code (Copilot), Windsurf
14
14
 
15
15
  Most security tools are built for enterprise security teams. GuardVibe is built for **you** — the developer using AI to build and ship web apps fast.
16
16
 
17
- - **313 security rules** purpose-built for the stacks AI agents generate
17
+ - **313 security rules, 26 tools** purpose-built for the stacks AI agents generate
18
18
  - **Zero setup friction** — `npx guardvibe` and you're scanning
19
19
  - **No account required** — runs 100% locally, no API keys, no cloud
20
20
  - **Understands your stack** — not generic SAST, but rules that know Next.js, Supabase, Stripe, Clerk, and the tools you actually use
@@ -179,7 +179,7 @@ SOC2, PCI-DSS, HIPAA control mapping with compliance reports
179
179
  ### Supply Chain
180
180
  Malicious postinstall scripts, unpinned GitHub Actions, typosquat detection
181
181
 
182
- ## Tools (25 MCP tools)
182
+ ## Tools (26 MCP tools)
183
183
 
184
184
  | Tool | What it does |
185
185
  |------|-------------|
@@ -201,6 +201,7 @@ Malicious postinstall scripts, unpinned GitHub Actions, typosquat detection
201
201
  | `scan_secrets_history` | Scan git history for leaked secrets (active and removed) |
202
202
  | `policy_check` | Check project against compliance policies defined in .guardviberc |
203
203
  | `analyze_dataflow` | Track tainted data flows from user input to dangerous sinks |
204
+ | `analyze_cross_file_dataflow` | **Cross-file taint analysis** — track tainted data across module boundaries |
204
205
  | `check_command` | Analyze shell commands for security risks before execution |
205
206
  | `scan_config_change` | Compare config file versions to detect security downgrades |
206
207
  | `repo_security_posture` | Assess overall repository security posture and map sensitive areas |
package/build/index.js CHANGED
@@ -23,6 +23,7 @@ import { reviewPr } from "./tools/review-pr.js";
23
23
  import { scanSecretsHistory } from "./tools/scan-secrets-history.js";
24
24
  import { policyCheck } from "./tools/policy-check.js";
25
25
  import { analyzeTaint, formatTaintFindings } from "./tools/taint-analysis.js";
26
+ import { analyzeCrossFileTaint, formatCrossFileTaintFindings } from "./tools/cross-file-taint.js";
26
27
  import { checkCommand } from "./tools/check-command.js";
27
28
  import { scanConfigChange } from "./tools/scan-config-change.js";
28
29
  import { repoSecurityPosture } from "./tools/repo-posture.js";
@@ -340,6 +341,26 @@ server.tool("analyze_dataflow", "Track user input (request body, URL params, for
340
341
  const results = formatTaintFindings(findings, format);
341
342
  return { content: [{ type: "text", text: results }] };
342
343
  });
344
+ // Tool 18b: Cross-File Taint/Dataflow Analysis
345
+ server.tool("analyze_cross_file_dataflow", "Track user input flowing across module boundaries — detects injection vulnerabilities that span multiple files. Resolves imports/exports, builds a module graph, and follows tainted data from HTTP handlers through helper functions to dangerous sinks (SQL, eval, redirect, file ops). Pass all related files for best results.", {
346
+ files: z
347
+ .array(z.object({
348
+ path: z.string().describe("Relative file path (e.g. src/lib/db.ts)"),
349
+ content: z.string().describe("File source code"),
350
+ }))
351
+ .describe("List of files to analyze: [{path, content}]"),
352
+ format: z.enum(["markdown", "json"]).default("markdown").describe("Output format"),
353
+ }, async ({ files, format }) => {
354
+ const { crossFileFindings, perFileFindings } = analyzeCrossFileTaint(files);
355
+ const total = crossFileFindings.length + Array.from(perFileFindings.values()).reduce((sum, f) => sum + f.length, 0);
356
+ if (total === 0) {
357
+ if (format === "json")
358
+ return { content: [{ type: "text", text: JSON.stringify({ summary: { crossFileFlows: 0, perFileFlows: 0, total: 0, critical: 0, high: 0, medium: 0 }, crossFileFindings: [], perFileFindings: [] }) }] };
359
+ return { content: [{ type: "text", text: "No tainted data flows detected across files." }] };
360
+ }
361
+ const results = formatCrossFileTaintFindings(crossFileFindings, perFileFindings, format);
362
+ return { content: [{ type: "text", text: results }] };
363
+ });
343
364
  // Tool 19: Shell Command Risk Analyzer
344
365
  server.tool("check_command", "Analyze a shell command for security risks before execution. Returns allow/ask/deny verdict with blast radius, safer alternatives, and context-aware risk assessment. Detects: destructive ops, git history rewrites, secret exposure, data exfiltration, deploy triggers, privilege escalation, database drops.", {
345
366
  command: z.string().describe("Shell command to analyze"),
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Cross-file taint analysis — tracks user input flowing across module boundaries.
3
+ * Resolves imports/exports, builds a module graph, and propagates taint between files.
4
+ */
5
+ import { type TaintFinding } from "./taint-analysis.js";
6
+ export interface FileEntry {
7
+ path: string;
8
+ content: string;
9
+ }
10
+ export interface CrossFileTaintFinding {
11
+ source: {
12
+ file: string;
13
+ type: string;
14
+ line: number;
15
+ variable: string;
16
+ };
17
+ sink: {
18
+ file: string;
19
+ type: string;
20
+ line: number;
21
+ code: string;
22
+ };
23
+ chain: string[];
24
+ severity: "critical" | "high" | "medium";
25
+ description: string;
26
+ fix: string;
27
+ }
28
+ interface ImportInfo {
29
+ /** The file that contains the import statement */
30
+ importer: string;
31
+ /** Resolved path of the module being imported */
32
+ source: string;
33
+ /** Named imports: local name -> exported name */
34
+ names: Map<string, string>;
35
+ /** Default import name, if any */
36
+ defaultName?: string;
37
+ /** Namespace import name (import * as X), if any */
38
+ namespaceName?: string;
39
+ /** Line number of the import statement */
40
+ line: number;
41
+ }
42
+ interface ExportInfo {
43
+ /** The file that exports */
44
+ file: string;
45
+ /** Exported name -> local name */
46
+ names: Map<string, string>;
47
+ /** Has default export */
48
+ hasDefault: boolean;
49
+ /** Default export local name (function/class name or "default") */
50
+ defaultLocal?: string;
51
+ }
52
+ interface FunctionSignature {
53
+ file: string;
54
+ name: string;
55
+ params: string[];
56
+ startLine: number;
57
+ endLine: number;
58
+ body: string;
59
+ }
60
+ interface TaintedExport {
61
+ file: string;
62
+ exportName: string;
63
+ /** Which parameter indices receive taint and flow to sinks */
64
+ taintedParams: Map<number, {
65
+ sinkType: string;
66
+ sinkLine: number;
67
+ sinkCode: string;
68
+ }>;
69
+ }
70
+ declare function normalizePath(from: string, importPath: string): string;
71
+ declare function stripExtension(filePath: string): string;
72
+ declare function parseImports(file: string, content: string): ImportInfo[];
73
+ declare function parseExports(file: string, content: string): ExportInfo;
74
+ declare function extractFunctions(file: string, content: string): FunctionSignature[];
75
+ declare function findTaintedExports(files: FileEntry[]): TaintedExport[];
76
+ export declare function analyzeCrossFileTaint(files: FileEntry[]): {
77
+ crossFileFindings: CrossFileTaintFinding[];
78
+ perFileFindings: Map<string, TaintFinding[]>;
79
+ };
80
+ export declare function formatCrossFileTaintFindings(crossFileFindings: CrossFileTaintFinding[], perFileFindings: Map<string, TaintFinding[]>, format: "markdown" | "json"): string;
81
+ export { parseImports, parseExports, extractFunctions, findTaintedExports, normalizePath, stripExtension };
@@ -0,0 +1,554 @@
1
+ // guardvibe-ignore — this file defines cross-file taint analysis patterns, not vulnerable code
2
+ /**
3
+ * Cross-file taint analysis — tracks user input flowing across module boundaries.
4
+ * Resolves imports/exports, builds a module graph, and propagates taint between files.
5
+ */
6
+ import { analyzeTaint } from "./taint-analysis.js";
7
+ // --- Import/Export Resolution ---
8
+ function normalizePath(from, importPath) {
9
+ if (!importPath.startsWith("."))
10
+ return importPath;
11
+ const fromDir = from.includes("/") ? from.substring(0, from.lastIndexOf("/")) : ".";
12
+ const parts = fromDir.split("/").filter(Boolean);
13
+ const importParts = importPath.split("/");
14
+ for (const p of importParts) {
15
+ if (p === "..")
16
+ parts.pop();
17
+ else if (p !== ".")
18
+ parts.push(p);
19
+ }
20
+ let resolved = parts.join("/");
21
+ resolved = resolved.replace(/\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/, "");
22
+ return resolved;
23
+ }
24
+ function stripExtension(filePath) {
25
+ return filePath.replace(/\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/, "");
26
+ }
27
+ function parseImports(file, content) {
28
+ const imports = [];
29
+ const lines = content.split("\n");
30
+ for (let i = 0; i < lines.length; i++) {
31
+ const line = lines[i];
32
+ // import X, { a, b } from './mod'
33
+ {
34
+ const re = /import\s+([\w$]+)\s*,\s*\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
35
+ let m;
36
+ while ((m = re.exec(line)) !== null) {
37
+ const names = new Map();
38
+ for (const spec of m[2].split(",")) {
39
+ const parts = spec.trim().split(/\s+as\s+/);
40
+ const exported = parts[0].trim();
41
+ const local = (parts[1] ?? parts[0]).trim();
42
+ if (exported)
43
+ names.set(local, exported);
44
+ }
45
+ imports.push({
46
+ importer: file,
47
+ source: normalizePath(file, m[3]),
48
+ names,
49
+ defaultName: m[1].trim(),
50
+ line: i + 1,
51
+ });
52
+ }
53
+ }
54
+ // import { a, b as c } from './mod'
55
+ {
56
+ const re = /import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
57
+ let m;
58
+ while ((m = re.exec(line)) !== null) {
59
+ if (/import\s+[\w$]+\s*,\s*\{/.test(line))
60
+ continue;
61
+ const names = new Map();
62
+ for (const spec of m[1].split(",")) {
63
+ const parts = spec.trim().split(/\s+as\s+/);
64
+ const exported = parts[0].trim();
65
+ const local = (parts[1] ?? parts[0]).trim();
66
+ if (exported)
67
+ names.set(local, exported);
68
+ }
69
+ imports.push({ importer: file, source: normalizePath(file, m[2]), names, line: i + 1 });
70
+ }
71
+ }
72
+ // import * as X from './mod'
73
+ {
74
+ const re = /import\s+\*\s+as\s+([\w$]+)\s+from\s+['"]([^'"]+)['"]/g;
75
+ let m;
76
+ while ((m = re.exec(line)) !== null) {
77
+ imports.push({
78
+ importer: file,
79
+ source: normalizePath(file, m[2]),
80
+ names: new Map(),
81
+ namespaceName: m[1].trim(),
82
+ line: i + 1,
83
+ });
84
+ }
85
+ }
86
+ // import X from './mod' (default only)
87
+ {
88
+ const re = /import\s+([\w$]+)\s+from\s+['"]([^'"]+)['"]/g;
89
+ let m;
90
+ while ((m = re.exec(line)) !== null) {
91
+ if (/import\s+\{/.test(line) || /import\s+\*\s+as/.test(line))
92
+ continue;
93
+ if (/import\s+[\w$]+\s*,\s*\{/.test(line))
94
+ continue;
95
+ imports.push({
96
+ importer: file,
97
+ source: normalizePath(file, m[2]),
98
+ names: new Map(),
99
+ defaultName: m[1].trim(),
100
+ line: i + 1,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ return imports;
106
+ }
107
+ function parseExports(file, content) {
108
+ const names = new Map();
109
+ let hasDefault = false;
110
+ let defaultLocal;
111
+ const lines = content.split("\n");
112
+ for (const line of lines) {
113
+ // export { a, b as c }
114
+ {
115
+ const re = /export\s+\{([^}]+)\}/g;
116
+ let m;
117
+ while ((m = re.exec(line)) !== null) {
118
+ for (const spec of m[1].split(",")) {
119
+ const parts = spec.trim().split(/\s+as\s+/);
120
+ const local = parts[0].trim();
121
+ const exported = (parts[1] ?? parts[0]).trim();
122
+ if (exported === "default") {
123
+ hasDefault = true;
124
+ defaultLocal = local;
125
+ }
126
+ else if (exported) {
127
+ names.set(exported, local);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ // export default function/class name
133
+ {
134
+ const re = /export\s+default\s+(?:async\s+)?(?:function|class)\s+([\w$]+)/;
135
+ const m = re.exec(line);
136
+ if (m) {
137
+ hasDefault = true;
138
+ defaultLocal = m[1];
139
+ }
140
+ }
141
+ // export default (anonymous)
142
+ if (!hasDefault && /export\s+default\s+/.test(line) && !/export\s+default\s+(?:async\s+)?(?:function|class)\s+[\w$]/.test(line)) {
143
+ hasDefault = true;
144
+ defaultLocal = "default";
145
+ }
146
+ // export function/const/class name
147
+ {
148
+ const re = /export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([\w$]+)/;
149
+ const m = re.exec(line);
150
+ if (m && !/export\s+default/.test(line)) {
151
+ names.set(m[1], m[1]);
152
+ }
153
+ }
154
+ }
155
+ return { file, names, hasDefault, defaultLocal };
156
+ }
157
+ // --- Function Extraction ---
158
+ function extractFunctions(file, content) {
159
+ const functions = [];
160
+ const lines = content.split("\n");
161
+ const funcPattern = /(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([\w$]+)\s*\(([^)]*)\)/;
162
+ const arrowPattern = /(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s+)?(?:\(([^)]*)\)\s*=>|\([^)]*\)\s*:\s*\w+\s*=>|function\s*\(([^)]*)\))/;
163
+ for (let i = 0; i < lines.length; i++) {
164
+ let match = funcPattern.exec(lines[i]);
165
+ if (match) {
166
+ const params = match[2].split(",").map(p => p.trim().split(/[:\s=]/)[0].trim()).filter(Boolean);
167
+ const body = extractFunctionBody(lines, i);
168
+ functions.push({ file, name: match[1], params, startLine: i + 1, endLine: i + body.split("\n").length, body });
169
+ continue;
170
+ }
171
+ match = arrowPattern.exec(lines[i]);
172
+ if (match) {
173
+ const paramStr = match[2] ?? match[3] ?? "";
174
+ const params = paramStr.split(",").map(p => p.trim().split(/[:\s=]/)[0].trim()).filter(Boolean);
175
+ const body = extractFunctionBody(lines, i);
176
+ functions.push({ file, name: match[1], params, startLine: i + 1, endLine: i + body.split("\n").length, body });
177
+ }
178
+ }
179
+ return functions;
180
+ }
181
+ function extractFunctionBody(lines, startIdx) {
182
+ let braceCount = 0;
183
+ let started = false;
184
+ const bodyLines = [];
185
+ for (let i = startIdx; i < lines.length; i++) {
186
+ const line = lines[i];
187
+ bodyLines.push(line);
188
+ for (const ch of line) {
189
+ if (ch === "{") {
190
+ braceCount++;
191
+ started = true;
192
+ }
193
+ if (ch === "}")
194
+ braceCount--;
195
+ }
196
+ if (started && braceCount <= 0)
197
+ break;
198
+ }
199
+ return bodyLines.join("\n");
200
+ }
201
+ // --- Cross-File Analysis Engine ---
202
+ // Sink patterns used for checking if a param flows to a dangerous operation
203
+ const SINK_PATTERNS = [
204
+ { pattern: /\beval\s*\(/g, type: "code-injection" },
205
+ { pattern: /\.query\s*\(\s*`/g, type: "sql-injection" },
206
+ { pattern: /\.raw\s*\(\s*`/g, type: "sql-injection" },
207
+ { pattern: /\.query\s*\(\s*["'][\s\S]*?\$\{/g, type: "sql-injection" },
208
+ { pattern: /\.query\s*\(\s*(?:["'][\s\S]*?\+|[\w]+\s*\+)/g, type: "sql-injection" },
209
+ { pattern: /redirect\s*\(/g, type: "open-redirect" },
210
+ { pattern: /\.(?:innerHTML|outerHTML)\s*=/g, type: "xss" },
211
+ { pattern: /new\s+Function\s*\(/g, type: "code-injection" },
212
+ { pattern: /writeFileSync?\s*\(/g, type: "path-traversal" },
213
+ { pattern: /readFileSync?\s*\(/g, type: "path-traversal" },
214
+ ];
215
+ function checkParamFlowsToSink(paramName, body, startLine) {
216
+ const lines = body.split("\n");
217
+ const taintedNames = new Set([paramName]);
218
+ const assignPattern = /(?:const|let|var)\s+([\w$]+)\s*=\s*(.*)/;
219
+ for (const line of lines) {
220
+ const m = assignPattern.exec(line);
221
+ if (m) {
222
+ for (const t of taintedNames) {
223
+ if (m[2].includes(t)) {
224
+ taintedNames.add(m[1]);
225
+ break;
226
+ }
227
+ }
228
+ }
229
+ }
230
+ for (let i = 0; i < lines.length; i++) {
231
+ const line = lines[i];
232
+ for (const sink of SINK_PATTERNS) {
233
+ sink.pattern.lastIndex = 0;
234
+ if (!sink.pattern.test(line))
235
+ continue;
236
+ for (const t of taintedNames) {
237
+ if (line.includes(t)) {
238
+ return { sinkType: sink.type, sinkLine: startLine + i, sinkCode: line.trim().substring(0, 100) };
239
+ }
240
+ }
241
+ }
242
+ }
243
+ return null;
244
+ }
245
+ function findTaintedExports(files) {
246
+ const taintedExports = [];
247
+ for (const file of files) {
248
+ const exports = parseExports(file.path, file.content);
249
+ const functions = extractFunctions(file.path, file.content);
250
+ for (const fn of functions) {
251
+ const exportedName = exports.names.get(fn.name)
252
+ ? fn.name
253
+ : (exports.defaultLocal === fn.name ? "default" : null);
254
+ if (!exportedName)
255
+ continue;
256
+ const taintedParams = new Map();
257
+ for (let pIdx = 0; pIdx < fn.params.length; pIdx++) {
258
+ const param = fn.params[pIdx];
259
+ if (!param)
260
+ continue;
261
+ const paramAsTainted = checkParamFlowsToSink(param, fn.body, fn.startLine);
262
+ if (paramAsTainted) {
263
+ taintedParams.set(pIdx, paramAsTainted);
264
+ }
265
+ }
266
+ if (taintedParams.size > 0) {
267
+ taintedExports.push({ file: file.path, exportName: exportedName === "default" ? fn.name : exportedName, taintedParams });
268
+ }
269
+ }
270
+ }
271
+ return taintedExports;
272
+ }
273
+ // Taint source patterns
274
+ const TAINT_SOURCES = [
275
+ { pattern: /(?:req|request)\.(?:body|query|params|headers|cookies)\b/g, type: "http-input" },
276
+ { pattern: /(?:formData|searchParams)\.get\s*\(/g, type: "form-input" },
277
+ { pattern: /(?:params|searchParams)\s*[\.\[]/g, type: "url-params" },
278
+ { pattern: /(?:await\s+)?(?:request|req)\.(?:json|text|formData)\s*\(\)/g, type: "request-body" },
279
+ { pattern: /new\s+URL\s*\([\s\S]*?(?:req|request)/g, type: "url-input" },
280
+ { pattern: /(?:event|e)\.(?:target|currentTarget)\.(?:value|textContent|innerHTML)/g, type: "dom-input" },
281
+ ];
282
+ function findTaintedCallSites(files, allImports, taintedExports) {
283
+ const findings = [];
284
+ const exportsByPath = new Map();
285
+ for (const te of taintedExports) {
286
+ const key = stripExtension(te.file);
287
+ const existing = exportsByPath.get(key) ?? [];
288
+ existing.push(te);
289
+ exportsByPath.set(key, existing);
290
+ }
291
+ for (const file of files) {
292
+ const fileImports = allImports.filter(imp => imp.importer === file.path);
293
+ const lines = file.content.split("\n");
294
+ // Find tainted variables in this file
295
+ const taintedVars = [];
296
+ const assignPattern = /(?:const|let|var)\s+([\w$]+)\s*=\s*(.*)/;
297
+ for (let i = 0; i < lines.length; i++) {
298
+ const m = assignPattern.exec(lines[i]);
299
+ if (!m)
300
+ continue;
301
+ for (const src of TAINT_SOURCES) {
302
+ src.pattern.lastIndex = 0;
303
+ if (src.pattern.test(m[2])) {
304
+ taintedVars.push({ name: m[1], line: i + 1, sourceType: src.type });
305
+ break;
306
+ }
307
+ }
308
+ }
309
+ // Propagate taint within file
310
+ let changed = true;
311
+ let iterations = 0;
312
+ const taintedSet = new Set(taintedVars.map(v => v.name));
313
+ while (changed && iterations < 10) {
314
+ changed = false;
315
+ iterations++;
316
+ for (let i = 0; i < lines.length; i++) {
317
+ const m = assignPattern.exec(lines[i]);
318
+ if (!m || taintedSet.has(m[1]))
319
+ continue;
320
+ for (const t of taintedSet) {
321
+ if (m[2].includes(t)) {
322
+ taintedSet.add(m[1]);
323
+ taintedVars.push({ name: m[1], line: i + 1, sourceType: "propagated" });
324
+ changed = true;
325
+ break;
326
+ }
327
+ }
328
+ }
329
+ }
330
+ for (const imp of fileImports) {
331
+ const sourceExports = exportsByPath.get(imp.source) ?? exportsByPath.get(stripExtension(imp.source)) ?? [];
332
+ if (sourceExports.length === 0)
333
+ continue;
334
+ for (const te of sourceExports) {
335
+ let localName = null;
336
+ for (const [local, exported] of imp.names) {
337
+ if (exported === te.exportName) {
338
+ localName = local;
339
+ break;
340
+ }
341
+ }
342
+ if (!localName && imp.defaultName && (te.exportName === "default" || te.exportName === imp.defaultName)) {
343
+ localName = imp.defaultName;
344
+ }
345
+ if (!localName && imp.namespaceName) {
346
+ localName = `${imp.namespaceName}.${te.exportName}`;
347
+ }
348
+ if (!localName)
349
+ continue;
350
+ const callPattern = new RegExp(`(?:await\\s+)?${localName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\(`, "g");
351
+ for (let i = 0; i < lines.length; i++) {
352
+ callPattern.lastIndex = 0;
353
+ if (!callPattern.test(lines[i]))
354
+ continue;
355
+ const args = extractCallArgs(lines[i], localName);
356
+ for (const [paramIdx, sinkInfo] of te.taintedParams) {
357
+ const argAtIdx = args[paramIdx];
358
+ if (!argAtIdx)
359
+ continue;
360
+ const taintSource = taintedVars.find(v => argAtIdx.includes(v.name));
361
+ if (!taintSource) {
362
+ let isInlineTainted = false;
363
+ let inlineSourceType = "";
364
+ for (const src of TAINT_SOURCES) {
365
+ src.pattern.lastIndex = 0;
366
+ if (src.pattern.test(argAtIdx)) {
367
+ isInlineTainted = true;
368
+ inlineSourceType = src.type;
369
+ break;
370
+ }
371
+ }
372
+ if (!isInlineTainted)
373
+ continue;
374
+ findings.push({
375
+ source: { file: file.path, type: inlineSourceType, line: i + 1, variable: "(inline)" },
376
+ sink: { file: te.file, type: sinkInfo.sinkType, line: sinkInfo.sinkLine, code: sinkInfo.sinkCode },
377
+ chain: [
378
+ `[SOURCE] ${inlineSourceType} in ${file.path}:${i + 1}`,
379
+ `[CALL] ${localName}() in ${file.path}:${i + 1}`,
380
+ `[SINK] ${sinkInfo.sinkType} in ${te.file}:${sinkInfo.sinkLine}`,
381
+ ],
382
+ severity: deriveSeverity(sinkInfo.sinkType),
383
+ description: `Tainted data flows from ${file.path} through ${localName}() into ${sinkInfo.sinkType} sink in ${te.file}.`,
384
+ fix: `Validate/sanitize input before passing to ${localName}(). ${getSinkFix(sinkInfo.sinkType)}`,
385
+ });
386
+ continue;
387
+ }
388
+ findings.push({
389
+ source: { file: file.path, type: taintSource.sourceType, line: taintSource.line, variable: taintSource.name },
390
+ sink: { file: te.file, type: sinkInfo.sinkType, line: sinkInfo.sinkLine, code: sinkInfo.sinkCode },
391
+ chain: [
392
+ `[SOURCE] ${taintSource.sourceType} -> ${taintSource.name} in ${file.path}:${taintSource.line}`,
393
+ `[CALL] ${localName}(${taintSource.name}) in ${file.path}:${i + 1}`,
394
+ `[SINK] ${sinkInfo.sinkType} in ${te.file}:${sinkInfo.sinkLine}`,
395
+ ],
396
+ severity: deriveSeverity(sinkInfo.sinkType),
397
+ description: `Tainted data flows from ${taintSource.sourceType} in ${file.path} through ${localName}() into ${sinkInfo.sinkType} sink in ${te.file}.`,
398
+ fix: `Validate/sanitize '${taintSource.name}' before passing to ${localName}(). ${getSinkFix(sinkInfo.sinkType)}`,
399
+ });
400
+ }
401
+ }
402
+ }
403
+ }
404
+ }
405
+ return findings;
406
+ }
407
+ function extractCallArgs(line, funcName) {
408
+ const escapedName = funcName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
409
+ const callMatch = new RegExp(`(?:await\\s+)?${escapedName}\\s*\\((.*)\\)`, "s").exec(line);
410
+ if (!callMatch)
411
+ return [];
412
+ const argsStr = callMatch[1];
413
+ const args = [];
414
+ let depth = 0;
415
+ let current = "";
416
+ for (const ch of argsStr) {
417
+ if (ch === "(" || ch === "[" || ch === "{")
418
+ depth++;
419
+ if (ch === ")" || ch === "]" || ch === "}")
420
+ depth--;
421
+ if (ch === "," && depth === 0) {
422
+ args.push(current.trim());
423
+ current = "";
424
+ }
425
+ else {
426
+ current += ch;
427
+ }
428
+ }
429
+ if (current.trim())
430
+ args.push(current.trim());
431
+ return args;
432
+ }
433
+ function deriveSeverity(sinkType) {
434
+ if (sinkType === "code-injection" || sinkType === "sql-injection")
435
+ return "critical";
436
+ if (sinkType === "xss" || sinkType === "path-traversal")
437
+ return "high";
438
+ return "medium";
439
+ }
440
+ function getSinkFix(sinkType) {
441
+ const fixes = {
442
+ "sql-injection": "Use parameterized queries instead of string interpolation.",
443
+ "code-injection": "Never pass user input to eval() or Function constructor.",
444
+ "xss": "Use textContent instead of innerHTML, or sanitize with DOMPurify.",
445
+ "open-redirect": "Validate redirect URLs against a trusted domain allowlist.",
446
+ "path-traversal": "Validate file paths with path.resolve() and check they stay within allowed directories.",
447
+ };
448
+ return fixes[sinkType] ?? "Sanitize input before use in sensitive operations.";
449
+ }
450
+ // --- Public API ---
451
+ export function analyzeCrossFileTaint(files) {
452
+ const perFileFindings = new Map();
453
+ for (const file of files) {
454
+ const lang = detectLang(file.path);
455
+ if (lang === "unknown")
456
+ continue;
457
+ const findings = analyzeTaint(file.content, lang);
458
+ if (findings.length > 0)
459
+ perFileFindings.set(file.path, findings);
460
+ }
461
+ const allImports = [];
462
+ for (const file of files) {
463
+ allImports.push(...parseImports(file.path, file.content));
464
+ }
465
+ const taintedExports = findTaintedExports(files);
466
+ const crossFileFindings = findTaintedCallSites(files, allImports, taintedExports);
467
+ return { crossFileFindings, perFileFindings };
468
+ }
469
+ function detectLang(path) {
470
+ if (/\.(ts|tsx|mts|cts)$/.test(path))
471
+ return "typescript";
472
+ if (/\.(js|jsx|mjs|cjs)$/.test(path))
473
+ return "javascript";
474
+ return "unknown";
475
+ }
476
+ export function formatCrossFileTaintFindings(crossFileFindings, perFileFindings, format) {
477
+ const perFileSummary = [];
478
+ for (const [file, findings] of perFileFindings) {
479
+ perFileSummary.push({ file, findings });
480
+ }
481
+ if (format === "json") {
482
+ return JSON.stringify({
483
+ summary: {
484
+ crossFileFlows: crossFileFindings.length,
485
+ perFileFlows: perFileSummary.reduce((sum, f) => sum + f.findings.length, 0),
486
+ total: crossFileFindings.length + perFileSummary.reduce((sum, f) => sum + f.findings.length, 0),
487
+ critical: crossFileFindings.filter(f => f.severity === "critical").length +
488
+ perFileSummary.reduce((sum, f) => sum + f.findings.filter(ff => ff.severity === "critical").length, 0),
489
+ high: crossFileFindings.filter(f => f.severity === "high").length +
490
+ perFileSummary.reduce((sum, f) => sum + f.findings.filter(ff => ff.severity === "high").length, 0),
491
+ medium: crossFileFindings.filter(f => f.severity === "medium").length +
492
+ perFileSummary.reduce((sum, f) => sum + f.findings.filter(ff => ff.severity === "medium").length, 0),
493
+ },
494
+ crossFileFindings: crossFileFindings.map(f => ({
495
+ severity: f.severity, source: f.source, sink: f.sink,
496
+ chain: f.chain, description: f.description, fix: f.fix,
497
+ })),
498
+ perFileFindings: perFileSummary.map(pf => ({
499
+ file: pf.file,
500
+ findings: pf.findings.map(f => ({
501
+ severity: f.severity, source: f.source, sink: f.sink,
502
+ chain: f.chain, description: f.description, fix: f.fix,
503
+ })),
504
+ })),
505
+ });
506
+ }
507
+ const lines = [];
508
+ const totalCross = crossFileFindings.length;
509
+ const totalPerFile = perFileSummary.reduce((sum, f) => sum + f.findings.length, 0);
510
+ lines.push(`## Cross-File Dataflow Analysis`);
511
+ lines.push(``);
512
+ lines.push(`| Scope | Flows |`);
513
+ lines.push(`|-------|-------|`);
514
+ lines.push(`| Cross-file | ${totalCross} |`);
515
+ lines.push(`| Per-file | ${totalPerFile} |`);
516
+ lines.push(`| **Total** | **${totalCross + totalPerFile}** |`);
517
+ lines.push(``);
518
+ if (totalCross > 0) {
519
+ lines.push(`### Cross-File Tainted Flows`);
520
+ lines.push(``);
521
+ const severityOrder = { critical: 0, high: 1, medium: 2 };
522
+ crossFileFindings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
523
+ for (const f of crossFileFindings) {
524
+ lines.push(`#### [${f.severity.toUpperCase()}] ${f.sink.type}`);
525
+ lines.push(`**Source:** \`${f.source.file}\`:${f.source.line} (${f.source.type})`);
526
+ lines.push(`**Sink:** \`${f.sink.file}\`:${f.sink.line} (${f.sink.type})`);
527
+ lines.push(`**Variable:** \`${f.source.variable}\``);
528
+ lines.push(`**Flow chain:**`);
529
+ for (const step of f.chain) {
530
+ lines.push(` ${step}`);
531
+ }
532
+ lines.push(`${f.description}`);
533
+ lines.push(`**Fix:** ${f.fix}`);
534
+ lines.push(``);
535
+ }
536
+ }
537
+ if (totalPerFile > 0) {
538
+ lines.push(`### Per-File Tainted Flows`);
539
+ lines.push(``);
540
+ for (const pf of perFileSummary) {
541
+ lines.push(`**${pf.file}:** ${pf.findings.length} flow(s)`);
542
+ for (const f of pf.findings) {
543
+ lines.push(`- [${f.severity.toUpperCase()}] ${f.source.type} (line ${f.source.line}) -> ${f.sink.type} (line ${f.sink.line})`);
544
+ }
545
+ lines.push(``);
546
+ }
547
+ }
548
+ if (totalCross === 0 && totalPerFile === 0) {
549
+ lines.push(`No tainted data flows detected across files.`);
550
+ }
551
+ return lines.join("\n");
552
+ }
553
+ // Exported for testing
554
+ export { parseImports, parseExports, extractFunctions, findTaintedExports, normalizePath, stripExtension };
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "2.4.4",
4
- "description": "Security MCP for vibe coding. 313 rules, 25 tools for Next.js, Supabase, Clerk, Stripe, Prisma, tRPC, Hono, GraphQL, Convex, Turso, Uploadthing, AI SDK, and the full AI-generated stack.",
3
+ "version": "2.5.0",
4
+ "mcpName": "io.github.goklab/guardvibe",
5
+ "description": "Security MCP for vibe coding. 313 rules, 26 tools for Next.js, Supabase, Clerk, Stripe, Prisma, tRPC, Hono, GraphQL, Convex, Turso, Uploadthing, AI SDK, and the full AI-generated stack.",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "guardvibe": "build/cli.js",
@@ -108,7 +109,7 @@
108
109
  "zod": "^3.25.0"
109
110
  },
110
111
  "devDependencies": {
111
- "@types/node": "^22.0.0",
112
+ "@types/node": "^25.5.2",
112
113
  "c8": "^11.0.0",
113
114
  "eslint": "^10.2.0",
114
115
  "tsx": "^4.21.0",