diff --git a/CHANGELOG.md b/CHANGELOG.md index c11e956..8d2d10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [v1.3.5] - 2026-01-26 +### Added +- **Security Report UX**: New DevTools Security tab with scan depth controls and AI toggle. +- **File Scope Picker**: Tree-based file selection to scope security scans precisely. +- **Per-Finding Patch Copy**: Generate and copy suggested patches for individual findings. +- **Snippet Context**: Findings now include surrounding code context for faster review. + +### Improved +- **AI Quality Parsing**: More resilient JSON parsing to prevent malformed AI responses from crashing analysis. + ## [v1.3.4] - 2026-01-25 ### Added - **Chat Export**: Export chats to Markdown with Mermaid rendering. diff --git a/src/app/actions.ts b/src/app/actions.ts index e5fb09d..339ad6c 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -3,7 +3,7 @@ import { getProfile, getRepo, getRepoFileTree, getFileContent, getProfileReadme, getReposReadmes, getFileContentBatch, getUserRepos, getRepoReadme } from "@/lib/github"; import { analyzeFileSelection, answerWithContext, answerWithContextStream } from "@/lib/gemini"; import { scanFiles, getScanSummary, groupBySeverity, type SecurityFinding, type ScanSummary } from "@/lib/security-scanner"; -import { analyzeCodeWithGemini } from "@/lib/gemini-security"; +import { analyzeCodeWithGemini, generateSecurityPatch } from "@/lib/gemini-security"; import { countTokens } from "@/lib/tokens"; import type { StreamUpdate } from "@/lib/streaming-types"; @@ -328,16 +328,90 @@ export async function* processProfileQueryStream( * Scan repository for security vulnerabilities * Uses pattern-based detection + Gemini AI analysis */ +export interface SecurityScanOptions { + includePatterns?: string[]; + excludePatterns?: string[]; + maxFiles?: number; + depth?: 'quick' | 'deep'; + enableAi?: boolean; + aiMaxFiles?: number; + filePaths?: string[]; +} + +function normalizePatterns(patterns?: string[]): string[] { + if (!patterns) return []; + return patterns.map(p => p.trim()).filter(Boolean); +} + +function buildMatchers(patterns: string[]): RegExp[] { + return patterns.map(pattern => { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(escaped, 'i'); + }); +} + +function matchesAny(path: string, matchers: RegExp[]): boolean { + return matchers.some((matcher) => matcher.test(path)); +} + +function scorePathRisk(path: string): number { + const keywords = [ + 'auth', 'login', 'oauth', 'jwt', 'token', 'session', 'admin', + 'middleware', 'api', 'route', 'controller', 'db', 'sql', + 'payment', 'billing', 'webhook', 'crypto', 'secret' + ]; + const lower = path.toLowerCase(); + return keywords.reduce((score, keyword) => (lower.includes(keyword) ? score + 1 : score), 0); +} + +function extractSnippet(content: string, line?: number, radius: number = 3): string { + const lines = content.split('\n'); + if (lines.length === 0) return ''; + const index = line && line > 0 ? Math.min(line - 1, lines.length - 1) : 0; + const start = Math.max(0, index - radius); + const end = Math.min(lines.length, index + radius + 1); + return lines.slice(start, end).map((text, i) => `${start + i + 1}| ${text}`).join('\n'); +} + +function attachSnippets(findings: SecurityFinding[], files: Array<{ path: string; content: string }>): SecurityFinding[] { + const fileMap = new Map(files.map((f) => [f.path, f.content])); + return findings.map((finding) => { + const content = fileMap.get(finding.file); + if (!content) return finding; + const snippet = extractSnippet(content, finding.line); + return { ...finding, snippet }; + }); +} + export async function scanRepositoryVulnerabilities( owner: string, repo: string, - files: Array<{ path: string; sha?: string }> -): Promise<{ findings: SecurityFinding[]; summary: ScanSummary; grouped: Record }> { + files: Array<{ path: string; sha?: string }>, + options: SecurityScanOptions = {} +): Promise<{ findings: SecurityFinding[]; summary: ScanSummary; grouped: Record; meta: { depth: 'quick' | 'deep'; aiEnabled: boolean; maxFiles: number; aiFilesSelected: number; durationMs: number } }> { try { + const startedAt = Date.now(); + const depth = options.depth || 'quick'; + const maxFiles = options.maxFiles || (depth === 'deep' ? 60 : 20); + const aiEnabled = options.enableAi !== false; + const aiMaxFiles = options.aiMaxFiles || (depth === 'deep' ? 25 : 10); + + const includeMatchers = buildMatchers(normalizePatterns(options.includePatterns)); + const excludeMatchers = buildMatchers(normalizePatterns(options.excludePatterns)); + // Select relevant files for security scanning (focus on code files) - const codeFiles = files.filter(f => - /\.(js|jsx|ts|tsx|py|java|php|rb|go|rs)$/i.test(f.path) || f.path === 'package.json' - ).slice(0, 20); // Limit to 20 files for performance + const selectedPaths = options.filePaths ? new Set(options.filePaths) : null; + + const codeFiles = files.filter(f => { + const isCode = /\.(js|jsx|ts|tsx|py|java|php|rb|go|rs)$/i.test(f.path) || f.path === 'package.json'; + if (!isCode) return false; + if (selectedPaths && !selectedPaths.has(f.path)) return false; + if (includeMatchers.length > 0 && !matchesAny(f.path, includeMatchers)) return false; + if (excludeMatchers.length > 0 && matchesAny(f.path, excludeMatchers)) return false; + return true; + }).slice(0, maxFiles); console.log('🔍 Security Scan: Found', codeFiles.length, 'code files to scan'); console.log('📁 Files to scan:', codeFiles.map(f => f.path)); @@ -367,12 +441,36 @@ export async function scanRepositoryVulnerabilities( // AI-powered analysis (more thorough, uses Gemini) let aiFindings: SecurityFinding[] = []; - try { - aiFindings = await analyzeCodeWithGemini(filesWithContent); - console.log('🤖 AI scan found', aiFindings.length, 'issues'); - } catch (aiError) { - console.warn('AI security analysis failed, continuing with pattern-based results only:', aiError); - // Continue with pattern findings only if AI fails + let aiFilesSelected = 0; + if (aiEnabled) { + try { + const filesByRisk = [...filesWithContent].sort((a, b) => scorePathRisk(b.path) - scorePathRisk(a.path)); + const patternHitFiles = new Set(patternFindings.map(f => f.file)); + const aiCandidates: Array<{ path: string; content: string }> = []; + + for (const file of filesByRisk) { + if (patternHitFiles.has(file.path)) { + aiCandidates.push(file); + } + } + + for (const file of filesByRisk) { + if (aiCandidates.length >= aiMaxFiles) break; + if (!aiCandidates.find(f => f.path === file.path)) { + aiCandidates.push(file); + } + } + + const aiFiles = aiCandidates.slice(0, aiMaxFiles); + aiFilesSelected = aiFiles.length; + if (aiFiles.length > 0) { + aiFindings = await analyzeCodeWithGemini(aiFiles); + console.log('🤖 AI scan found', aiFindings.length, 'issues'); + } + } catch (aiError) { + console.warn('AI security analysis failed, continuing with pattern-based results only:', aiError); + // Continue with pattern findings only if AI fails + } } // Combine and deduplicate findings @@ -384,6 +482,7 @@ export async function scanRepositoryVulnerabilities( const filteredFindings = allFindings.filter(f => !f.confidence || f.confidence !== 'low' ); + const findingsWithSnippets = attachSnippets(filteredFindings, filesWithContent); console.log('✨ After confidence filtering:', filteredFindings.length); console.log('📊 Final results:', filteredFindings); @@ -401,9 +500,18 @@ export async function scanRepositoryVulnerabilities( afterConfidenceFilter: filteredFindings.length }; - const grouped = groupBySeverity(filteredFindings); + const grouped = groupBySeverity(findingsWithSnippets); - return { findings: filteredFindings, summary, grouped }; + const durationMs = Date.now() - startedAt; + const meta = { + depth, + aiEnabled, + maxFiles, + aiFilesSelected, + durationMs + }; + + return { findings: findingsWithSnippets, summary, grouped, meta }; } catch (error: any) { console.error('Vulnerability scanning error:', error); // Provide more detailed error message @@ -412,6 +520,30 @@ export async function scanRepositoryVulnerabilities( } } +export async function generateSecurityPatchForFinding( + owner: string, + repo: string, + finding: SecurityFinding +): Promise<{ patch: string; explanation: string }> { + try { + const content = await getFileContent(owner, repo, finding.file); + const snippet = typeof content === 'string' ? extractSnippet(content, finding.line) : ''; + const result = await generateSecurityPatch({ + filePath: finding.file, + fileContent: typeof content === 'string' ? content : '', + line: finding.line, + description: finding.description, + recommendation: finding.recommendation, + snippet + }); + return result; + } catch (error: any) { + console.error('Generate security patch failed:', error); + return { patch: '', explanation: 'Failed to generate patch.' }; + } +} + + /** * Deduplicate findings based on file, line, and title */ diff --git a/src/components/DevTools.tsx b/src/components/DevTools.tsx index e88439b..35b564f 100644 --- a/src/components/DevTools.tsx +++ b/src/components/DevTools.tsx @@ -1,9 +1,11 @@ import { useState, useEffect } from "react"; import { createPortal } from "react-dom"; -import { Wrench, Search, Shield, FileText, TestTube, Zap, X, Loader2, ChevronRight, HelpCircle } from "lucide-react"; +import { Wrench, Search, Shield, FileText, TestTube, Zap, X, Loader2, HelpCircle, AlertTriangle } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import { toast } from "sonner"; -import { searchRepositoryCode, analyzeFileQuality, generateArtifact } from "@/app/actions"; +import { searchRepositoryCode, analyzeFileQuality, generateArtifact, scanRepositoryVulnerabilities, generateSecurityPatchForFinding } from "@/app/actions"; +import type { SecurityFinding } from "@/lib/security-scanner"; +import { FileTreePicker } from "@/components/FileTreePicker"; interface DevToolsProps { repoContext: { owner: string; repo: string; fileTree: any[] }; @@ -12,7 +14,7 @@ interface DevToolsProps { export function DevTools({ repoContext, onSendMessage }: DevToolsProps) { const [isOpen, setIsOpen] = useState(false); - const [activeTab, setActiveTab] = useState<'search' | 'quality' | 'generate' | 'help'>('search'); + const [activeTab, setActiveTab] = useState<'search' | 'quality' | 'security' | 'generate' | 'help'>('search'); const [loadingOperation, setLoadingOperation] = useState(null); // Search State @@ -22,6 +24,19 @@ export function DevTools({ repoContext, onSendMessage }: DevToolsProps) { // Quality/Gen State const [selectedFile, setSelectedFile] = useState(""); + // Security State + const [securityDepth, setSecurityDepth] = useState<'quick' | 'deep'>('quick'); + const [securityEnableAi, setSecurityEnableAi] = useState(true); + const [showFilePicker, setShowFilePicker] = useState(false); + const [securitySelectedFiles, setSecuritySelectedFiles] = useState([]); + const [securityReport, setSecurityReport] = useState<{ + findings: SecurityFinding[]; + summary: any; + meta: any; + } | null>(null); + const [patches, setPatches] = useState>({}); + const [patchLoadingId, setPatchLoadingId] = useState(null); + const handleSearch = async (e: React.FormEvent) => { e.preventDefault(); if (!searchQuery.trim()) return; @@ -138,6 +153,86 @@ export function DevTools({ repoContext, onSendMessage }: DevToolsProps) { } }; + const handleSecurityScan = async () => { + setLoadingOperation('security'); + setSecurityReport(null); + setPatches({}); + try { + const filesToScan = repoContext.fileTree.map((f: any) => ({ path: f.path, sha: f.sha })); + + const { findings, summary, meta } = await scanRepositoryVulnerabilities( + repoContext.owner, + repoContext.repo, + filesToScan, + { + depth: securityDepth, + enableAi: securityEnableAi, + filePaths: securitySelectedFiles.length > 0 ? securitySelectedFiles : undefined + } + ); + + const filesScanned = summary.debug?.filesSuccessfullyFetched || 0; + let content = `### 🛡️ Security Report\n\n`; + content += `**Depth**: ${meta.depth} \n`; + content += `**AI Analysis**: ${meta.aiEnabled ? `Enabled (${meta.aiFilesSelected} files)` : 'Disabled'} \n`; + content += `**Files Scanned**: ${filesScanned} \n`; + content += `**Duration**: ${(meta.durationMs / 1000).toFixed(1)}s\n\n`; + + if (summary.total === 0) { + content += `✅ No security issues found in the scanned files.\n\n`; + content += `This scan checked for:\n- Secrets in source code\n- Common injection patterns\n- Unsafe crypto usage\n- Dependency risks\n\n`; + } else { + content += `**Findings**: ${summary.total} \n`; + if (summary.critical > 0) content += `🔴 Critical: ${summary.critical} \n`; + if (summary.high > 0) content += `🟠 High: ${summary.high} \n`; + if (summary.medium > 0) content += `🟡 Medium: ${summary.medium} \n`; + if (summary.low > 0) content += `🔵 Low: ${summary.low} \n`; + if (summary.info > 0) content += `⚪ Info: ${summary.info} \n`; + content += `\n`; + + content += `View full findings in Dev Tools → Security.\n`; + } + + onSendMessage("model", content); + setSecurityReport({ findings, summary, meta }); + setShowFilePicker(false); + } catch (error: any) { + if (error?.message) { + toast.error("Security scan failed", { description: error.message }); + } else { + toast.error("Security scan failed"); + } + } finally { + setLoadingOperation(null); + } + }; + + const handleGeneratePatch = async (finding: SecurityFinding) => { + const id = `${finding.file}:${finding.line || 0}:${finding.title}`; + setPatchLoadingId(id); + try { + const result = await generateSecurityPatchForFinding( + repoContext.owner, + repoContext.repo, + finding + ); + setPatches((prev) => ({ ...prev, [id]: result })); + } catch (error) { + toast.error("Patch generation failed"); + } finally { + setPatchLoadingId(null); + } + }; + + const handleCopy = async (text: string, successMessage: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success(successMessage); + } catch { + toast.error("Copy failed"); + } + }; + const [mounted, setMounted] = useState(false); useEffect(() => { @@ -200,6 +295,13 @@ export function DevTools({ repoContext, onSendMessage }: DevToolsProps) { > Generate + + + + +
+
File Scope
+ +
+ {showFilePicker && ( + /\.(js|jsx|ts|tsx|py|java|php|rb|go|rs|json)$/.test(f.path))} + selected={securitySelectedFiles} + onChange={setSecuritySelectedFiles} + /> + )} + {!showFilePicker && ( +
+ {securitySelectedFiles.length > 0 + ? `${securitySelectedFiles.length} files selected` + : 'All code files will be scanned.'} +
+ )} +
+
AI Analysis
+ +
+ + + {securityReport && ( +
+
+
Report Summary
+
+ {securityReport.summary.total} issues • {securityReport.meta.depth} scan • {securityReport.meta.aiEnabled ? `AI on (${securityReport.meta.aiFilesSelected} files)` : 'AI off'} +
+
+
+ {securityReport.findings.length === 0 && ( +
No issues found.
+ )} + {securityReport.findings.map((finding) => { + const id = `${finding.file}:${finding.line || 0}:${finding.title}`; + const patch = patches[id]; + const isLoadingPatch = patchLoadingId === id; + return ( +
+
+
+
{finding.title}
+
+ {finding.severity.toUpperCase()} • {finding.file}{finding.line ? `:${finding.line}` : ''} +
+
+ +
+
{finding.description}
+ {finding.snippet && ( +
+                                                                            {finding.snippet}
+                                                                        
+ )} +
Fix: {finding.recommendation}
+ {patch && ( +
+
{patch.explanation}
+ {patch.patch && ( +
+
+ +
+
+                                                                                        {patch.patch}
+                                                                                    
+
+ )} +
+ )} +
+ ); + })} +
+
+ )} + + )} + {activeTab === 'help' && (
@@ -379,6 +620,18 @@ export function DevTools({ repoContext, onSendMessage }: DevToolsProps) {
  • Copy the generated Jest code block.
  • +
    +

    + + How to Run a Security Scan +

    +
      +
    1. Click Security.
    2. +
    3. Pick depth and optional include/exclude patterns.
    4. +
    5. Click Run Security Scan.
    6. +
    7. Review the report in chat.
    8. +
    +
    )} diff --git a/src/components/FileTreePicker.tsx b/src/components/FileTreePicker.tsx new file mode 100644 index 0000000..eff778d --- /dev/null +++ b/src/components/FileTreePicker.tsx @@ -0,0 +1,228 @@ +import { useMemo, useState } from "react"; +import { ChevronRight, Folder, FileText, CheckSquare, Square } from "lucide-react"; + +interface FileTreePickerProps { + files: Array<{ path: string }>; + selected: string[]; + onChange: (paths: string[]) => void; +} + +interface TreeNode { + name: string; + path: string; + type: 'dir' | 'file'; + children?: TreeNode[]; + filePaths?: string[]; +} + +function buildTree(paths: string[]): TreeNode { + const root: TreeNode = { name: '', path: '', type: 'dir', children: [], filePaths: [] }; + + for (const fullPath of paths) { + const parts = fullPath.split('/').filter(Boolean); + let current = root; + current.filePaths?.push(fullPath); + + parts.forEach((part, index) => { + const isLast = index === parts.length - 1; + if (isLast) { + current.children = current.children || []; + current.children.push({ + name: part, + path: fullPath, + type: 'file' + }); + return; + } + + current.children = current.children || []; + let next = current.children.find(c => c.type === 'dir' && c.name === part); + if (!next) { + next = { name: part, path: current.path ? `${current.path}/${part}` : part, type: 'dir', children: [], filePaths: [] }; + current.children.push(next); + } + next.filePaths?.push(fullPath); + current = next; + }); + } + + return root; +} + +function sortTree(node: TreeNode): TreeNode { + if (!node.children) return node; + const children = [...node.children]; + children.sort((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return { + ...node, + children: children.map(child => sortTree(child)) + }; +} + +function filterTree(node: TreeNode, query: string): TreeNode | null { + if (!query.trim()) return node; + const lower = query.toLowerCase(); + if (node.type === 'file') { + return node.path.toLowerCase().includes(lower) ? node : null; + } + + const children = (node.children || []) + .map(child => filterTree(child, query)) + .filter(Boolean) as TreeNode[]; + + if (children.length > 0 || node.path.toLowerCase().includes(lower)) { + return { ...node, children }; + } + + return null; +} + +function FileNode({ + node, + level, + selectedSet, + expandedSet, + toggleExpand, + toggleFile, + toggleDirectory +}: { + node: TreeNode; + level: number; + selectedSet: Set; + expandedSet: Set; + toggleExpand: (path: string) => void; + toggleFile: (path: string) => void; + toggleDirectory: (paths: string[]) => void; +}) { + const paddingLeft = 12 + level * 12; + if (node.type === 'file') { + const isChecked = selectedSet.has(node.path); + return ( +
    + + + {node.name} +
    + ); + } + + const isExpanded = expandedSet.has(node.path); + const dirPaths = node.filePaths || []; + const allSelected = dirPaths.length > 0 && dirPaths.every(path => selectedSet.has(path)); + + return ( +
    +
    + + + + {node.name || 'root'} +
    + {isExpanded && ( +
    + {(node.children || []).map(child => ( + + ))} +
    + )} +
    + ); +} + +export function FileTreePicker({ files, selected, onChange }: FileTreePickerProps) { + const [search, setSearch] = useState(''); + const [expanded, setExpanded] = useState>(new Set([''])); + const selectedSet = useMemo(() => new Set(selected), [selected]); + + const allPaths = useMemo(() => files.map((f) => f.path), [files]); + const tree = useMemo(() => sortTree(buildTree(allPaths)), [allPaths]); + const filteredTree = useMemo(() => filterTree(tree, search) || tree, [tree, search]); + + const toggleExpand = (path: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const toggleFile = (path: string) => { + const next = new Set(selectedSet); + if (next.has(path)) next.delete(path); + else next.add(path); + onChange(Array.from(next)); + }; + + const toggleDirectory = (paths: string[]) => { + const next = new Set(selectedSet); + const allSelected = paths.every(path => next.has(path)); + if (allSelected) { + paths.forEach(path => next.delete(path)); + } else { + paths.forEach(path => next.add(path)); + } + onChange(Array.from(next)); + }; + + return ( +
    + setSearch(e.target.value)} + className="w-full bg-zinc-950 border border-white/10 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-purple-500/50 outline-none text-sm" + placeholder="Filter files..." + /> +
    + {(filteredTree.children || []).map(child => ( + + ))} +
    +
    + + + {selected.length} selected +
    +
    + ); +} diff --git a/src/lib/gemini-security.ts b/src/lib/gemini-security.ts index 0c30a22..e3c184b 100644 --- a/src/lib/gemini-security.ts +++ b/src/lib/gemini-security.ts @@ -197,6 +197,79 @@ Be extremely conservative. False alarms erode trust. } } +function extractJsonPayload(text: string): string | null { + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start === -1 || end === -1 || end <= start) return null; + return text.slice(start, end + 1); +} + +export async function generateSecurityPatch(params: { + filePath: string; + fileContent: string; + line?: number; + description: string; + recommendation: string; + snippet?: string; +}): Promise<{ patch: string; explanation: string }> { + try { + const model = genAI.getGenerativeModel({ + model: 'gemini-2.5-flash' + }); + + const contextSnippet = params.snippet || ''; + const lineInfo = params.line ? `Line: ${params.line}` : 'Line: unknown'; + + const prompt = ` +You are a security engineer. Generate a minimal, safe fix for the vulnerability. + +File: ${params.filePath} +${lineInfo} + +Issue: +${params.description} + +Recommendation: +${params.recommendation} + +Context snippet: +\`\`\` +${contextSnippet} +\`\`\` + +Full file (may be truncated): +\`\`\` +${params.fileContent.slice(0, 8000)} +${params.fileContent.length > 8000 ? '\n... (truncated)' : ''} +\`\`\` + +Return ONLY valid JSON with keys: +- "patch": a unified diff with --- a/${params.filePath} and +++ b/${params.filePath} +- "explanation": a short explanation of the fix + +Do not include markdown fences.`; + + const result = await model.generateContent(prompt); + const text = result.response.text(); + const jsonPayload = extractJsonPayload(text); + if (!jsonPayload) { + return { patch: text.trim(), explanation: 'Model response did not include JSON.' }; + } + + const parsed = JSON.parse(jsonPayload); + return { + patch: String(parsed.patch || '').trim(), + explanation: String(parsed.explanation || '').trim() + }; + } catch (error: any) { + console.error('Gemini patch generation error:', error); + return { + patch: '', + explanation: 'Failed to generate patch.' + }; + } +} + /** * Validate AI findings to prevent false positives */ diff --git a/src/lib/quality-analyzer.ts b/src/lib/quality-analyzer.ts index 477a74d..971b5c4 100644 --- a/src/lib/quality-analyzer.ts +++ b/src/lib/quality-analyzer.ts @@ -123,10 +123,8 @@ export async function analyzeCodeQuality( const result = await model.generateContent(prompt); const text = result.response.text(); - const jsonMatch = text.match(/\{[\s\S]*\}/); - - if (jsonMatch) { - const aiReport = JSON.parse(jsonMatch[0]); + const aiReport = parseAiJson(text); + if (aiReport) { return { metrics, score: aiReport.score, @@ -152,3 +150,25 @@ export async function analyzeCodeQuality( }] : [] }; } + +function parseAiJson(text: string): any | null { + const stripped = text + .replace(/```json/g, '```') + .replace(/```/g, '') + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'"); + + const firstBrace = stripped.indexOf('{'); + const lastBrace = stripped.lastIndexOf('}'); + if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) return null; + + const raw = stripped.slice(firstBrace, lastBrace + 1); + const cleaned = raw.replace(/,\s*([}\]])/g, '$1'); + + try { + return JSON.parse(cleaned); + } catch (error) { + console.error('AI JSON parse failed:', error); + return null; + } +} diff --git a/src/lib/security-scanner.ts b/src/lib/security-scanner.ts index 5bb7011..d55accb 100644 --- a/src/lib/security-scanner.ts +++ b/src/lib/security-scanner.ts @@ -10,6 +10,7 @@ export interface SecurityFinding { description: string; file: string; line?: number; + snippet?: string; recommendation: string; cwe?: string; cvss?: number;