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 (
+
+ );
+}
+
+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 (
+
+ );
+}
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) {
@@ -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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 {