diff --git a/app/analyze/[id]/AnalyzeClient.tsx b/app/analyze/[id]/AnalyzeClient.tsx index fde8a10..be3c774 100644 --- a/app/analyze/[id]/AnalyzeClient.tsx +++ b/app/analyze/[id]/AnalyzeClient.tsx @@ -10,6 +10,8 @@ import { FilterBar } from '@/components/FilterBar'; import { FingerprintCard } from '@/components/FingerprintCard'; import { LoadingState } from '@/components/LoadingState'; import { ProgressBar } from '@/components/ProgressBar'; +import { SecurityCollapseBanner } from '@/components/SecurityCollapseBanner'; +import { SecurityOverview } from '@/components/SecurityOverview'; import type { AnalysisRecord, DebtNode, @@ -33,6 +35,12 @@ function AnalyzeContent() { maxScore: 100, nodeTypes: ['function', 'class', 'module', 'variable'], search: '', + criticalSecurityOnly: false, + owaspCategories: [], + cweCategories: [], + securityScoreThreshold: 0, + secretLeaksOnly: false, + injectionOnly: false, }); const poll = useCallback(async () => { @@ -94,8 +102,15 @@ function AnalyzeContent() { const filteredNodes = useMemo(() => { return nodes.filter((n) => { + if (n.debt_score > filter.maxScore) return false; if (n.debt_score < filter.minScore) return false; + if (n.security_score < filter.securityScoreThreshold) return false; if (!filter.nodeTypes.includes(n.node_type)) return false; + if (filter.criticalSecurityOnly && !n.has_critical_security) return false; + if (filter.owaspCategories.length > 0 && !n.owasp_categories.some((category) => filter.owaspCategories.includes(category))) return false; + if (filter.cweCategories.length > 0 && !n.cwe_categories.some((category) => filter.cweCategories.includes(category))) return false; + if (filter.secretLeaksOnly && !n.security_findings.some((finding) => finding.category === 'Secrets')) return false; + if (filter.injectionOnly && !n.security_findings.some((finding) => finding.category === 'Injection' || finding.owaspIds.includes('A03'))) return false; if (filter.search) { const q = filter.search.toLowerCase(); if ( @@ -116,6 +131,39 @@ function AnalyzeContent() { ); }, [links, filteredNodes]); + const availableOwaspCategories = useMemo( + () => [...new Set(nodes.flatMap((node) => node.owasp_categories ?? []))].sort(), + [nodes] + ); + + const availableCweCategories = useMemo( + () => [...new Set(nodes.flatMap((node) => node.cwe_categories ?? []))].sort(), + [nodes] + ); + + const securityFindings = useMemo( + () => nodes.flatMap((node) => node.security_findings ?? []), + [nodes] + ); + + const collapseBanner = useMemo(() => { + if (!analysis?.security_collapse) return null; + const affectedCoreModules = [...new Set(nodes.filter((node) => node.has_critical_security).map((node) => node.file_path))].slice(0, 8); + const reasons = [ + `Critical vulnerabilities: ${analysis.critical_vulnerabilities}`, + `Repository security score: ${analysis.repo_security_score.toFixed(1)}/100`, + `Affected modules: ${affectedCoreModules.length}`, + ]; + const severity: 'critical' | 'high' | 'moderate' = analysis.repo_security_score > 85 ? 'critical' : 'high'; + return { + isCollapsed: true, + severity, + reasons, + affectedCoreModules, + propagationRisk: Math.min(100, Math.round(analysis.repo_security_score * 0.8 + analysis.critical_vulnerabilities * 6)), + }; + }, [analysis, nodes]); + const isLoading = !analysis || (analysis.status !== 'complete' && analysis.status !== 'failed'); @@ -129,7 +177,7 @@ function AnalyzeContent() { {isRateLimit ? 'GitHub Quota Paused' : 'Analysis Failed'}

{error}

- + {isRateLimit ? (
+ {collapseBanner && ( + + )} + + + {/* Dynamic visual graph and filters block */}
@@ -271,6 +325,8 @@ function AnalyzeContent() { filter={filter} onChange={setFilter} nodeCount={filteredNodes.length} + availableOwaspCategories={availableOwaspCategories} + availableCweCategories={availableCweCategories} />
diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index 356f344..d81df23 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -7,6 +7,9 @@ import { computeDuplicationScores } from '@/lib/duplication'; import { computeBlastRadius, buildGraphData } from '@/lib/blast-radius'; import { scoreAllSymbols, averageScore } from '@/lib/debt-scorer'; import { classifyAgentCode } from '@/lib/huggingface'; +import { analyzeSecurityRepository, getSecurityNodeMetrics } from '@/lib/security/detector'; +import { computeSecurityAwarePriority } from '@/lib/security/security-scorer'; +import { isSecuritySensitivePath } from '@/lib/security/security-utils'; export const maxDuration = 300; @@ -206,6 +209,24 @@ async function runPipeline( console.error("[Pipeline Warning] Duplication analysis failed:", dupErr); } + console.log("[Pipeline] Progress: 52"); + await updateAnalysisProgress(analysisId, { + status: 'scoring', + progress: 52, + progress_message: 'security analysis', + }); + + let securityResult = null as Awaited> | null; + try { + securityResult = analyzeSecurityRepository({ + files, + symbols, + blastRadiusMap: new Map(), + }); + } catch (securityErr) { + console.error('[Pipeline Warning] Security detection failed:', securityErr); + } + let blastRadiusMap = new Map(); try { blastRadiusMap = computeBlastRadius(symbols); @@ -220,6 +241,55 @@ async function runPipeline( console.error("[Pipeline Warning] Debt scoring formulas failed:", scoreErr); } + if (!securityResult) { + try { + securityResult = analyzeSecurityRepository({ + files, + symbols, + blastRadiusMap, + }); + } catch (securityErr) { + console.error('[Pipeline Warning] Security analysis retry failed:', securityErr); + } + } + + const securityByFile = new Map>(); + if (securityResult) { + for (const symbol of symbols) { + securityByFile.set(symbol.filePath, getSecurityNodeMetrics(securityResult, symbol.filePath)); + } + } + + const securityPriorityMap = new Map(); + for (const symbol of symbols) { + const metrics = securityByFile.get(symbol.filePath) ?? getSecurityNodeMetrics(securityResult ?? { + findings: [], + summary: { totalVulnerabilities: 0, critical: 0, high: 0, medium: 0, low: 0, score: 0, categoryCounts: {}, owaspCategories: [], cweCategories: [], topFindings: [] }, + collapse: { isCollapsed: false, severity: 'moderate', reasons: [], affectedCoreModules: [], propagationRisk: 0 }, + nodeMetrics: {}, + repoSecurityScore: 0, + criticalVulnerabilities: 0, + }, symbol.filePath); + securityPriorityMap.set( + symbol.id, + computeSecurityAwarePriority({ + debtScore: debtScores.get(symbol.id) ?? 0, + blastRadius: blastRadiusMap.get(symbol.id) ?? 0, + securityScore: metrics.securityScore, + }) + ); + } + + const securitySummary = securityResult?.summary ?? null; + const repoSecurityScore = securityResult?.repoSecurityScore ?? 0; + const collapseResult = securityResult?.collapse ?? { + isCollapsed: false, + severity: 'moderate' as const, + reasons: [], + affectedCoreModules: [], + propagationRisk: 0, + }; + const avgScore = averageScore(debtScores); // 4. Isolated dependency graph generation (Requirement 10) @@ -230,12 +300,16 @@ async function runPipeline( progress_message: 'generating graph', total_nodes: symbols.length, avg_debt_score: avgScore, + security_summary: securitySummary, + security_collapse: collapseResult.isCollapsed, + critical_vulnerabilities: securityResult?.criticalVulnerabilities ?? 0, + repo_security_score: repoSecurityScore, }); let topSymbols: any[] = []; let links: any[] = []; try { - const graphData = buildGraphData(symbols, debtScores, blastRadiusMap); + const graphData = buildGraphData(symbols, debtScores, blastRadiusMap, securityPriorityMap); topSymbols = graphData.topSymbols; links = graphData.links; } catch (graphErr) { @@ -280,9 +354,17 @@ async function runPipeline( line_start: sym.lineStart, line_end: sym.lineEnd, debt_score: debtScores.get(sym.id) ?? 0, + security_score: securityByFile.get(sym.filePath)?.securityScore ?? 0, + security_weighted_score: securityByFile.get(sym.filePath)?.securityWeightedScore ?? 0, + has_critical_security: securityByFile.get(sym.filePath)?.hasCriticalSecurity ?? false, + vulnerability_count: securityByFile.get(sym.filePath)?.vulnerabilityCount ?? 0, + security_risk_level: securityByFile.get(sym.filePath)?.securityRiskLevel ?? 'none', complexity: sym.complexity, duplication_score: duplicationScores.get(sym.filePath) ?? 0, blast_radius: blastRadiusMap.get(sym.id) ?? 0, + owasp_categories: securityByFile.get(sym.filePath)?.owaspCategories ?? [], + cwe_categories: securityByFile.get(sym.filePath)?.cweCategories ?? [], + security_findings: securityByFile.get(sym.filePath)?.securityFindings ?? [], dependencies: sym.calls.filter((c: string) => topIds.has(c)), dependents: sym.calledBy.filter((c: string) => topIds.has(c)), explanation: null, @@ -296,9 +378,21 @@ async function runPipeline( for (let i = 0; i < nodesToInsert.length; i += batchSize) { const batch = nodesToInsert.slice(i, i + batchSize); const { error } = await supabase.from('debt_nodes').insert(batch); - if (error) { + if (!error) continue; + + const errorMessage = error.message ?? ''; + const isSchemaCacheError = /schema cache|Could not find the '.+' column/i.test(errorMessage); + if (!isSchemaCacheError) { console.error(`[Pipeline Error] Nodes insertion batch failed:`, error); - throw new Error(`Failed to save nodes in database: ${error.message}`); + throw new Error(`Failed to save nodes in database: ${errorMessage}`); + } + + console.warn('[Pipeline Warning] Retrying node insert without security columns because Supabase schema cache is stale:', errorMessage); + const fallbackBatch = batch.map(({ security_score, security_weighted_score, has_critical_security, vulnerability_count, security_risk_level, owasp_categories, cwe_categories, security_findings, ...rest }) => rest); + const fallbackInsert = await supabase.from('debt_nodes').insert(fallbackBatch); + if (fallbackInsert.error) { + console.error('[Pipeline Error] Fallback insert also failed:', fallbackInsert.error); + throw new Error(`Failed to save nodes in database: ${fallbackInsert.error.message}`); } } @@ -312,6 +406,10 @@ async function runPipeline( avg_debt_score: avgScore, fingerprint_label: fingerprintLabel ?? undefined, fingerprint_confidence: fingerprintConfidence ?? undefined, + security_summary: securitySummary, + security_collapse: collapseResult.isCollapsed, + critical_vulnerabilities: securityResult?.criticalVulnerabilities ?? 0, + repo_security_score: repoSecurityScore, }); console.log(`[Pipeline] Job ${analysisId} completed successfully.`); diff --git a/app/api/explain/route.ts b/app/api/explain/route.ts index cfd4303..28c0ffb 100644 --- a/app/api/explain/route.ts +++ b/app/api/explain/route.ts @@ -91,6 +91,12 @@ export async function POST(request: NextRequest) { complexity: node.complexity || 0, blastRadius: node.blast_radius || 0, codeSnippet, + securityScore: node.security_score ?? 0, + vulnerabilityCount: node.vulnerability_count ?? 0, + securityRiskLevel: node.security_risk_level ?? 'none', + owaspCategories: node.owasp_categories ?? [], + cweCategories: node.cwe_categories ?? [], + securityFindings: node.security_findings ?? [], }); // Save back to DB diff --git a/app/globals.css b/app/globals.css index b03e356..f3bdbf6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,16 +4,24 @@ :root { /* Premium Warm Theme CSS Tokens */ - --bg-primary-start: #fffdf9; /* #fffdf9 */ - --bg-primary-end: #faf7f3; /* #faf7f3 */ - --bg-primary: #f7f2ec; /* #f7f2ec */ - - --bg-glass: rgba(255, 255, 255, 0.6); /* Exact vertical sidebar/glass background */ - --border-glass: rgba(176, 122, 77, 0.14); /* Exact border spec: rgba(176,122,77,0.14) */ - - --text-primary: #2b2622; /* #2b2622 */ - --text-muted: #8f8175; /* #8f8175 */ - --accent-cyan: #9a6a43; /* Primary accent: #9a6a43 */ + --bg-primary-start: #fffdf9; + /* #fffdf9 */ + --bg-primary-end: #faf7f3; + /* #faf7f3 */ + --bg-primary: #f7f2ec; + /* #f7f2ec */ + + --bg-glass: rgba(255, 255, 255, 0.6); + /* Exact vertical sidebar/glass background */ + --border-glass: rgba(176, 122, 77, 0.14); + /* Exact border spec: rgba(176,122,77,0.14) */ + + --text-primary: #2b2622; + /* #2b2622 */ + --text-muted: #8f8175; + /* #8f8175 */ + --accent-cyan: #9a6a43; + /* Primary accent: #9a6a43 */ } body { @@ -30,7 +38,7 @@ body { backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid var(--border-glass); - box-shadow: + box-shadow: 0 8px 30px -8px rgba(176, 122, 77, 0.05), 0 1px 1px rgba(255, 255, 255, 0.8) inset; } @@ -61,10 +69,27 @@ body { animation: pulse-ring 2s ease-out infinite; } +.security-critical-node { + animation: security-pulse 1.8s ease-in-out infinite; +} + +.security-collapsed-node { + animation: security-collapse 1.25s steps(2, end) infinite; +} + +.security-warning-ring { + animation: security-ring 1.6s ease-in-out infinite; +} + +.security-critical-marker { + animation: security-marker 1.6s ease-in-out infinite; +} + /* Custom premium micro-animations (Requirement 8) */ .hover-lift { transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1); } + .hover-lift:hover { transform: translateY(-2px) scale(1.01); box-shadow: 0 20px 40px -15px rgba(61, 47, 34, 0.06), 0 1px 3px rgba(139, 115, 85, 0.03); @@ -79,6 +104,7 @@ body { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); @@ -89,10 +115,60 @@ body { 0% { box-shadow: 0 0 0 0 rgba(20, 184, 166, 0.45); } + 70% { box-shadow: 0 0 0 8px rgba(20, 184, 166, 0); } + 100% { box-shadow: 0 0 0 0 rgba(20, 184, 166, 0); } } + +@keyframes security-pulse { + 0%, + 100% { + opacity: 0.86; + filter: drop-shadow(0 0 0 rgba(143, 29, 29, 0)); + } + + 50% { + opacity: 1; + filter: drop-shadow(0 0 12px rgba(143, 29, 29, 0.45)); + } +} + +@keyframes security-collapse { + 0%, + 100% { + stroke-opacity: 0.95; + } + + 50% { + stroke-opacity: 0.45; + } +} + +@keyframes security-ring { + 0%, + 100% { + opacity: 0.7; + } + + 50% { + opacity: 1; + } +} + +@keyframes security-marker { + 0%, + 100% { + opacity: 0.65; + transform: translateY(0); + } + + 50% { + opacity: 1; + transform: translateY(-2px); + } +} \ No newline at end of file diff --git a/app/roadmap/[id]/page.tsx b/app/roadmap/[id]/page.tsx index e1a418e..e9565f7 100644 --- a/app/roadmap/[id]/page.tsx +++ b/app/roadmap/[id]/page.tsx @@ -58,8 +58,7 @@ export default function RoadmapPage() {
{analysis && (

- {analysis.repo_owner}/{analysis.repo_name} — prioritized by debt score - and blast radius + {analysis.repo_owner}/{analysis.repo_name} — prioritized by debt and security risk

)}
@@ -76,6 +75,8 @@ export default function RoadmapPage() { + +
)} diff --git a/components/FilterBar.tsx b/components/FilterBar.tsx index a732668..8380711 100644 --- a/components/FilterBar.tsx +++ b/components/FilterBar.tsx @@ -7,6 +7,8 @@ interface FilterBarProps { filter: FilterState; onChange: (filter: FilterState) => void; nodeCount: number; + availableOwaspCategories: string[]; + availableCweCategories: string[]; } const NODE_TYPES: DebtNode['node_type'][] = [ @@ -16,7 +18,13 @@ const NODE_TYPES: DebtNode['node_type'][] = [ 'variable', ]; -export function FilterBar({ filter, onChange, nodeCount }: FilterBarProps) { +export function FilterBar({ + filter, + onChange, + nodeCount, + availableOwaspCategories, + availableCweCategories, +}: FilterBarProps) { return (
@@ -52,6 +60,94 @@ export function FilterBar({ filter, onChange, nodeCount }: FilterBarProps) { />
+
+ + onChange({ ...filter, securityScoreThreshold: Number(e.target.value) })} + className="w-full accent-[#8f1d1d] cursor-pointer" + /> +
+ +
+ onChange({ ...filter, criticalSecurityOnly: !filter.criticalSecurityOnly })} + /> + onChange({ ...filter, secretLeaksOnly: !filter.secretLeaksOnly })} + /> + onChange({ ...filter, injectionOnly: !filter.injectionOnly })} + /> +
+ +
+

OWASP

+
+ {availableOwaspCategories.length === 0 ? ( + No OWASP categories + ) : ( + availableOwaspCategories.map((category) => { + const active = filter.owaspCategories.includes(category); + return ( + + ); + }) + )} +
+
+ +
+

CWE

+
+ {availableCweCategories.length === 0 ? ( + No CWE categories + ) : ( + availableCweCategories.map((category) => { + const active = filter.cweCategories.includes(category); + return ( + + ); + }) + )} +
+
+
{NODE_TYPES.map((type) => { const isActive = filter.nodeTypes.includes(type); @@ -65,11 +161,10 @@ export function FilterBar({ filter, onChange, nodeCount }: FilterBarProps) { : [...filter.nodeTypes, type]; onChange({ ...filter, nodeTypes: types }); }} - className={`text-xs px-3 py-1.5 rounded-xl capitalize font-semibold transition-all ${ - isActive + className={`text-xs px-3 py-1.5 rounded-xl capitalize font-semibold transition-all ${isActive ? 'bg-gradient-to-r from-[#b07b4f] to-[#8c6239] text-white shadow-sm hover:opacity-95' : 'bg-[#efe8de]/70 text-slate-600 hover:text-slate-800 hover:bg-[#e5d9c8]/70 border border-transparent' - }`} + }`} > {type} @@ -79,3 +174,23 @@ export function FilterBar({ filter, onChange, nodeCount }: FilterBarProps) {
); } + +function ToggleButton({ + active, + label, + onClick, +}: { + active: boolean; + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/components/HeatMap.tsx b/components/HeatMap.tsx index 1cf5e5e..edb7e91 100644 --- a/components/HeatMap.tsx +++ b/components/HeatMap.tsx @@ -17,9 +17,17 @@ interface HeatMapProps { interface SimNode extends d3.SimulationNodeDatum { id: string; debt_score: number; + security_score: number; + security_weighted_score: number; + has_critical_security: boolean; + vulnerability_count: number; + security_risk_level: DebtNode['security_risk_level']; symbol_name: string; file_path: string; blast_radius: number; + owasp_categories: string[]; + cwe_categories: string[]; + security_findings: DebtNode['security_findings']; x?: number; y?: number; fx?: number | null; @@ -66,15 +74,30 @@ export function HeatMap({ .attr('y', '-100%') .attr('width', '300%') .attr('height', '300%'); - + glowFilter.append('feGaussianBlur') .attr('stdDeviation', '6') .attr('result', 'blur'); - + const feMerge = glowFilter.append('feMerge'); feMerge.append('feMergeNode').attr('in', 'blur'); feMerge.append('feMergeNode').attr('in', 'SourceGraphic'); + const securityGlow = defs.append('filter') + .attr('id', 'security-glow') + .attr('x', '-120%') + .attr('y', '-120%') + .attr('width', '340%') + .attr('height', '340%'); + + securityGlow.append('feGaussianBlur') + .attr('stdDeviation', '4') + .attr('result', 'blur'); + + const securityMerge = securityGlow.append('feMerge'); + securityMerge.append('feMergeNode').attr('in', 'blur'); + securityMerge.append('feMergeNode').attr('in', 'SourceGraphic'); + const zoom = d3 .zoom() .scaleExtent([0.15, 5]) @@ -88,9 +111,17 @@ export function HeatMap({ const simNodes: SimNode[] = nodes.map((n) => ({ id: n.id, debt_score: n.debt_score, + security_score: n.security_score ?? 0, + security_weighted_score: n.security_weighted_score ?? 0, + has_critical_security: n.has_critical_security ?? false, + vulnerability_count: n.vulnerability_count ?? 0, + security_risk_level: n.security_risk_level ?? 'none', symbol_name: n.symbol_name, file_path: n.file_path, blast_radius: n.blast_radius, + owasp_categories: n.owasp_categories ?? [], + cwe_categories: n.cwe_categories ?? [], + security_findings: n.security_findings ?? [], x: n.x ?? undefined, y: n.y ?? undefined, })); @@ -172,38 +203,93 @@ export function HeatMap({ .join('circle') .attr('r', (d) => radius(d, selectedId)) .attr('fill', (d) => scoreColor(d.debt_score)) - .attr('stroke', (d) => (d.id === selectedId ? '#b07a4d' : 'rgba(176, 122, 77, 0.25)')) - .attr('stroke-width', (d) => (d.id === selectedId ? 4 : 1.25)) - .attr('opacity', (d) => (d.id === selectedId ? 1 : 0.85)) - .attr('filter', (d) => (d.id === selectedId ? 'url(#node-glow)' : null)) + .attr('stroke', (d) => { + if (d.has_critical_security) return d.security_risk_level === 'critical' ? '#8f1d1d' : '#d85b2b'; + return d.id === selectedId ? '#b07a4d' : 'rgba(176, 122, 77, 0.25)'; + }) + .attr('stroke-width', (d) => { + if (d.has_critical_security) return d.id === selectedId ? 4.5 : 3; + return d.id === selectedId ? 4 : 1.25; + }) + .attr('stroke-dasharray', (d) => (d.has_critical_security && d.security_risk_level === 'critical' ? '5,3' : null)) + .attr('opacity', (d) => (d.id === selectedId ? 1 : 0.88)) + .attr('filter', (d) => (d.has_critical_security ? 'url(#security-glow)' : d.id === selectedId ? 'url(#node-glow)' : null)) + .attr('class', (d) => { + const classes = [] as string[]; + if (d.has_critical_security) classes.push('security-critical-node'); + if (d.has_critical_security && d.security_risk_level === 'critical') classes.push('security-collapsed-node'); + return classes.join(' '); + }) .style('cursor', 'pointer') .on('click', (event, d) => { event.stopPropagation(); const full = nodes.find((n) => n.id === d.id) ?? null; onSelect(full); }) - .on('mouseover', function(event, d) { + .on('mouseover', function (event, d) { d3.select(this) .transition() .duration(200) .attr('r', radius(d, selectedId) * 1.2) .attr('opacity', 1) .attr('stroke-width', 2.5) - .attr('filter', 'url(#node-glow)'); + .attr('filter', d.has_critical_security ? 'url(#security-glow)' : 'url(#node-glow)'); }) - .on('mouseout', function(event, d) { + .on('mouseout', function (event, d) { const isSelected = d.id === selectedId; d3.select(this) .transition() .duration(200) .attr('r', radius(d, selectedId)) .attr('opacity', isSelected ? 1 : 0.85) - .attr('stroke-width', isSelected ? 4 : 1.25) - .attr('filter', isSelected ? 'url(#node-glow)' : null); + .attr('stroke-width', d.has_critical_security ? 3 : isSelected ? 4 : 1.25) + .attr('filter', d.has_critical_security ? 'url(#security-glow)' : isSelected ? 'url(#node-glow)' : null); }); + node.append('title').text((d) => { + const topOwasp = d.owasp_categories?.[0] ?? 'none'; + const debtScore = Number.isFinite(d.debt_score) ? d.debt_score : 0; + const securityScore = Number.isFinite(d.security_score) ? d.security_score : 0; + const vulnerabilityCount = Number.isFinite(d.vulnerability_count) ? d.vulnerability_count : 0; + const riskLevel = d.security_risk_level ?? 'none'; + return [ + d.symbol_name, + `Debt score: ${debtScore.toFixed(1)}`, + `Security score: ${securityScore.toFixed(1)}`, + `Critical vulnerabilities: ${vulnerabilityCount}`, + `Top OWASP: ${topOwasp}`, + `Risk level: ${riskLevel}`, + ].join('\n'); + }); + node.call(dragBehavior); + const criticalRing = g + .append('g') + .selectAll('circle') + .data(simNodes.filter((d) => d.has_critical_security)) + .join('circle') + .attr('fill', 'none') + .attr('stroke', (d) => (d.security_risk_level === 'critical' ? '#8f1d1d' : '#d85b2b')) + .attr('stroke-width', 2.5) + .attr('opacity', 0.75) + .attr('stroke-dasharray', (d) => (d.security_risk_level === 'critical' ? '6,4' : '3,3')) + .attr('class', 'security-warning-ring'); + + const criticalMarker = g + .append('g') + .selectAll('text') + .data(simNodes.filter((d) => d.has_critical_security)) + .join('text') + .text((d) => (d.security_risk_level === 'critical' ? '⚠' : '!')) + .attr('text-anchor', 'middle') + .attr('dy', 4) + .attr('font-size', 12) + .attr('font-weight', 900) + .attr('fill', '#8f1d1d') + .attr('pointer-events', 'none') + .attr('class', 'security-critical-marker'); + const label = g .append('g') .selectAll('text') @@ -227,6 +313,15 @@ export function HeatMap({ node.attr('cx', (d) => d.x ?? 0).attr('cy', (d) => d.y ?? 0); label.attr('x', (d) => d.x ?? 0).attr('y', (d) => d.y ?? 0); + criticalRing + .attr('cx', (d) => d.x ?? 0) + .attr('cy', (d) => d.y ?? 0) + .attr('r', (d) => radius(d, selectedId) + 7); + + criticalMarker + .attr('x', (d) => d.x ?? 0) + .attr('y', (d) => (d.y ?? 0) - radius(d, selectedId) - 2); + if (selectedId && glowCircle1 && glowCircle2) { const sel = simNodes.find(n => n.id === selectedId); if (sel) { @@ -317,14 +412,28 @@ export function HeatMap({ } function radius(d: SimNode, selectedId: string | null): number { - const base = Math.max(6, Math.min(24, 6 + d.blast_radius * 0.35 + d.debt_score * 0.08)); + const base = Math.max(6, Math.min(24, 6 + d.blast_radius * 0.35 + d.debt_score * 0.08 + (d.has_critical_security ? 1.5 : 0))); if (d.id === selectedId) return base * 1.35; // Selected node is 35% larger return base; } function getLinkNode(endpoint: SimNode | string): SimNode { if (typeof endpoint === 'string') { - return { id: endpoint, debt_score: 0, symbol_name: '', file_path: '', blast_radius: 0 }; + return { + id: endpoint, + debt_score: 0, + security_score: 0, + security_weighted_score: 0, + has_critical_security: false, + vulnerability_count: 0, + security_risk_level: 'none', + symbol_name: '', + file_path: '', + blast_radius: 0, + owasp_categories: [], + cwe_categories: [], + security_findings: [], + } as SimNode; } return endpoint; } diff --git a/components/NodeSidebar.tsx b/components/NodeSidebar.tsx index e7a53cf..9931a95 100644 --- a/components/NodeSidebar.tsx +++ b/components/NodeSidebar.tsx @@ -11,6 +11,7 @@ import { } from 'lucide-react'; import type { DebtNode } from '@/types'; import { formatScore, scoreColor, truncate } from '@/lib/utils'; +import { SecurityPanel } from '@/components/SecurityPanel'; interface NodeSidebarProps { node: DebtNode | null; @@ -58,7 +59,7 @@ export function NodeSidebar({ node, analysisId, onClose }: NodeSidebarProps) { }), }); const data = await res.json(); - + if (!res.ok) { throw new Error(data.error ?? 'Failed to generate explanation.'); } @@ -119,6 +120,16 @@ export function NodeSidebar({ node, analysisId, onClose }: NodeSidebarProps) {
+
+
+ Security + + {(node.security_risk_level ?? 'none').toUpperCase()} + +
+ +
+
Code scope: Lines {node.line_start}–{node.line_end} diff --git a/components/OWASPChart.tsx b/components/OWASPChart.tsx new file mode 100644 index 0000000..0d8a718 --- /dev/null +++ b/components/OWASPChart.tsx @@ -0,0 +1,61 @@ +'use client'; + +import type { SecurityFinding, SecuritySummary } from '@/types'; + +interface OWASPChartProps { + summary: SecuritySummary | null | undefined; + findings: SecurityFinding[]; +} + +export function OWASPChart({ summary, findings }: OWASPChartProps) { + const categoryEntries = Object.entries(summary?.categoryCounts ?? {}).sort((a, b) => b[1] - a[1]); + const severity = [ + { label: 'Critical', value: summary?.critical ?? 0, color: '#8f1d1d' }, + { label: 'High', value: summary?.high ?? 0, color: '#d85b2b' }, + { label: 'Medium', value: summary?.medium ?? 0, color: '#f0c04e' }, + { label: 'Low', value: summary?.low ?? 0, color: '#4d84c4' }, + ]; + + return ( +
+
+
+

OWASP Coverage

+

{findings.length} findings mapped to security categories

+
+
+

Security Score

+

{summary?.score ?? 0}

+
+
+ +
+
+ {severity.map((item) => ( + + ))} +
+
+ {categoryEntries.slice(0, 6).map(([label, value]) => ( + + ))} +
+
+
+ ); +} + +function Bar({ label, value, color, total }: { label: string; value: number; color: string; total: number }) { + const width = `${Math.max(5, Math.min(100, (value / total) * 100))}%`; + return ( +
+
+ {label} + {value} +
+
+
+
+
+ ); +} diff --git a/components/RoadmapTable.tsx b/components/RoadmapTable.tsx index aa3380a..5bb12af 100644 --- a/components/RoadmapTable.tsx +++ b/components/RoadmapTable.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { ArrowUpRight, Download } from 'lucide-react'; import type { RoadmapItem } from '@/types'; import { formatScore, scoreColor } from '@/lib/utils'; +import { SecurityBadge } from '@/components/SecurityBadge'; interface RoadmapTableProps { items: RoadmapItem[]; @@ -32,8 +33,11 @@ export function RoadmapTable({ items, analysisId }: RoadmapTableProps) { Symbol File Debt + Security + Critical Vulns + OWASP Blast - Priority + Security Priority @@ -41,7 +45,7 @@ export function RoadmapTable({ items, analysisId }: RoadmapTableProps) { {items.map((item) => ( {item.rank} {item.symbolName} @@ -51,9 +55,16 @@ export function RoadmapTable({ items, analysisId }: RoadmapTableProps) { {formatScore(item.debtScore)} + + = 75 ? 'high' : item.securityScore >= 50 ? 'medium' : 'low'} label={formatScore(item.securityScore)} /> + + {item.vulnerabilityCount} + + {item.owaspCategories.join(', ') || '—'} + {item.blastRadius} - - {formatScore(item.priority)} + + {formatScore(item.securityPriority)} = { + critical: 'text-[#fff] bg-[#8f1d1d] border-[#b83b3b] shadow-[0_0_0_1px_rgba(184,59,59,0.35)]', + high: 'text-[#fff] bg-[#d85b2b] border-[#ef7d4f] shadow-[0_0_0_1px_rgba(239,125,79,0.3)]', + medium: 'text-[#2b2622] bg-[#f0c04e] border-[#f2cf79]', + low: 'text-[#1f4b87] bg-[#d8ecff] border-[#9dc4ef]', +}; + +interface SecurityBadgeProps { + severity: VulnerabilitySeverity | 'none'; + label?: string; + className?: string; +} + +export function SecurityBadge({ severity, label, className = '' }: SecurityBadgeProps) { + if (severity === 'none') { + return ( + + {label ?? 'None'} + + ); + } + + return ( + + {label ?? severity} + + ); +} diff --git a/components/SecurityCollapseBanner.tsx b/components/SecurityCollapseBanner.tsx new file mode 100644 index 0000000..ebd9661 --- /dev/null +++ b/components/SecurityCollapseBanner.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { ShieldAlert, Waves, TriangleAlert } from 'lucide-react'; +import type { SecurityCollapseResult } from '@/types'; + +interface SecurityCollapseBannerProps { + collapse: SecurityCollapseResult | null | undefined; + criticalFindings: number; +} + +export function SecurityCollapseBanner({ collapse, criticalFindings }: SecurityCollapseBannerProps) { + if (!collapse?.isCollapsed) return null; + + return ( +
+
+
+
+
+ +
+
+

+ + Security Collapse Detected +

+

Repository security posture has crossed the collapse threshold.

+

+ {collapse.reasons.join(' ')} +

+
+
+ +
+ + + + +
+
+ + {collapse.affectedCoreModules.length > 0 && ( +
+ {collapse.affectedCoreModules.slice(0, 8).map((module) => ( + + + {module} + + ))} +
+ )} +
+ ); +} + +function Metric({ label, value }: { label: string; value: string | number }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/components/SecurityOverview.tsx b/components/SecurityOverview.tsx new file mode 100644 index 0000000..7c9d56c --- /dev/null +++ b/components/SecurityOverview.tsx @@ -0,0 +1,70 @@ +'use client'; + +import type { AnalysisRecord, DebtNode, SecurityFinding } from '@/types'; +import { SecurityBadge } from '@/components/SecurityBadge'; +import { OWASPChart } from '@/components/OWASPChart'; +import { VulnerabilityTable } from '@/components/VulnerabilityTable'; + +interface SecurityOverviewProps { + analysis: AnalysisRecord | null; + nodes: DebtNode[]; +} + +export function SecurityOverview({ analysis, nodes }: SecurityOverviewProps) { + const securitySummary = analysis?.security_summary; + const findings = nodes.flatMap((node) => node.security_findings ?? [] as SecurityFinding[]); + const topModules = [...nodes] + .sort((a, b) => b.security_score - a.security_score || b.vulnerability_count - a.vulnerability_count) + .slice(0, 5); + + return ( +
+
+ + + + +
+ +
+ +
+
+

Top Vulnerable Modules

+

Ranked by security score and vulnerability concentration

+
+
+ {topModules.length === 0 &&

No vulnerable modules found.

} + {topModules.map((node) => ( +
+
+
+

{node.symbol_name}

+

{node.file_path}

+
+ +
+
+ Security {node.security_score} + Critical {node.vulnerability_count} + Blast {node.blast_radius} +
+
+ ))} +
+
+
+ + +
+ ); +} + +function Metric({ label, value, accent = false, danger = false }: { label: string; value: string | number; accent?: boolean; danger?: boolean }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/components/SecurityPanel.tsx b/components/SecurityPanel.tsx new file mode 100644 index 0000000..97c5529 --- /dev/null +++ b/components/SecurityPanel.tsx @@ -0,0 +1,65 @@ +'use client'; + +import type { DebtNode } from '@/types'; +import { SecurityBadge } from '@/components/SecurityBadge'; +import { VulnerabilityTable } from '@/components/VulnerabilityTable'; + +interface SecurityPanelProps { + node: DebtNode; +} + +export function SecurityPanel({ node }: SecurityPanelProps) { + const findings = node.security_findings ?? []; + const securityScore = Number.isFinite(node.security_score) ? node.security_score : 0; + const securityWeightedScore = Number.isFinite(node.security_weighted_score) ? node.security_weighted_score : 0; + const vulnerabilityCount = Number.isFinite(node.vulnerability_count) ? node.vulnerability_count : 0; + const blastRadius = Number.isFinite(node.blast_radius) ? node.blast_radius : 0; + const owaspCategories = node.owasp_categories ?? []; + const cweCategories = node.cwe_categories ?? []; + + return ( +
+
+
+
+

Security Score

+

{securityScore}

+
+ +
+ +
+ + + + +
+
+ +
+

OWASP / CWE

+
+ {owaspCategories.length > 0 ? owaspCategories.map((category) => ( + {category} + )) : No OWASP mapping} +
+
+ {cweCategories.length > 0 ? cweCategories.map((category) => ( + {category} + )) : No CWE mapping} +
+
+ + +
+ ); +} + +function Stat({ label, value }: { label: string; value: string | number }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/components/VulnerabilityTable.tsx b/components/VulnerabilityTable.tsx new file mode 100644 index 0000000..bd0f912 --- /dev/null +++ b/components/VulnerabilityTable.tsx @@ -0,0 +1,58 @@ +'use client'; + +import type { SecurityFinding } from '@/types'; +import { SecurityBadge } from '@/components/SecurityBadge'; + +interface VulnerabilityTableProps { + findings: SecurityFinding[]; +} + +export function VulnerabilityTable({ findings }: VulnerabilityTableProps) { + if (findings.length === 0) { + return ( +
+

No security findings surfaced for this selection.

+
+ ); + } + + return ( +
+
+

Security Findings

+ {findings.length} findings +
+
+ {findings.slice(0, 12).map((finding) => ( +
+
+

{finding.title}

+ + {finding.ruleId} +
+
+ + + + +
+
+ {finding.filePath} + Lines {finding.lineStart}-{finding.lineEnd} + Occurrences {finding.occurrenceCount} +
+
+ ))} +
+
+ ); +} + +function Field({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+

{label}

+

{value || '—'}

+
+ ); +} diff --git a/lib/blast-radius.ts b/lib/blast-radius.ts index f28491f..2ae332f 100644 --- a/lib/blast-radius.ts +++ b/lib/blast-radius.ts @@ -36,15 +36,17 @@ function buildAdjacency(symbols: ASTSymbol[]): Map { export function buildGraphData( symbols: ASTSymbol[], scores: Map, - blastRadiusMap: Map + blastRadiusMap: Map, + priorityMap?: Map ): { topSymbols: ASTSymbol[]; links: GraphLink[] } { const ranked = [...symbols] .map((s) => ({ symbol: s, score: scores.get(s.id) ?? 0, blast: blastRadiusMap.get(s.id) ?? 0, + priority: priorityMap?.get(s.id) ?? scores.get(s.id) ?? 0, })) - .sort((a, b) => b.score - a.score || b.blast - a.blast); + .sort((a, b) => b.priority - a.priority || b.score - a.score || b.blast - a.blast); const topSymbols = ranked .slice(0, MAX_GRAPH_NODES) @@ -63,7 +65,7 @@ export function buildGraphData( links.push({ source: sym.id, target: depId, - weight: scores.get(sym.id) ?? 1, + weight: priorityMap?.get(sym.id) ?? scores.get(sym.id) ?? 1, }); } } @@ -76,7 +78,7 @@ export function buildGraphData( links.push({ source: depId, target: sym.id, - weight: scores.get(sym.id) ?? 1, + weight: priorityMap?.get(sym.id) ?? scores.get(sym.id) ?? 1, }); } } diff --git a/lib/csv.ts b/lib/csv.ts index db7cca5..4f50640 100644 --- a/lib/csv.ts +++ b/lib/csv.ts @@ -1,6 +1,7 @@ import Papa from 'papaparse'; import type { DebtNode, RoadmapItem } from '@/types'; import { computePriority } from '@/lib/debt-scorer'; +import { computeSecurityAwarePriority } from '@/lib/security/security-scorer'; export function nodesToRoadmap(nodes: DebtNode[]): RoadmapItem[] { return nodes @@ -10,11 +11,21 @@ export function nodesToRoadmap(nodes: DebtNode[]): RoadmapItem[] { filePath: node.file_path, symbolName: node.symbol_name, debtScore: node.debt_score, + securityScore: node.security_score, + vulnerabilityCount: node.vulnerability_count, + criticalSecurity: node.has_critical_security, + owaspCategories: node.owasp_categories, + cweCategories: node.cwe_categories, blastRadius: node.blast_radius, + securityPriority: computeSecurityAwarePriority({ + debtScore: node.debt_score, + blastRadius: node.blast_radius, + securityScore: node.security_score, + }), priority: computePriority(node.debt_score, node.blast_radius), explanation: node.explanation, })) - .sort((a, b) => b.priority - a.priority) + .sort((a, b) => b.securityPriority - a.securityPriority || b.priority - a.priority) .map((item, index) => ({ ...item, rank: index + 1 })); } @@ -25,7 +36,13 @@ export function exportToCSV(nodes: DebtNode[]): string { 'File Path': item.filePath, Symbol: item.symbolName, 'Debt Score': item.debtScore, + 'Security Score': item.securityScore, + 'Vulnerability Count': item.vulnerabilityCount, + 'Critical Security': item.criticalSecurity, + OWASP: item.owaspCategories.join('; '), + 'CWE Categories': item.cweCategories.join('; '), 'Blast Radius': item.blastRadius, + 'Security Priority': item.securityPriority, Priority: item.priority, Explanation: item.explanation ?? '', })); diff --git a/lib/duplication.ts b/lib/duplication.ts index e2b2e9b..f178a8c 100644 --- a/lib/duplication.ts +++ b/lib/duplication.ts @@ -1,4 +1,4 @@ -import simhash from 'simhash'; +import { createHash } from 'crypto'; import type { ParsedFile } from '@/types'; const SHINGLE_SIZE = 3; @@ -7,27 +7,12 @@ export function computeDuplicationScores( files: ParsedFile[] ): Map { const fileHashes: { path: string; hash: number[] }[] = []; - - // Safe initialization of simhash using 'md5' algorithm - let hasher: (tokens: string[]) => number[]; - try { - hasher = simhash('md5'); - } catch (err) { - console.error('Failed to initialize simhash with md5, falling back to default:', err); - try { - hasher = simhash(); - } catch (err2) { - console.error('Failed to initialize default simhash, using fallback stub:', err2); - hasher = () => []; - } - } for (const file of files) { try { const shingles = tokenize(file.content); if (shingles.length === 0) continue; - // Generate the simhash array of bits safely - const hash = hasher(shingles); + const hash = buildBitHash(shingles); fileHashes.push({ path: file.path, hash }); } catch (err) { console.error(`Failed to generate simhash for file ${file.path}:`, err); @@ -88,6 +73,21 @@ function hammingDistance(a: number[], b: number[]): number { return distance; } +function buildBitHash(tokens: string[]): number[] { + const vector = new Array(64).fill(0); + + for (const token of tokens) { + const digest = createHash('sha256').update(token).digest(); + for (let bit = 0; bit < 64; bit++) { + const byte = digest[Math.floor(bit / 8)] ?? 0; + const mask = 1 << (bit % 8); + vector[bit] += byte & mask ? 1 : -1; + } + } + + return vector.map((value) => (value >= 0 ? 1 : 0)); +} + export function getFileDuplicationScore( filePath: string, scores: Map diff --git a/lib/huggingface.ts b/lib/huggingface.ts index 4ee03c1..44812c3 100644 --- a/lib/huggingface.ts +++ b/lib/huggingface.ts @@ -124,46 +124,130 @@ export async function generateDebtExplanation(context: { complexity: number; blastRadius: number; codeSnippet: string; + securityScore?: number; + vulnerabilityCount?: number; + securityRiskLevel?: string; + owaspCategories?: string[]; + cweCategories?: string[]; + securityFindings?: Array<{ title: string; severity: string; recommendation: string; evidence: string }>; }): Promise { // Truncate fields to verify and handle token limits (Requirement 16) const truncatedPath = (context.filePath || '').slice(0, 200); const truncatedSymbol = (context.symbolName || '').slice(0, 100); const truncatedCode = (context.codeSnippet || '').slice(0, 1000); + const securitySummary = [ + `Security score: ${context.securityScore ?? 0}/100`, + `Vulnerabilities: ${context.vulnerabilityCount ?? 0}`, + `Risk level: ${context.securityRiskLevel ?? 'none'}`, + `OWASP: ${(context.owaspCategories ?? []).join(', ') || 'none'}`, + `CWE: ${(context.cweCategories ?? []).join(', ') || 'none'}`, + ].join('\n'); + const securityFindings = (context.securityFindings ?? []) + .slice(0, 6) + .map((finding) => `- [${finding.severity}] ${finding.title}: ${finding.evidence}`) + .join('\n'); - const prompt = `[INST] You are a senior software architect analyzing technical debt. + const prompt = `[INST] You are a senior security architect and technical debt expert. -Analyze this code symbol and explain its technical debt in 2-3 concise sentences. Focus on maintainability risks, coupling, and refactoring priority. +Analyze this code symbol for technical debt, security vulnerabilities, exploitability, propagation risk, and remediation steps. + +Return strict JSON: +{ + "summary": "", + "technicalDebt": [], + "securityFindings": [], + "criticalRisks": [], + "recommendedFixes": [], + "priorityLevel": "" +} File: ${truncatedPath} Symbol: ${truncatedSymbol} Debt Score: ${context.debtScore}/100 Complexity: ${context.complexity} Blast Radius: ${context.blastRadius} dependent symbols +${securitySummary} + +Security Findings: +${securityFindings || '- none'} Code: \`\`\` ${truncatedCode} \`\`\` -Provide a clear, actionable explanation. [/INST]`; +Provide only JSON. [/INST]`; console.log("[Explain] Prompt length:", prompt.length); console.log("[Explain] Calling HuggingFace..."); try { - return await callHF(EXPLANATION_MODEL, prompt, { max_new_tokens: 300 }); + const raw = await callHF(EXPLANATION_MODEL, prompt, { max_new_tokens: 380 }); + const parsed = parseSecurityExplanation(raw); + if (parsed) return parsed; + return raw; } catch (err) { console.warn("[HuggingFace] Primary model failed due to offline state or timeout. Attempting fallback model..."); try { const fallbackPrompt = `Explain technical debt for ${truncatedSymbol} in ${truncatedPath} (score ${context.debtScore}): ${truncatedCode.slice(0, 400)}`; - return await callHF(FALLBACK_MODEL, fallbackPrompt, { max_new_tokens: 150 }); + const raw = await callHF(FALLBACK_MODEL, fallbackPrompt, { max_new_tokens: 150 }); + const parsed = parseSecurityExplanation(raw); + return parsed ?? raw; } catch (fallbackErr) { console.warn("[HuggingFace] Fallback model also failed (network unreachable). Gracefully fallback to custom local heuristic explanation."); - return generateHeuristicExplanation(context); + return generateSecurityHeuristicExplanation(context); } } } +function parseSecurityExplanation(raw: string): string | null { + const trimmed = raw.trim(); + const jsonCandidate = trimmed.startsWith('```') ? trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '') : trimmed; + + try { + const parsed = JSON.parse(jsonCandidate) as { + summary?: string; + technicalDebt?: string[]; + securityFindings?: string[]; + criticalRisks?: string[]; + recommendedFixes?: string[]; + priorityLevel?: string; + }; + const sections = [ + parsed.summary ? `Summary: ${parsed.summary}` : '', + parsed.priorityLevel ? `Priority: ${parsed.priorityLevel}` : '', + parsed.technicalDebt?.length ? `Technical debt: ${parsed.technicalDebt.join('; ')}` : '', + parsed.securityFindings?.length ? `Security findings: ${parsed.securityFindings.join('; ')}` : '', + parsed.criticalRisks?.length ? `Critical risks: ${parsed.criticalRisks.join('; ')}` : '', + parsed.recommendedFixes?.length ? `Recommended fixes: ${parsed.recommendedFixes.join('; ')}` : '', + ].filter(Boolean); + + return sections.join('\n'); + } catch { + return null; + } +} + +function generateSecurityHeuristicExplanation(context: { + filePath: string; + symbolName: string; + debtScore: number; + complexity: number; + blastRadius: number; + securityScore?: number; + vulnerabilityCount?: number; + securityRiskLevel?: string; +}): string { + const debt = generateHeuristicExplanation(context); + const securityLines = [ + `Security score: ${context.securityScore ?? 0}/100`, + `Vulnerability count: ${context.vulnerabilityCount ?? 0}`, + `Risk level: ${context.securityRiskLevel ?? 'none'}`, + ]; + + return `${debt} ${securityLines.join('. ')}. Prioritize remediation in the highest blast-radius paths first.`; +} + export async function classifyAgentCode(codeSnippet: string): Promise<{ label: string; confidence: number; diff --git a/lib/security/cwe-mapper.ts b/lib/security/cwe-mapper.ts new file mode 100644 index 0000000..a28391c --- /dev/null +++ b/lib/security/cwe-mapper.ts @@ -0,0 +1,30 @@ +const CWE: Record = { + CWE_79: 'Cross-site Scripting', + CWE_89: 'SQL Injection', + CWE_94: 'Code Injection', + CWE_95: 'Eval Injection', + CWE_116: 'Improper Encoding or Escaping', + CWE_22: 'Path Traversal', + CWE_78: 'OS Command Injection', + CWE_94A: 'Unsafe Deserialization', + CWE_200: 'Information Exposure', + CWE_284: 'Improper Access Control', + CWE_330: 'Insufficiently Random Values', + CWE_352: 'Cross-Site Request Forgery', + CWE_400: 'Uncontrolled Resource Consumption', + CWE_502: 'Deserialization of Untrusted Data', + CWE_611: 'XML External Entity', + CWE_732: 'Incorrect Permission Assignment', + CWE_918: 'Server-Side Request Forgery', + CWE_693: 'Protection Mechanism Failure', + CWE_20: 'Improper Input Validation', + CWE_94B: 'Prototype Pollution', +}; + +export function mapCWE(id: string): string { + return CWE[id] ?? id; +} + +export function mapCWEIds(ids: string[]): string[] { + return [...new Set(ids.map((id) => mapCWE(id)))]; +} diff --git a/lib/security/detector.ts b/lib/security/detector.ts new file mode 100644 index 0000000..96555d6 --- /dev/null +++ b/lib/security/detector.ts @@ -0,0 +1,465 @@ +import type { + ASTSymbol, + ParsedFile, + SecurityAnalysisResult, + SecurityFinding, + SecurityNodeMetrics, + SecuritySummary, + VulnerabilitySeverity, +} from '@/types'; +import { + clampScore, + countOccurrences, + highestSeverity, + isProductionPath, + isSecuritySensitivePath, + isTestLikePath, + lineAt, + locateLineRange, + makeFindingId, + severityRank, + unique, +} from '@/lib/security/security-utils'; +import { calculateSecurityScore, calculateSecurityWeightedScore } from '@/lib/security/security-scorer'; +import { mapOWASPIds } from '@/lib/security/owasp-mapper'; +import { mapCWEIds } from '@/lib/security/cwe-mapper'; +import { analyzeSecurityCollapse } from '@/lib/security/security-collapse'; + +type RuleContext = { + file: ParsedFile; + lineNumber: number; + line: string; + content: string; +}; + +type SecurityRule = { + id: string; + title: string; + description: string; + severity: VulnerabilitySeverity; + category: string; + owaspIds: string[]; + cweIds: string[]; + recommendation: string; + exploitability: number; + match: (context: RuleContext) => boolean; + suppress?: (context: RuleContext) => boolean; + evidence?: (context: RuleContext) => string; +}; + +const RULES: SecurityRule[] = [ + { + id: 'hardcoded-secret', + title: 'Hardcoded secret or credential', + description: 'Sensitive secrets, tokens, keys, or credentials appear directly in source.', + severity: 'critical', + category: 'Secrets', + owaspIds: ['A05', 'A07'], + cweIds: ['CWE_798', 'CWE_259', 'CWE_321'], + recommendation: 'Move secrets to environment variables or a managed secret store and rotate exposed values.', + exploitability: 0.95, + match: ({ line }) => /(?:api[_-]?key|secret|token|password|passwd|private[_-]?key|client[_-]?secret)\s*[:=]\s*['"][^'"]{8,}['"]/i.test(line) || /BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY/.test(line), + suppress: ({ file }) => isTestLikePath(file.path), + }, + { + id: 'sql-injection', + title: 'SQL injection sink', + description: 'User-controlled data flows into SQL construction or execution.', + severity: 'critical', + category: 'Injection', + owaspIds: ['A03'], + cweIds: ['CWE_89', 'CWE_564'], + recommendation: 'Use parameterized queries or prepared statements and validate all query inputs.', + exploitability: 0.9, + match: ({ line }) => /(select|insert|update|delete|query|execute|exec)\s*\([^)]*(?:\+|`\$\{|format\(|join\().*/i.test(line) || /from\s*\(.*req\.|body\.|params\.|query\./i.test(line), + }, + { + id: 'command-injection', + title: 'Command injection sink', + description: 'Shell execution is built from untrusted input.', + severity: 'critical', + category: 'Injection', + owaspIds: ['A03'], + cweIds: ['CWE_78'], + recommendation: 'Avoid shell execution with user input. Use argument arrays and strict allowlists.', + exploitability: 0.92, + match: ({ line }) => /(exec|execSync|spawn|spawnSync|system|popen)\s*\([^)]*(?:\+|`\$\{|req\.|body\.|query\.|params\.)/i.test(line), + }, + { + id: 'eval-usage', + title: 'Dynamic code evaluation', + description: 'Unsafe dynamic evaluation expands the attack surface.', + severity: 'high', + category: 'Injection', + owaspIds: ['A03'], + cweIds: ['CWE_94', 'CWE_95'], + recommendation: 'Remove eval-style execution and replace it with explicit parsing or dispatch logic.', + exploitability: 0.85, + match: ({ line }) => /\beval\s*\(|new Function\s*\(|setTimeout\s*\(\s*['"`].*['"`]/i.test(line), + }, + { + id: 'xss', + title: 'Cross-site scripting sink', + description: 'Unsafe HTML insertion or scripting may execute attacker-controlled markup.', + severity: 'high', + category: 'XSS', + owaspIds: ['A03', 'A05'], + cweIds: ['CWE_79', 'CWE_116'], + recommendation: 'Escape output, avoid raw HTML insertion, and sanitize any rich content.', + exploitability: 0.82, + match: ({ line }) => /dangerouslySetInnerHTML|innerHTML\s*=|outerHTML\s*=|document\.write\s*\(/i.test(line), + }, + { + id: 'jwt-weakness', + title: 'JWT validation weakness', + description: 'Token decoding, weak verification, or insecure JWT configuration was detected.', + severity: 'high', + category: 'Auth', + owaspIds: ['A07'], + cweIds: ['CWE_347', 'CWE_287'], + recommendation: 'Verify JWT signatures, pin algorithms, and reject unsigned or weakly configured tokens.', + exploitability: 0.8, + match: ({ line }) => /jwt\.(decode|verify|sign)|alg\s*:\s*['"]none['"]|ignoreExpiration\s*:\s*true/i.test(line), + }, + { + id: 'weak-crypto', + title: 'Weak cryptography or hashing', + description: 'Deprecated or weak cryptographic primitives are used.', + severity: 'medium', + category: 'Crypto', + owaspIds: ['A02'], + cweIds: ['CWE_327', 'CWE_328', 'CWE_330'], + recommendation: 'Replace weak algorithms with modern, approved cryptography and strong randomness sources.', + exploitability: 0.6, + match: ({ line }) => /\b(md5|sha1|des|rc4|rabbit|bcrypt\s*\(.*?\brounds\s*[:=]\s*[0-3]\b|crypto\.createHash\s*\(\s*['"](?:md5|sha1)['"])/i.test(line), + }, + { + id: 'insecure-deserialization', + title: 'Insecure deserialization', + description: 'Trusted data is deserialized without safety controls.', + severity: 'high', + category: 'Integrity', + owaspIds: ['A08'], + cweIds: ['CWE_502', 'CWE_94A', 'CWE_611'], + recommendation: 'Avoid unsafe deserializers, validate schemas, and keep untrusted data as plain data.', + exploitability: 0.78, + match: ({ line }) => /(?:deserialize|unserialize|yaml\.load|jsyaml\.load|pickle|Marshal\.load|Object\.assign\s*\(.*req\.|JSON\.parse\s*\(.*req\.)/i.test(line), + }, + { + id: 'ssrf', + title: 'Server-side request forgery', + description: 'User-controlled URLs can pivot server requests into internal services.', + severity: 'critical', + category: 'SSRF', + owaspIds: ['A10'], + cweIds: ['CWE_918'], + recommendation: 'Allowlist destinations, validate URLs, and block internal network ranges.', + exploitability: 0.9, + match: ({ line }) => /(?:fetch|axios\.|request|http\.request|https\.request)\s*\(.*(?:req\.|query\.|body\.|params\.|url|href)/i.test(line), + }, + { + id: 'path-traversal', + title: 'Path traversal sink', + description: 'File paths are built from user-controlled input without normalization.', + severity: 'high', + category: 'Path', + owaspIds: ['A05'], + cweIds: ['CWE_22'], + recommendation: 'Normalize and constrain paths to a strict base directory before file access.', + exploitability: 0.82, + match: ({ line }) => /(?:readFile|readFileSync|createReadStream|stat|unlink|writeFile|writeFileSync|resolve|join)\s*\(.*(?:req\.|query\.|params\.|body\.)/i.test(line), + }, + { + id: 'prototype-pollution', + title: 'Prototype pollution risk', + description: 'Object merging or key handling can mutate prototypes or unsafe paths.', + severity: 'high', + category: 'Integrity', + owaspIds: ['A08'], + cweIds: ['CWE_1321', 'CWE_915'], + recommendation: 'Reject prototype keys and avoid merging untrusted objects directly into application state.', + exploitability: 0.75, + match: ({ line }) => /__proto__|constructor\.prototype|Object\.assign\s*\(|deepmerge\s*\(|merge\s*\(/i.test(line), + }, + { + id: 'mass-assignment', + title: 'Mass assignment', + description: 'Untrusted request bodies are written directly into models or persistence layers.', + severity: 'high', + category: 'Auth', + owaspIds: ['A01'], + cweIds: ['CWE_915'], + recommendation: 'Whitelist assignable fields and map request DTOs explicitly.', + exploitability: 0.72, + match: ({ line }) => /(?:create|update|save|patch)\s*\(.*(?:req\.|body\.|query\.|params\.|\.body\b|\.json\b)/i.test(line) || /Object\.assign\s*\(.*(?:model|user|data).*,\s*(?:req\.|body\.)/i.test(line), + }, + { + id: 'insecure-logging', + title: 'Insecure logging of sensitive data', + description: 'Logs may expose credentials, tokens, or full request payloads.', + severity: 'medium', + category: 'Logging', + owaspIds: ['A09'], + cweIds: ['CWE_532', 'CWE_200'], + recommendation: 'Redact sensitive fields before logging and avoid logging secrets or entire payloads.', + exploitability: 0.58, + match: ({ line }) => /console\.(log|warn|error)|logger\.(info|warn|error)|debug\(/i.test(line), + evidence: ({ line }) => line, + }, + { + id: 'tls-disablement', + title: 'TLS verification disabled', + description: 'Transport security checks are disabled or bypassed.', + severity: 'critical', + category: 'Transport', + owaspIds: ['A02', 'A05'], + cweIds: ['CWE_319', 'CWE_295'], + recommendation: 'Never disable certificate verification in production and enforce secure transport settings.', + exploitability: 0.93, + match: ({ line }) => /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0['"]?|secureProtocol\s*:\s*['"]?TLSv1/i.test(line), + }, + { + id: 'cors-wildcard', + title: 'Overly permissive CORS', + description: 'CORS is configured to accept any origin.', + severity: 'medium', + category: 'CORS', + owaspIds: ['A05'], + cweIds: ['CWE_346'], + recommendation: 'Restrict CORS to trusted origins and avoid wildcard access in authenticated contexts.', + exploitability: 0.55, + match: ({ line }) => /Access-Control-Allow-Origin['"]?\s*:\s*['"]\*['"]|cors\s*\(\s*\{[^}]*origin\s*:\s*['"]\*['"]/i.test(line), + }, +]; + +function shouldSuppressFinding(rule: SecurityRule, context: RuleContext): boolean { + if (rule.suppress?.(context)) return true; + if (isTestLikePath(context.file.path) && rule.severity !== 'critical') return true; + if (!isProductionPath(context.file.path) && rule.category === 'Secrets') return false; + if (/\/vendor\//i.test(context.file.path)) return true; + return false; +} + +function buildFinding(rule: SecurityRule, context: RuleContext, occurrenceCount: number): SecurityFinding { + const location = locateLineRange(context.content, context.line.trim()); + const evidence = rule.evidence?.(context) ?? context.line.trim(); + return { + id: makeFindingId(rule.id, context.file.path, location.start, evidence), + ruleId: rule.id, + title: rule.title, + description: rule.description, + severity: rule.severity, + filePath: context.file.path, + lineStart: location.start, + lineEnd: location.end, + evidence, + recommendation: rule.recommendation, + occurrenceCount, + exploitability: rule.exploitability, + owaspIds: rule.owaspIds, + cweIds: rule.cweIds, + category: rule.category, + }; +} + +export function detectSecurityFindings(file: ParsedFile): SecurityFinding[] { + const lines = file.content.split(/\r?\n/); + const findings: SecurityFinding[] = []; + + for (const rule of RULES) { + const matchedLines: number[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const context: RuleContext = { file, lineNumber: index + 1, line, content: file.content }; + if (!rule.match(context)) continue; + if (shouldSuppressFinding(rule, context)) continue; + matchedLines.push(index + 1); + } + + if (matchedLines.length === 0) continue; + const firstLine = matchedLines[0]; + const context: RuleContext = { + file, + lineNumber: firstLine, + line: lineAt(file.content, firstLine), + content: file.content, + }; + findings.push(buildFinding(rule, context, matchedLines.length)); + } + + const lowerContent = file.content.toLowerCase(); + if (isSecuritySensitivePath(file.path) && /password|secret|token/.test(lowerContent) && !isTestLikePath(file.path)) { + findings.push({ + id: makeFindingId('sensitive-path-secrets', file.path, 1, file.path), + ruleId: 'sensitive-path-secrets', + title: 'Sensitive path secret exposure', + description: 'A security-sensitive path contains secret-bearing code or configuration.', + severity: 'critical', + filePath: file.path, + lineStart: 1, + lineEnd: Math.min(lines.length, 1), + evidence: file.path, + recommendation: 'Move secrets out of the repo and isolate security-sensitive code paths.', + occurrenceCount: 1, + exploitability: 0.88, + owaspIds: ['A05', 'A07'], + cweIds: ['CWE_798', 'CWE_200'], + category: 'Secrets', + }); + } + + return findings; +} + +export function summarizeSecurityFindings(findings: SecurityFinding[]): SecuritySummary { + const counts = findings.reduce( + (acc, finding) => { + acc[finding.severity] += finding.occurrenceCount; + return acc; + }, + { critical: 0, high: 0, medium: 0, low: 0 } as Record + ); + + const score = calculateSecurityScore(counts); + const categoryCounts: Record = {}; + const owaspIds = unique(findings.flatMap((finding) => finding.owaspIds)); + const cweIds = unique(findings.flatMap((finding) => finding.cweIds)); + + for (const finding of findings) { + categoryCounts[finding.category] = (categoryCounts[finding.category] ?? 0) + finding.occurrenceCount; + } + + const topFindings = [...findings] + .sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.occurrenceCount - a.occurrenceCount) + .slice(0, 10); + + return { + totalVulnerabilities: findings.reduce((sum, finding) => sum + finding.occurrenceCount, 0), + critical: counts.critical, + high: counts.high, + medium: counts.medium, + low: counts.low, + score, + categoryCounts, + owaspCategories: owaspIds, + cweCategories: cweIds, + topFindings, + }; +} + +function buildNodeMetrics( + findings: SecurityFinding[], + symbols: ASTSymbol[], + blastRadiusMap: Map +): Record { + const metrics: Record = {}; + + for (const symbol of symbols) { + const symbolFindings = findings.filter((finding) => finding.filePath === symbol.filePath); + const criticalCount = symbolFindings.filter((finding) => finding.severity === 'critical').length; + const summary = summarizeSecurityFindings(symbolFindings); + const securityScore = summary.score; + const blastRadius = blastRadiusMap.get(symbol.id) ?? symbol.calledBy.length; + const weighted = calculateSecurityWeightedScore({ + securityScore, + blastRadius, + vulnerabilityCount: summary.totalVulnerabilities, + criticalCount, + hasSensitivePath: isSecuritySensitivePath(symbol.filePath), + }); + + metrics[symbol.filePath] = { + securityScore, + securityWeightedScore: weighted, + hasCriticalSecurity: criticalCount > 0, + vulnerabilityCount: summary.totalVulnerabilities, + securityRiskLevel: highestSeverity(symbolFindings), + owaspCategories: summary.owaspCategories, + cweCategories: summary.cweCategories, + securityFindings: symbolFindings, + criticalCount, + }; + } + + return metrics; +} + +export function analyzeSecurityRepository(params: { + files: ParsedFile[]; + symbols: ASTSymbol[]; + blastRadiusMap: Map; +}): SecurityAnalysisResult { + const findings = params.files.flatMap((file) => detectSecurityFindings(file)); + const summary = summarizeSecurityFindings(findings); + const nodeMetrics = buildNodeMetrics(findings, params.symbols, params.blastRadiusMap); + const repoSecurityScore = clampScore( + summary.score + Math.min(20, summary.critical * 6 + summary.high * 3 + Object.keys(summary.categoryCounts).length * 2), + 0, + 100 + ); + + const collapseInput: SecurityAnalysisResult = { + findings, + summary, + collapse: { + isCollapsed: false, + severity: 'moderate', + reasons: [], + affectedCoreModules: [], + propagationRisk: 0, + }, + nodeMetrics, + repoSecurityScore, + criticalVulnerabilities: summary.critical, + }; + + const collapse = analyzeSecurityCollapse({ + security: collapseInput, + symbols: params.symbols, + blastRadiusMap: params.blastRadiusMap, + }); + + return { + findings, + summary, + collapse, + nodeMetrics, + repoSecurityScore, + criticalVulnerabilities: summary.critical, + }; +} + +export function getSecurityNodeMetrics( + result: SecurityAnalysisResult, + filePath: string +): SecurityNodeMetrics { + return result.nodeMetrics[filePath] ?? { + securityScore: 0, + securityWeightedScore: 0, + hasCriticalSecurity: false, + vulnerabilityCount: 0, + securityRiskLevel: 'none', + owaspCategories: [], + cweCategories: [], + securityFindings: [], + criticalCount: 0, + }; +} + +export function buildSecuritySummary(result: SecurityAnalysisResult): SecuritySummary { + return result.summary; +} + +export function securityFindingCounts(findings: SecurityFinding[]): Record { + return findings.reduce( + (acc, finding) => { + acc[finding.severity] += finding.occurrenceCount; + return acc; + }, + { critical: 0, high: 0, medium: 0, low: 0 } as Record + ); +} + +export function securityCategoryList(findings: SecurityFinding[]): string[] { + return unique(findings.map((finding) => finding.category)); +} diff --git a/lib/security/owasp-mapper.ts b/lib/security/owasp-mapper.ts new file mode 100644 index 0000000..1fbe1d0 --- /dev/null +++ b/lib/security/owasp-mapper.ts @@ -0,0 +1,22 @@ +import type { OWASPCategory } from '@/types'; + +const OWASP: Record = { + 'A01': { id: 'A01', name: 'Broken Access Control', description: 'Authorization and access control flaws.' }, + 'A02': { id: 'A02', name: 'Cryptographic Failures', description: 'Weak or disabled cryptography and key handling.' }, + 'A03': { id: 'A03', name: 'Injection', description: 'SQL, command, template, and other injection flaws.' }, + 'A04': { id: 'A04', name: 'Insecure Design', description: 'Design-level security weaknesses and unsafe defaults.' }, + 'A05': { id: 'A05', name: 'Security Misconfiguration', description: 'Unsafe configuration and exposure of sensitive surfaces.' }, + 'A06': { id: 'A06', name: 'Vulnerable and Outdated Components', description: 'Risks from unsafe third-party code or dependencies.' }, + 'A07': { id: 'A07', name: 'Identification and Authentication Failures', description: 'Weak session, token, or authentication handling.' }, + 'A08': { id: 'A08', name: 'Software and Data Integrity Failures', description: 'Unsafe deserialization, supply-chain, and integrity issues.' }, + 'A09': { id: 'A09', name: 'Security Logging and Monitoring Failures', description: 'Insufficient security observability or unsafe logging.' }, + 'A10': { id: 'A10', name: 'Server-Side Request Forgery', description: 'Unsafe server-side fetches and internal request pivoting.' }, +}; + +export function mapOWASP(id: string): OWASPCategory { + return OWASP[id] ?? { id, name: id, description: 'Mapped security category.' }; +} + +export function mapOWASPIds(ids: string[]): OWASPCategory[] { + return [...new Map(ids.map((id) => [id, mapOWASP(id)])).values()]; +} diff --git a/lib/security/security-collapse.ts b/lib/security/security-collapse.ts new file mode 100644 index 0000000..b434d41 --- /dev/null +++ b/lib/security/security-collapse.ts @@ -0,0 +1,96 @@ +import type { ASTSymbol, SecurityAnalysisResult, SecurityCollapseResult } from '@/types'; +import { isSecuritySensitivePath, severityRank, unique } from '@/lib/security/security-utils'; + +export function analyzeSecurityCollapse(params: { + security: SecurityAnalysisResult; + symbols: ASTSymbol[]; + blastRadiusMap: Map; +}): SecurityCollapseResult { + const { security, symbols, blastRadiusMap } = params; + const reasons: string[] = []; + const affectedCoreModules = new Set(); + + if (security.criticalVulnerabilities >= 3) { + reasons.push(`Critical vulnerabilities count reached ${security.criticalVulnerabilities}`); + } + + if (security.repoSecurityScore > 75) { + reasons.push(`Repository security score is ${security.repoSecurityScore.toFixed(1)}/100`); + } + + const fileToSymbols = new Map(); + for (const symbol of symbols) { + const bucket = fileToSymbols.get(symbol.filePath) ?? []; + bucket.push(symbol); + fileToSymbols.set(symbol.filePath, bucket); + } + + for (const [filePath, metrics] of Object.entries(security.nodeMetrics)) { + const related = fileToSymbols.get(filePath) ?? []; + const propagation = Math.max( + metrics.securityScore, + ...related.map((symbol) => blastRadiusMap.get(symbol.id) ?? 0) + ); + if (metrics.hasCriticalSecurity && isSecuritySensitivePath(filePath)) { + reasons.push(`Critical security issues exist in sensitive module ${filePath}`); + affectedCoreModules.add(filePath); + } + if (metrics.hasCriticalSecurity && related.some((symbol) => (blastRadiusMap.get(symbol.id) ?? 0) > 15)) { + reasons.push(`High blast-radius module ${filePath} contains critical vulnerabilities`); + affectedCoreModules.add(filePath); + } + if (propagation > 80 && metrics.hasCriticalSecurity) { + reasons.push(`Vulnerable code in ${filePath} shows high propagation risk (${propagation.toFixed(1)})`); + affectedCoreModules.add(filePath); + } + } + + const transitiveHotspots = symbols.filter((symbol) => { + const nodeMetrics = security.nodeMetrics[symbol.filePath]; + return nodeMetrics?.hasCriticalSecurity && (symbol.calledBy.length > 15 || (blastRadiusMap.get(symbol.id) ?? 0) > 15); + }); + + if (transitiveHotspots.length > 0) { + reasons.push(`Vulnerable core modules are depended upon by more than 15 files`); + for (const hotspot of transitiveHotspots) { + affectedCoreModules.add(hotspot.filePath); + } + } + + const secretLeaks = security.findings.filter((finding) => finding.category === 'Secrets' && finding.severity === 'critical'); + if (secretLeaks.some((finding) => isSecuritySensitivePath(finding.filePath))) { + reasons.push(`Secrets are exposed in production security/authentication paths`); + for (const finding of secretLeaks) affectedCoreModules.add(finding.filePath); + } + + const propagationRisk = Math.min( + 100, + Math.round( + ((security.criticalVulnerabilities * 16) + + unique([...affectedCoreModules]).length * 12 + + reasons.length * 8 + + security.repoSecurityScore * 0.6) + ) + ); + + const collapse = + security.criticalVulnerabilities >= 3 || + security.repoSecurityScore > 75 || + transitiveHotspots.length > 0 || + secretLeaks.some((finding) => isSecuritySensitivePath(finding.filePath)) || + security.findings.some((finding) => finding.severity === 'high' && isSecuritySensitivePath(finding.filePath)); + + const severity = collapse + ? security.criticalVulnerabilities >= 3 || security.repoSecurityScore > 85 + ? 'critical' + : 'high' + : 'moderate'; + + return { + isCollapsed: collapse, + severity, + reasons: unique(reasons), + affectedCoreModules: unique([...affectedCoreModules]), + propagationRisk, + }; +} diff --git a/lib/security/security-scorer.ts b/lib/security/security-scorer.ts new file mode 100644 index 0000000..b59ca65 --- /dev/null +++ b/lib/security/security-scorer.ts @@ -0,0 +1,56 @@ +import type { SecurityFinding, VulnerabilitySeverity } from '@/types'; +import { clampScore, severityRank } from '@/lib/security/security-utils'; + +export function calculateSecurityScore(counts: Record): number { + const raw = + counts.critical * 25 + + counts.high * 15 + + counts.medium * 8 + + counts.low * 3; + + return Math.round(clampScore(raw, 0, 100) * 10) / 10; +} + +export function calculateFindingScore(finding: SecurityFinding): number { + const severityBase = { + critical: 25, + high: 15, + medium: 8, + low: 3, + }[finding.severity]; + + return Math.round(clampScore(severityBase + finding.exploitability * 10, 0, 100) * 10) / 10; +} + +export function calculateSecurityWeightedScore(params: { + securityScore: number; + blastRadius: number; + vulnerabilityCount: number; + criticalCount: number; + hasSensitivePath: boolean; +}): number { + const weighted = + params.securityScore * 0.55 + + Math.min(params.blastRadius * 1.8, 30) + + Math.min(params.vulnerabilityCount * 2.5, 20) + + params.criticalCount * 12 + + (params.hasSensitivePath ? 10 : 0); + + return Math.round(clampScore(weighted, 0, 100) * 10) / 10; +} + +export function computeSecurityAwarePriority(params: { + debtScore: number; + blastRadius: number; + securityScore: number; +}): number { + return Math.round((params.debtScore * params.blastRadius) + (params.securityScore * 1.5)); +} + +export function sortBySecurityRisk(items: T[]): T[] { + return [...items].sort((a, b) => { + const aScore = a.securityScore + a.debtScore * 0.3; + const bScore = b.securityScore + b.debtScore * 0.3; + return bScore - aScore || severityRank('low') - severityRank('low'); + }); +} diff --git a/lib/security/security-utils.ts b/lib/security/security-utils.ts new file mode 100644 index 0000000..f587874 --- /dev/null +++ b/lib/security/security-utils.ts @@ -0,0 +1,64 @@ +import type { SecurityFinding, VulnerabilitySeverity } from '@/types'; + +export function clampScore(value: number, min = 0, max = 100): number { + return Math.min(max, Math.max(min, value)); +} + +export function unique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +export function severityRank(severity: VulnerabilitySeverity): number { + switch (severity) { + case 'critical': + return 4; + case 'high': + return 3; + case 'medium': + return 2; + case 'low': + return 1; + default: + return 0; + } +} + +export function highestSeverity(findings: SecurityFinding[]): VulnerabilitySeverity | 'none' { + if (findings.length === 0) return 'none'; + return [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0].severity; +} + +export function isTestLikePath(filePath: string): boolean { + return /(?:^|\/)(?:test|tests|spec|__tests__|mock|mocks|fixture|fixtures|sample|examples?)(?:\/|$)/i.test(filePath); +} + +export function isProductionPath(filePath: string): boolean { + return !isTestLikePath(filePath); +} + +export function isSecuritySensitivePath(filePath: string): boolean { + return /(?:auth|security|login|session|token|jwt|oauth|password|sso|crypto|tls|cors|middleware|gateway|api\b)/i.test(filePath); +} + +export function lineAt(content: string, lineNumber: number): string { + return content.split(/\r?\n/)[Math.max(0, lineNumber - 1)] ?? ''; +} + +export function locateLineRange(content: string, needle: string): { start: number; end: number } { + const lines = content.split(/\r?\n/); + const index = lines.findIndex((line) => line.includes(needle)); + if (index === -1) { + return { start: 1, end: Math.min(lines.length, 1) }; + } + return { start: index + 1, end: index + 1 }; +} + +export function countOccurrences(content: string, pattern: RegExp): number { + const matches = content.match(pattern); + return matches?.length ?? 0; +} + +export function makeFindingId(ruleId: string, filePath: string, lineStart: number, evidence: string): string { + const clean = evidence.slice(0, 40).replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, ''); + return [ruleId, filePath, lineStart, clean || 'evidence'].join(':'); +} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts index 33193eb..e8ddf36 100644 --- a/lib/supabase/server.ts +++ b/lib/supabase/server.ts @@ -45,6 +45,10 @@ export async function updateAnalysisProgress( avg_debt_score: number; fingerprint_label: string; fingerprint_confidence: number; + security_summary: AnalysisRecord['security_summary']; + security_collapse: boolean; + critical_vulnerabilities: number; + repo_security_score: number; }> ) { try { @@ -54,7 +58,29 @@ export async function updateAnalysisProgress( .update(updates) .eq('id', analysisId); if (error) { - console.error(`[Supabase Error] Failed to update progress for ${analysisId}: ${error.message}`); + const errorMessage = error.message ?? ''; + const isSchemaCacheError = /schema cache|Could not find the '.+' column/i.test(errorMessage); + if (!isSchemaCacheError) { + console.error(`[Supabase Error] Failed to update progress for ${analysisId}: ${errorMessage}`); + return; + } + + const fallbackUpdates = { + ...updates, + } as Record; + delete fallbackUpdates.security_summary; + delete fallbackUpdates.security_collapse; + delete fallbackUpdates.critical_vulnerabilities; + delete fallbackUpdates.repo_security_score; + + const fallback = await supabase + .from('analyses') + .update(fallbackUpdates) + .eq('id', analysisId); + + if (fallback.error) { + console.error(`[Supabase Error] Failed to update progress for ${analysisId}: ${fallback.error.message}`); + } } } catch (err) { console.error(`[Supabase Error] Exception thrown during update progress for ${analysisId}:`, err); @@ -91,7 +117,19 @@ export async function insertDebtNodes( for (let i = 0; i < nodes.length; i += batchSize) { const batch = nodes.slice(i, i + batchSize); const { error } = await supabase.from('debt_nodes').insert(batch); - if (error) throw new Error(`Failed to insert nodes: ${error.message}`); + if (!error) continue; + + const errorMessage = error.message ?? ''; + const isSchemaCacheError = /schema cache|Could not find the '.+' column/i.test(errorMessage); + if (!isSchemaCacheError) { + throw new Error(`Failed to insert nodes: ${errorMessage}`); + } + + const fallbackBatch = batch.map(({ security_score, security_weighted_score, has_critical_security, vulnerability_count, security_risk_level, owasp_categories, cwe_categories, security_findings, ...rest }) => rest as Omit); + const fallbackInsert = await supabase.from('debt_nodes').insert(fallbackBatch); + if (fallbackInsert.error) { + throw new Error(`Failed to insert nodes: ${fallbackInsert.error.message}`); + } } } diff --git a/supabase/migrations/20260522210829_initial_schema.sql b/supabase/migrations/20260522210829_initial_schema.sql index d6fc3de..2a3892b 100644 --- a/supabase/migrations/20260522210829_initial_schema.sql +++ b/supabase/migrations/20260522210829_initial_schema.sql @@ -19,6 +19,10 @@ create table if not exists public.analyses ( avg_debt_score numeric(10,2) not null default 0, fingerprint_label text, fingerprint_confidence numeric(5,4), + security_summary jsonb, + security_collapse boolean not null default false, + critical_vulnerabilities integer not null default 0, + repo_security_score numeric(10,2) not null default 0, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); @@ -32,9 +36,17 @@ create table if not exists public.debt_nodes ( line_start integer not null, line_end integer not null, debt_score numeric(10,2) not null default 0, + security_score numeric(10,2) not null default 0, + security_weighted_score numeric(10,2) not null default 0, + has_critical_security boolean not null default false, + vulnerability_count integer not null default 0, + security_risk_level text, complexity integer not null default 1, duplication_score numeric(5,4) not null default 0, blast_radius integer not null default 0, + owasp_categories jsonb not null default '[]'::jsonb, + cwe_categories jsonb not null default '[]'::jsonb, + security_findings jsonb not null default '[]'::jsonb, dependencies jsonb not null default '[]'::jsonb, dependents jsonb not null default '[]'::jsonb, explanation text, @@ -48,6 +60,7 @@ create index if not exists idx_analyses_user on public.analyses(user_id); create index if not exists idx_analyses_status on public.analyses(status); create index if not exists idx_debt_nodes_analysis on public.debt_nodes(analysis_id); create index if not exists idx_debt_nodes_score on public.debt_nodes(analysis_id, debt_score desc); +create index if not exists idx_debt_nodes_security on public.debt_nodes(analysis_id, security_score desc); alter table public.analyses enable row level security; alter table public.debt_nodes enable row level security; diff --git a/supabase/migrations/20260523062107_updated_schema.sql b/supabase/migrations/20260523062107_updated_schema.sql new file mode 100644 index 0000000..63f3f1e --- /dev/null +++ b/supabase/migrations/20260523062107_updated_schema.sql @@ -0,0 +1,110 @@ +-- DebtRadar Supabase schema +-- Run in Supabase SQL editor + +create extension if not exists "pgcrypto"; + +create table if not exists public.analyses ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) on delete set null, + repo_url text not null, + repo_owner text not null, + repo_name text not null, + status text not null default 'pending' + check (status in ('pending','fetching','parsing','scoring','graphing','complete','failed')), + progress integer not null default 0 check (progress >= 0 and progress <= 100), + progress_message text, + error_message text, + total_files integer not null default 0, + total_nodes integer not null default 0, + avg_debt_score numeric(10,2) not null default 0, + fingerprint_label text, + fingerprint_confidence numeric(5,4), + security_summary jsonb, + security_collapse boolean not null default false, + critical_vulnerabilities integer not null default 0, + repo_security_score numeric(10,2) not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.debt_nodes ( + id uuid primary key default gen_random_uuid(), + analysis_id uuid not null references public.analyses(id) on delete cascade, + file_path text not null, + symbol_name text not null, + node_type text not null check (node_type in ('function','class','module','variable')), + line_start integer not null, + line_end integer not null, + debt_score numeric(10,2) not null default 0, + security_score numeric(10,2) not null default 0, + security_weighted_score numeric(10,2) not null default 0, + has_critical_security boolean not null default false, + vulnerability_count integer not null default 0, + security_risk_level text, + complexity integer not null default 1, + duplication_score numeric(5,4) not null default 0, + blast_radius integer not null default 0, + owasp_categories jsonb not null default '[]'::jsonb, + cwe_categories jsonb not null default '[]'::jsonb, + security_findings jsonb not null default '[]'::jsonb, + dependencies jsonb not null default '[]'::jsonb, + dependents jsonb not null default '[]'::jsonb, + explanation text, + fingerprint_tag text, + x numeric(12,4), + y numeric(12,4), + created_at timestamptz not null default now() +); + +alter table public.analyses + add column if not exists security_summary jsonb, + add column if not exists security_collapse boolean not null default false, + add column if not exists critical_vulnerabilities integer not null default 0, + add column if not exists repo_security_score numeric(10,2) not null default 0; + +alter table public.debt_nodes + add column if not exists security_score numeric(10,2) not null default 0, + add column if not exists security_weighted_score numeric(10,2) not null default 0, + add column if not exists has_critical_security boolean not null default false, + add column if not exists vulnerability_count integer not null default 0, + add column if not exists security_risk_level text, + add column if not exists owasp_categories jsonb not null default '[]'::jsonb, + add column if not exists cwe_categories jsonb not null default '[]'::jsonb, + add column if not exists security_findings jsonb not null default '[]'::jsonb; + +create index if not exists idx_analyses_user on public.analyses(user_id); +create index if not exists idx_analyses_status on public.analyses(status); +create index if not exists idx_debt_nodes_analysis on public.debt_nodes(analysis_id); +create index if not exists idx_debt_nodes_score on public.debt_nodes(analysis_id, debt_score desc); +create index if not exists idx_debt_nodes_security on public.debt_nodes(analysis_id, security_score desc); + +alter table public.analyses enable row level security; +alter table public.debt_nodes enable row level security; + +create policy "Analyses are viewable by everyone" + on public.analyses for select using (true); + +create policy "Analyses insertable by authenticated users" + on public.analyses for insert with check (auth.uid() = user_id or user_id is null); + +create policy "Analyses updatable by service" + on public.analyses for update using (true); + +create policy "Debt nodes viewable by everyone" + on public.debt_nodes for select using (true); + +create policy "Debt nodes insertable" + on public.debt_nodes for insert with check (true); + +create or replace function public.handle_updated_at() +returns trigger as $$ +begin + new.updated_at = now(); + return new; +end; +$$ language plpgsql; + +drop trigger if exists analyses_updated_at on public.analyses; +create trigger analyses_updated_at + before update on public.analyses + for each row execute function public.handle_updated_at(); diff --git a/supabase/migrations/20260523062327_updated_schema.sql b/supabase/migrations/20260523062327_updated_schema.sql new file mode 100644 index 0000000..63f3f1e --- /dev/null +++ b/supabase/migrations/20260523062327_updated_schema.sql @@ -0,0 +1,110 @@ +-- DebtRadar Supabase schema +-- Run in Supabase SQL editor + +create extension if not exists "pgcrypto"; + +create table if not exists public.analyses ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) on delete set null, + repo_url text not null, + repo_owner text not null, + repo_name text not null, + status text not null default 'pending' + check (status in ('pending','fetching','parsing','scoring','graphing','complete','failed')), + progress integer not null default 0 check (progress >= 0 and progress <= 100), + progress_message text, + error_message text, + total_files integer not null default 0, + total_nodes integer not null default 0, + avg_debt_score numeric(10,2) not null default 0, + fingerprint_label text, + fingerprint_confidence numeric(5,4), + security_summary jsonb, + security_collapse boolean not null default false, + critical_vulnerabilities integer not null default 0, + repo_security_score numeric(10,2) not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.debt_nodes ( + id uuid primary key default gen_random_uuid(), + analysis_id uuid not null references public.analyses(id) on delete cascade, + file_path text not null, + symbol_name text not null, + node_type text not null check (node_type in ('function','class','module','variable')), + line_start integer not null, + line_end integer not null, + debt_score numeric(10,2) not null default 0, + security_score numeric(10,2) not null default 0, + security_weighted_score numeric(10,2) not null default 0, + has_critical_security boolean not null default false, + vulnerability_count integer not null default 0, + security_risk_level text, + complexity integer not null default 1, + duplication_score numeric(5,4) not null default 0, + blast_radius integer not null default 0, + owasp_categories jsonb not null default '[]'::jsonb, + cwe_categories jsonb not null default '[]'::jsonb, + security_findings jsonb not null default '[]'::jsonb, + dependencies jsonb not null default '[]'::jsonb, + dependents jsonb not null default '[]'::jsonb, + explanation text, + fingerprint_tag text, + x numeric(12,4), + y numeric(12,4), + created_at timestamptz not null default now() +); + +alter table public.analyses + add column if not exists security_summary jsonb, + add column if not exists security_collapse boolean not null default false, + add column if not exists critical_vulnerabilities integer not null default 0, + add column if not exists repo_security_score numeric(10,2) not null default 0; + +alter table public.debt_nodes + add column if not exists security_score numeric(10,2) not null default 0, + add column if not exists security_weighted_score numeric(10,2) not null default 0, + add column if not exists has_critical_security boolean not null default false, + add column if not exists vulnerability_count integer not null default 0, + add column if not exists security_risk_level text, + add column if not exists owasp_categories jsonb not null default '[]'::jsonb, + add column if not exists cwe_categories jsonb not null default '[]'::jsonb, + add column if not exists security_findings jsonb not null default '[]'::jsonb; + +create index if not exists idx_analyses_user on public.analyses(user_id); +create index if not exists idx_analyses_status on public.analyses(status); +create index if not exists idx_debt_nodes_analysis on public.debt_nodes(analysis_id); +create index if not exists idx_debt_nodes_score on public.debt_nodes(analysis_id, debt_score desc); +create index if not exists idx_debt_nodes_security on public.debt_nodes(analysis_id, security_score desc); + +alter table public.analyses enable row level security; +alter table public.debt_nodes enable row level security; + +create policy "Analyses are viewable by everyone" + on public.analyses for select using (true); + +create policy "Analyses insertable by authenticated users" + on public.analyses for insert with check (auth.uid() = user_id or user_id is null); + +create policy "Analyses updatable by service" + on public.analyses for update using (true); + +create policy "Debt nodes viewable by everyone" + on public.debt_nodes for select using (true); + +create policy "Debt nodes insertable" + on public.debt_nodes for insert with check (true); + +create or replace function public.handle_updated_at() +returns trigger as $$ +begin + new.updated_at = now(); + return new; +end; +$$ language plpgsql; + +drop trigger if exists analyses_updated_at on public.analyses; +create trigger analyses_updated_at + before update on public.analyses + for each row execute function public.handle_updated_at(); diff --git a/supabase/schema.sql b/supabase/schema.sql index 7ab5240..1656617 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -19,6 +19,10 @@ create table if not exists public.analyses ( avg_debt_score numeric(10,2) not null default 0, fingerprint_label text, fingerprint_confidence numeric(5,4), + security_summary jsonb, + security_collapse boolean not null default false, + critical_vulnerabilities integer not null default 0, + repo_security_score numeric(10,2) not null default 0, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); @@ -32,9 +36,17 @@ create table if not exists public.debt_nodes ( line_start integer not null, line_end integer not null, debt_score numeric(10,2) not null default 0, + security_score numeric(10,2) not null default 0, + security_weighted_score numeric(10,2) not null default 0, + has_critical_security boolean not null default false, + vulnerability_count integer not null default 0, + security_risk_level text, complexity integer not null default 1, duplication_score numeric(5,4) not null default 0, blast_radius integer not null default 0, + owasp_categories jsonb not null default '[]'::jsonb, + cwe_categories jsonb not null default '[]'::jsonb, + security_findings jsonb not null default '[]'::jsonb, dependencies jsonb not null default '[]'::jsonb, dependents jsonb not null default '[]'::jsonb, explanation text, @@ -48,6 +60,7 @@ create index if not exists idx_analyses_user on public.analyses(user_id); create index if not exists idx_analyses_status on public.analyses(status); create index if not exists idx_debt_nodes_analysis on public.debt_nodes(analysis_id); create index if not exists idx_debt_nodes_score on public.debt_nodes(analysis_id, debt_score desc); +create index if not exists idx_debt_nodes_security on public.debt_nodes(analysis_id, security_score desc); alter table public.analyses enable row level security; alter table public.debt_nodes enable row level security; diff --git a/types/index.ts b/types/index.ts index 6b5e746..574142b 100644 --- a/types/index.ts +++ b/types/index.ts @@ -7,6 +7,75 @@ export type AnalysisStatus = | 'complete' | 'failed'; +export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low'; + +export interface OWASPCategory { + id: string; + name: string; + description: string; +} + +export interface SecurityFinding { + id: string; + ruleId: string; + title: string; + description: string; + severity: VulnerabilitySeverity; + filePath: string; + lineStart: number; + lineEnd: number; + evidence: string; + recommendation: string; + occurrenceCount: number; + exploitability: number; + owaspIds: string[]; + cweIds: string[]; + category: string; + suppressed?: boolean; +} + +export interface SecuritySummary { + totalVulnerabilities: number; + critical: number; + high: number; + medium: number; + low: number; + score: number; + categoryCounts: Record; + owaspCategories: string[]; + cweCategories: string[]; + topFindings: SecurityFinding[]; +} + +export interface SecurityCollapseResult { + isCollapsed: boolean; + severity: 'critical' | 'high' | 'moderate'; + reasons: string[]; + affectedCoreModules: string[]; + propagationRisk: number; +} + +export interface SecurityNodeMetrics { + securityScore: number; + securityWeightedScore: number; + hasCriticalSecurity: boolean; + vulnerabilityCount: number; + securityRiskLevel: VulnerabilitySeverity | 'none'; + owaspCategories: string[]; + cweCategories: string[]; + securityFindings: SecurityFinding[]; + criticalCount: number; +} + +export interface SecurityAnalysisResult { + findings: SecurityFinding[]; + summary: SecuritySummary; + collapse: SecurityCollapseResult; + nodeMetrics: Record; + repoSecurityScore: number; + criticalVulnerabilities: number; +} + export interface AnalysisRecord { id: string; user_id: string | null; @@ -22,6 +91,10 @@ export interface AnalysisRecord { avg_debt_score: number; fingerprint_label: string | null; fingerprint_confidence: number | null; + security_summary: SecuritySummary | null; + security_collapse: boolean; + critical_vulnerabilities: number; + repo_security_score: number; created_at: string; updated_at: string; } @@ -35,9 +108,17 @@ export interface DebtNode { line_start: number; line_end: number; debt_score: number; + security_score: number; + security_weighted_score: number; + has_critical_security: boolean; + vulnerability_count: number; + security_risk_level: VulnerabilitySeverity | 'none'; complexity: number; duplication_score: number; blast_radius: number; + owasp_categories: string[]; + cwe_categories: string[]; + security_findings: SecurityFinding[]; dependencies: string[]; dependents: string[]; explanation: string | null; @@ -78,8 +159,14 @@ export interface RoadmapItem { filePath: string; symbolName: string; debtScore: number; + securityScore: number; + vulnerabilityCount: number; + criticalSecurity: boolean; + owaspCategories: string[]; + cweCategories: string[]; blastRadius: number; priority: number; + securityPriority: number; explanation: string | null; } @@ -88,6 +175,12 @@ export interface FilterState { maxScore: number; nodeTypes: DebtNode['node_type'][]; search: string; + criticalSecurityOnly: boolean; + owaspCategories: string[]; + cweCategories: string[]; + securityScoreThreshold: number; + secretLeaksOnly: boolean; + injectionOnly: boolean; } export interface ParsedFile {