guardvibe 2.4.5 → 2.7.3

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.
@@ -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 };
@@ -0,0 +1,14 @@
1
+ import type { DoctorScope } from "../server/types.js";
2
+ /**
3
+ * guardvibe_doctor — Unified host hardening scanner
4
+ *
5
+ * Orchestrates multiple analyzers to provide a comprehensive
6
+ * security assessment of AI coding host configuration.
7
+ *
8
+ * Analyzers:
9
+ * 1. MCP Config (audit_mcp_config) — hooks, servers, tool access
10
+ * 2. Host Environment (scan_host_config) — base URL hijack, env sniffing
11
+ * 3. Permissions (inline) — allowedTools wildcards, sensitive paths
12
+ * 4. File Transport (inline) — file:// references, path traversal
13
+ */
14
+ export declare function doctor(projectPath: string, scope?: DoctorScope, format?: "markdown" | "json"): string;