import type { ArchVulnerability, EnrichedKnowledgeGraph } from '../index.js'; import { resolveAuth } from '../shared/auth.js'; const MAX_ATTEMPTS = 3; const RETRY_DELAYS_MS = [1000, 2000, 4000] as const; const MAX_ARCH_PROMPT_CHARS = 20_000; const ARCHITECTURE_SYSTEM_PROMPT = `You are an expert software architect and security engineer. Analyze this project knowledge graph and find ARCHITECTURAL security vulnerabilities. Focus on: 1. Broken trust boundaries: public nodes calling privileged nodes without auth 2. Missing auth on call paths: edges that bypass auth nodes 3. Sensitive data flows: nodes with accesses_database=true connected to public nodes 4. Single points of failure: nodes with handles_auth=true that many others depend on 5. Privilege escalation: paths from public trust_boundary to privileged trust_boundary For each vulnerability respond with JSON array: [ { "id": "unique-id", "category": "broken_trust_boundary|missing_auth_on_path|sensitive_data_exposure|single_point_of_failure|privilege_escalation_path", "severity": "CRITICAL|HIGH|MEDIUM|LOW", "affected_nodes": ["node_id_1", "node_id_2"], "description": "clear description of the vulnerability", "recommendation": "concrete fix recommendation" } ] Respond ONLY with valid JSON array. No markdown. No text outside JSON. If no vulnerabilities found, respond with empty array: []`; interface QwenChatCompletionResponse { choices?: Array<{ message?: { content?: string; }; }>; } /** * Analyzes enriched architecture graph with Qwen and returns architectural vulnerabilities. */ export async function analyzeArchitectureVulnerabilities( graph: EnrichedKnowledgeGraph, projectPath: string, ): Promise { void projectPath; try { const auth = resolveAuth(); const compact = buildCompactGraph(graph); const userPrompt = buildArchitecturePrompt(compact); return await requestArchitectureAnalysisWithRetry(userPrompt, auth); } catch (error) { if (isQwenHttpError(error) && error.status === 400) { console.warn(`⚠️ Architecture LLM 400 response body: ${error.responseBody}`); } const message = error instanceof Error ? error.message : String(error); console.warn(`⚠️ Architecture LLM analysis unavailable: ${message}`); return []; } } async function requestArchitectureAnalysisWithRetry( userPrompt: string, auth: ReturnType, ): Promise { let lastError: Error | null = null; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { try { return await requestArchitectureAnalysis(userPrompt, auth); } catch (error) { lastError = error instanceof Error ? error : new Error('Unknown architecture analysis error'); if (attempt < MAX_ATTEMPTS) { await sleep(RETRY_DELAYS_MS[attempt - 1] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]); } } } if (lastError !== null) { throw lastError; } return []; } async function requestArchitectureAnalysis( userPrompt: string, auth: ReturnType, ): Promise { const response = await fetch(`${auth.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.apiKey}`, }, body: JSON.stringify({ model: auth.model, temperature: 0.1, max_tokens: 4096, stream: false, messages: [ { role: 'system', content: ARCHITECTURE_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], }), }); if (!response.ok) { const errorText = await response.text(); const requestError = new Error( `Qwen architecture analysis failed with ${response.status}: ${errorText}`, ) as Error & { status: number; responseBody: string }; requestError.status = response.status; requestError.responseBody = errorText; throw requestError; } const payload = (await response.json()) as QwenChatCompletionResponse; const content = payload.choices?.[0]?.message?.content; if (typeof content !== 'string' || content.trim().length === 0) { throw new Error('Qwen architecture response does not contain message content'); } return parseArchitectureResponse(content); } function parseArchitectureResponse(content: string): ArchVulnerability[] { const trimmed = content.trim(); const fencedMatch = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed); const candidate = fencedMatch?.[1]?.trim() ?? trimmed; let jsonText = candidate; if (!jsonText.startsWith('[') || !jsonText.endsWith(']')) { const arrayMatch = candidate.match(/\[[\s\S]*\]/); if (arrayMatch === null) { throw new Error('Architecture analysis response is not a JSON array'); } jsonText = arrayMatch[0]; } let value: unknown; try { value = JSON.parse(jsonText); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown JSON parse error'; throw new Error(`Failed to parse architecture JSON response: ${message}`); } if (!Array.isArray(value)) { throw new Error('Architecture response JSON root must be an array'); } const vulnerabilities: ArchVulnerability[] = []; for (const item of value) { if (!isArchVulnerability(item)) { throw new Error('Architecture response contains invalid vulnerability object'); } vulnerabilities.push(item); } return vulnerabilities; } function buildCompactGraph(graph: EnrichedKnowledgeGraph): { nodes: Array<{ id: string; security: EnrichedKnowledgeGraph['nodes'][number]['security']; }>; edges: EnrichedKnowledgeGraph['edges']; } { let nodes = graph.nodes; if (nodes.length > 200) { nodes = nodes.filter((node) => node.security.handles_auth || node.security.handles_user_input || node.security.makes_external_calls || node.security.accesses_database || node.security.has_crypto, ); } const allowedNodeIds = new Set(nodes.map((node) => node.id)); const edges = graph.edges.filter((edge) => allowedNodeIds.has(edge.from) && allowedNodeIds.has(edge.to)); return { nodes: nodes.map((node) => ({ id: node.id, security: node.security })), edges, }; } function buildArchitecturePrompt(compact: { nodes: Array<{ id: string; security: EnrichedKnowledgeGraph['nodes'][number]['security']; }>; edges: EnrichedKnowledgeGraph['edges']; }): string { const initial = JSON.stringify(compact); if (initial.length <= MAX_ARCH_PROMPT_CHARS || compact.nodes.length <= 1) { return initial; } const degreeByNode = new Map(); for (const node of compact.nodes) { degreeByNode.set(node.id, 0); } for (const edge of compact.edges) { degreeByNode.set(edge.from, (degreeByNode.get(edge.from) ?? 0) + 1); degreeByNode.set(edge.to, (degreeByNode.get(edge.to) ?? 0) + 1); } const rankedNodes = [...compact.nodes].sort((a, b) => { return (degreeByNode.get(b.id) ?? 0) - (degreeByNode.get(a.id) ?? 0); }); for (let keepCount = rankedNodes.length - 1; keepCount >= 1; keepCount -= 1) { const keptNodes = rankedNodes.slice(0, keepCount); const keptIds = new Set(keptNodes.map((node) => node.id)); const keptEdges = compact.edges.filter((edge) => keptIds.has(edge.from) && keptIds.has(edge.to)); const candidate = JSON.stringify({ nodes: keptNodes, edges: keptEdges }); if (candidate.length <= MAX_ARCH_PROMPT_CHARS) { return candidate; } } const minimal = JSON.stringify({ nodes: [rankedNodes[0]], edges: [] }); if (minimal.length <= MAX_ARCH_PROMPT_CHARS) { return minimal; } return minimal.slice(0, MAX_ARCH_PROMPT_CHARS); } function isQwenHttpError(error: unknown): error is Error & { status: number; responseBody: string } { if (!(error instanceof Error)) { return false; } const candidate = error as Partial<{ status: unknown; responseBody: unknown }>; return typeof candidate.status === 'number' && typeof candidate.responseBody === 'string'; } function isArchVulnerability(value: unknown): value is ArchVulnerability { if (!isRecord(value)) { return false; } const validCategory = value.category === 'broken_trust_boundary' || value.category === 'missing_auth_on_path' || value.category === 'sensitive_data_exposure' || value.category === 'single_point_of_failure' || value.category === 'privilege_escalation_path'; const validSeverity = value.severity === 'CRITICAL' || value.severity === 'HIGH' || value.severity === 'MEDIUM' || value.severity === 'LOW'; return ( typeof value.id === 'string' && validCategory && validSeverity && Array.isArray(value.affected_nodes) && value.affected_nodes.every((nodeId) => typeof nodeId === 'string') && typeof value.description === 'string' && typeof value.recommendation === 'string' ); } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }