diff --git a/bun.lock b/bun.lock index 911687d..91db460 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@aws-sdk/client-s3": "^3.500.0", "@getgitops/gitdb": "^0.2.0", "@google-cloud/storage": "^7.21.0", + "chart.js": "^4.5.1", "lucide-svelte": "^0.320.0", }, "devDependencies": { @@ -185,6 +186,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@nodable/entities": ["@nodable/entities@2.2.0", "", {}, "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg=="], "@oxc-project/types": ["@oxc-project/types@0.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="], @@ -443,6 +446,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "code-red": ["code-red@1.0.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1", "acorn": "^8.10.0", "estree-walker": "^3.0.3", "periscopic": "^3.1.0" } }, "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw=="], diff --git a/package.json b/package.json index a7ca65c..47d67ba 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@aws-sdk/client-s3": "^3.500.0", "@getgitops/gitdb": "^0.2.0", "@google-cloud/storage": "^7.21.0", + "chart.js": "^4.5.1", "lucide-svelte": "^0.320.0" } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index ce903a3..c416505 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -5,15 +5,32 @@ import { getGitDb } from '$lib/server/gitdb'; getGitDb(); +const authWithToken = async (token: string) => { + return true; +}; + export const handle: Handle = async ({ event, resolve }) => { if ( - event.url.pathname === '/login' || - event.url.pathname.startsWith('/api/auth/') || - event.url.pathname === '/api/code-report/analyse-result' + event.url.pathname === '/login' ) { return resolve(event); } + if (event.url.pathname.startsWith('/api/')) { + const token = event.request.headers.get('Authorization') || ""; + if (!token || token.trim() === '') { + return new Response(null, { status: 401 }); + } + + const isAuthenticated = await authWithToken(token); + + if (!isAuthenticated) { + return new Response(null, { status: 401 }); + } + console.log('✅ [API] Authenticated successfully with token:', token); + return resolve(event); + } + await ensureAuthReady(); await ensureOrganizationReady(); diff --git a/src/lib/code-report/analysis-summary.ts b/src/lib/code-report/analysis-summary.ts index 9a92031..2c1f3ef 100644 --- a/src/lib/code-report/analysis-summary.ts +++ b/src/lib/code-report/analysis-summary.ts @@ -14,6 +14,56 @@ export type AnalysisSummary = { targetsScanned: number; }; +export type VulnerabilityFinding = { + id: string; + packageName: string; + installedVersion: string; + fixedVersion: string; + severity: 'critical' | 'high' | 'medium' | 'low' | 'unknown'; + status: string; + target: string; + packagePath: string; + packageIdentifier: string; + lineStart: number | null; + lineEnd: number | null; + codeSnippet: string; + title: string; + description: string; + primaryUrl: string; + cveUrl: string; + cvssScore: number | null; + cweIds: string[]; + references: string[]; + epssScore: number | null; + epssPercentile: number | null; + publishedDate: string | null; + lastModifiedDate: string | null; +}; + +export type SecretFinding = { + id: string; + ruleId: string; + title: string; + severity: 'critical' | 'high' | 'medium' | 'low' | 'unknown'; + file: string; + lineStart: number | null; + lineEnd: number | null; + match: string; + author: string; + commit: string; + date: string; + entropy: number | null; +}; + +export type SbomComponent = { + name: string; + version: string; + type: string; + purl: string; + licenses: string[]; + locations: string[]; +}; + function emptySummary(): AnalysisSummary { return { vulnerabilities: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }, @@ -70,3 +120,202 @@ export function summarizeAnalysisResult(result: unknown): AnalysisSummary { return summary; } + +export function extractVulnerabilities(result: unknown): VulnerabilityFinding[] { + if (!result || typeof result !== 'object') return []; + + const results = (result as Record).Results; + if (!Array.isArray(results)) return []; + + return results.flatMap((entry) => { + if (!entry || typeof entry !== 'object') return []; + const row = entry as Record; + const vulnerabilities = Array.isArray(row.Vulnerabilities) ? row.Vulnerabilities : []; + + return vulnerabilities.flatMap((vulnerability) => { + if (!vulnerability || typeof vulnerability !== 'object') return []; + const vuln = vulnerability as Record; + const severity = String(vuln.Severity || '').toLowerCase(); + const cvss = vuln.CVSS; + const scores = cvss && typeof cvss === 'object' ? Object.values(cvss) : []; + const score = scores.reduce((highest, source) => { + if (!source || typeof source !== 'object') return highest; + const value = Number((source as Record).V3Score); + return Number.isFinite(value) && (highest === null || value > highest) ? value : highest; + }, null); + + const epss = vuln.EPSS; + const epssScore = + epss && typeof epss === 'object' && Number.isFinite(Number((epss as Record).Score)) + ? Number((epss as Record).Score) + : null; + const epssPercentile = + epss && + typeof epss === 'object' && + Number.isFinite(Number((epss as Record).Percentile)) + ? Number((epss as Record).Percentile) + : null; + + return [ + { + id: String(vuln.VulnerabilityID || `${vuln.PkgName || 'unknown'}-${row.Target || ''}`), + packageName: String(vuln.PkgName || 'Paquete desconocido'), + installedVersion: String(vuln.InstalledVersion || 'desconocida'), + fixedVersion: String(vuln.FixedVersion || ''), + severity: + severity === 'critical' || + severity === 'high' || + severity === 'medium' || + severity === 'low' + ? severity + : 'unknown', + status: String(vuln.Status || 'unknown').toLowerCase(), + target: String(row.Target || 'Target no especificado'), + packagePath: String(vuln.PkgPath || row.Target || 'Ruta no especificada'), + packageIdentifier: String(vuln.PkgIdentifier || ''), + lineStart: Number.isFinite(Number(vuln.StartLine)) ? Number(vuln.StartLine) : null, + lineEnd: Number.isFinite(Number(vuln.EndLine)) ? Number(vuln.EndLine) : null, + codeSnippet: String(vuln.CodeSnippet || vuln.Snippet || ''), + title: String(vuln.Title || 'Vulnerabilidad sin título'), + description: String(vuln.Description || ''), + primaryUrl: String(vuln.PrimaryURL || ''), + cveUrl: `https://nvd.nist.gov/vuln/detail/${String(vuln.VulnerabilityID || '')}`, + cvssScore: score, + cweIds: Array.isArray(vuln.CweIDs) ? vuln.CweIDs.map(String) : [], + references: Array.isArray(vuln.References) ? vuln.References.map(String) : [], + epssScore, + epssPercentile, + publishedDate: vuln.PublishedDate ? String(vuln.PublishedDate) : null, + lastModifiedDate: vuln.LastModifiedDate ? String(vuln.LastModifiedDate) : null, + }, + ]; + }); + }); +} + +// never surface the raw credential in the UI, only enough context to locate it +function maskSecret(value: string) { + const trimmed = value.trim(); + if (!trimmed) return ''; + if (trimmed.length <= 12) return '•'.repeat(trimmed.length); + return `${trimmed.slice(0, 4)}${'•'.repeat(8)}${trimmed.slice(-4)}`; +} + +function normalizeSeverity(value: unknown): SecretFinding['severity'] { + const severity = String(value || '').toLowerCase(); + return severity === 'critical' || + severity === 'high' || + severity === 'medium' || + severity === 'low' + ? severity + : 'unknown'; +} + +// Accepts gitleaks report JSON (top-level array of findings) and Trivy `Results[].Secrets[]`. +export function extractSecrets(result: unknown): SecretFinding[] { + if (!result) return []; + + if (Array.isArray(result)) { + return result.flatMap((entry, index) => { + if (!entry || typeof entry !== 'object') return []; + const row = entry as Record; + const start = Number(row.StartLine); + const end = Number(row.EndLine); + return [ + { + id: String(row.Fingerprint || `${row.RuleID || 'secret'}-${index}`), + ruleId: String(row.RuleID || 'unknown-rule'), + title: String(row.Description || row.RuleID || 'Secreto detectado'), + severity: 'high' as const, + file: String(row.File || 'Archivo no especificado'), + lineStart: Number.isFinite(start) ? start : null, + lineEnd: Number.isFinite(end) ? end : null, + match: maskSecret(String(row.Match || row.Secret || '')), + author: String(row.Author || ''), + commit: String(row.Commit || ''), + date: String(row.Date || ''), + entropy: Number.isFinite(Number(row.Entropy)) ? Number(row.Entropy) : null, + }, + ]; + }); + } + + if (typeof result !== 'object') return []; + const results = (result as Record).Results; + if (!Array.isArray(results)) return []; + + return results.flatMap((entry) => { + if (!entry || typeof entry !== 'object') return []; + const row = entry as Record; + const secrets = Array.isArray(row.Secrets) ? row.Secrets : []; + + return secrets.flatMap((secret, index) => { + if (!secret || typeof secret !== 'object') return []; + const item = secret as Record; + const start = Number(item.StartLine); + const end = Number(item.EndLine); + return [ + { + id: `${String(item.RuleID || 'secret')}-${String(row.Target || '')}-${index}`, + ruleId: String(item.RuleID || 'unknown-rule'), + title: String(item.Title || item.Category || 'Secreto detectado'), + severity: normalizeSeverity(item.Severity), + file: String(item.Target || row.Target || 'Archivo no especificado'), + lineStart: Number.isFinite(start) ? start : null, + lineEnd: Number.isFinite(end) ? end : null, + match: maskSecret(String(item.Match || '')), + author: '', + commit: '', + date: '', + entropy: null, + }, + ]; + }); + }); +} + +// Accepts CycloneDX JSON (`components[]`) and native syft-json (`artifacts[]`). +export function extractSbomComponents(result: unknown): SbomComponent[] { + if (!result || typeof result !== 'object') return []; + const root = result as Record; + const entries = Array.isArray(root.components) + ? root.components + : Array.isArray(root.artifacts) + ? root.artifacts + : []; + + return entries.flatMap((entry) => { + if (!entry || typeof entry !== 'object') return []; + const row = entry as Record; + + const licenses = Array.isArray(row.licenses) + ? row.licenses.flatMap((license) => { + if (typeof license === 'string') return [license]; + if (!license || typeof license !== 'object') return []; + const item = license as Record; + const value = item.license?.id || item.license?.name || item.value || item.spdxExpression; + return value ? [String(value)] : []; + }) + : []; + + const locations = Array.isArray(row.locations) + ? row.locations.flatMap((location) => { + if (typeof location === 'string') return [location]; + if (!location || typeof location !== 'object') return []; + const path = (location as Record).path; + return path ? [String(path)] : []; + }) + : []; + + return [ + { + name: String(row.name || 'Componente sin nombre'), + version: String(row.version || 'desconocida'), + type: String(row.type || 'unknown'), + purl: String(row.purl || ''), + licenses: [...new Set(licenses)], + locations, + }, + ]; + }); +} diff --git a/src/lib/code-report/cve-aggregation.ts b/src/lib/code-report/cve-aggregation.ts new file mode 100644 index 0000000..3a54bdd --- /dev/null +++ b/src/lib/code-report/cve-aggregation.ts @@ -0,0 +1,106 @@ +import { extractVulnerabilities, type VulnerabilityFinding } from './analysis-summary'; + +export type CveOccurrence = { + serviceId: string; + serviceSlug: string; + serviceName: string; + scannedAt: string | null; + finding: VulnerabilityFinding; +}; + +export type CveSummary = { + id: string; + title: string; + severity: VulnerabilityFinding['severity']; + cvssScore: number | null; + affectedServiceCount: number; + occurrenceCount: number; +}; + +const severityRank: Record = { + critical: 4, + high: 3, + medium: 2, + low: 1, + unknown: 0, +}; + +export type CompletedAnalysis = { result: unknown; createdAt: string } | null | undefined; + +// groups every vulnerability finding across a project's services by CVE id, using the +// latest completed analysis available for each service +export function collectCveOccurrences( + services: { id: string; slug: string; name: string }[], + analysisByServiceId: Map, +): Map { + const occurrencesByCve = new Map(); + + for (const service of services) { + const analysis = analysisByServiceId.get(service.id); + if (!analysis) continue; + + for (const finding of extractVulnerabilities(analysis.result)) { + const occurrences = occurrencesByCve.get(finding.id) ?? []; + occurrences.push({ + serviceId: service.id, + serviceSlug: service.slug, + serviceName: service.name, + scannedAt: analysis.createdAt, + finding, + }); + occurrencesByCve.set(finding.id, occurrences); + } + } + + return occurrencesByCve; +} + +export function highestSeverity(occurrences: CveOccurrence[]): VulnerabilityFinding['severity'] { + return occurrences.reduce( + (highest, occurrence) => + severityRank[occurrence.finding.severity] > severityRank[highest] + ? occurrence.finding.severity + : highest, + 'unknown', + ); +} + +export function highestCvssScore(occurrences: CveOccurrence[]): number | null { + return occurrences.reduce((highest, occurrence) => { + const score = occurrence.finding.cvssScore; + return score !== null && (highest === null || score > highest) ? score : highest; + }, null); +} + +export function highestEpssScore(occurrences: CveOccurrence[]): number | null { + return occurrences.reduce((highest, occurrence) => { + const score = occurrence.finding.epssScore; + return score !== null && (highest === null || score > highest) ? score : highest; + }, null); +} + +export function highestEpssPercentile(occurrences: CveOccurrence[]): number | null { + return occurrences.reduce((highest, occurrence) => { + const percentile = occurrence.finding.epssPercentile; + return percentile !== null && (highest === null || percentile > highest) + ? percentile + : highest; + }, null); +} + +export function summarizeCves(occurrencesByCve: Map): CveSummary[] { + return [...occurrencesByCve.entries()] + .map(([id, occurrences]) => ({ + id, + title: occurrences[0]?.finding.title ?? '', + severity: highestSeverity(occurrences), + cvssScore: highestCvssScore(occurrences), + affectedServiceCount: new Set(occurrences.map((occurrence) => occurrence.serviceId)).size, + occurrenceCount: occurrences.length, + })) + .sort( + (left, right) => + severityRank[right.severity] - severityRank[left.severity] || + right.affectedServiceCount - left.affectedServiceCount, + ); +} diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index cad3432..92a5c59 100644 --- a/src/lib/components/AppSidebar.svelte +++ b/src/lib/components/AppSidebar.svelte @@ -22,6 +22,7 @@ Layers, HardDrive, Bot, + ShieldAlert, } from 'lucide-svelte'; type NavItem = { label: string; href: string; icon: ComponentType }; @@ -97,7 +98,13 @@ name: 'Code Report', icon: BarChart3, items: [ + { + label: 'Dashboard', + href: `${projectBase}/code-report/dashboard`, + icon: LayoutDashboard, + }, { label: 'Services', href: `${projectBase}/code-report/services`, icon: Layers }, + { label: 'CVEs', href: `${projectBase}/code-report/cves`, icon: ShieldAlert }, { label: 'History', href: `${projectBase}/code-report/history`, icon: GitBranch }, { label: 'GitOps Report Bot', href: `${projectBase}/code-report/bot`, icon: Bot }, { label: 'Settings', href: `${projectBase}/code-report/settings`, icon: Settings }, diff --git a/src/lib/components/CodeReportVisualization.svelte b/src/lib/components/CodeReportVisualization.svelte new file mode 100644 index 0000000..6416268 --- /dev/null +++ b/src/lib/components/CodeReportVisualization.svelte @@ -0,0 +1,336 @@ + + +
+
+ {#each tabs as tab}{/each} +
+ {#if !analysis}
+ Todavía no se ha ejecutado ningún análisis. +
{:else if activeTab === 'summary'} (riskInfoModalOpen = true)} + />{:else if activeTab === 'vulnerabilities'}
+
+ {#each vulnerabilitySubTabs as subTab}{/each} +
+ {#if activeVulnerabilityTab === 'cve'}{:else}{/if} +
{:else if activeTab === 'secrets'}{:else}{/if} +
+ +{#if riskInfoModalOpen}{/if} diff --git a/src/lib/components/code-report/CodeReportFiles.svelte b/src/lib/components/code-report/CodeReportFiles.svelte new file mode 100644 index 0000000..0f198bf --- /dev/null +++ b/src/lib/components/code-report/CodeReportFiles.svelte @@ -0,0 +1,134 @@ + + +
+ {#if filteredFileGroups.length === 0}

+ No se han detectado vulnerabilidades asociadas a archivos. +

{:else}
+ +
+ {#if selectedFile}
+

+ Archivo seleccionado +

+

+ {selectedFile.path} +

+

+ {selectedFile.vulnerabilities.length} vulnerabilidades detectadas en este archivo +

+
+
+ {#each selectedFile.vulnerabilities as finding, index (finding.id + finding.packageName + finding.installedVersion + index)}
+
+
+ {finding.severity}{finding.id} +
+ Ver CVE ↗ +
+

{finding.packageName}

+

+ {finding.installedVersion} → {finding.fixedVersion || 'Sin versión corregida'} +

+

+ {finding.description || finding.title} +

+
+ {#if finding.lineStart !== null}

+ Línea{finding.lineEnd !== null && finding.lineEnd !== finding.lineStart + ? `s ${finding.lineStart}-${finding.lineEnd}` + : ` ${finding.lineStart}`} +

{:else}

+ Este informe no incluye línea ni fragmento de código. +

{/if}{#if finding.codeSnippet}
{finding.codeSnippet}
{/if} +
+
+ CWE: {finding.cweIds.length > 0 + ? finding.cweIds.join(', ') + : 'No indicado'}{#if finding.primaryUrl}Ver advisory ↗{/if} +
+
{/each} +
{:else}
+
+

+ Selecciona un archivo para inspeccionar +

+

+ Elige un archivo del listado para ver sus vulnerabilidades. +

+
+
{/if} +
+
{/if} +
diff --git a/src/lib/components/code-report/CodeReportSbom.svelte b/src/lib/components/code-report/CodeReportSbom.svelte new file mode 100644 index 0000000..fe6d533 --- /dev/null +++ b/src/lib/components/code-report/CodeReportSbom.svelte @@ -0,0 +1,124 @@ + + +
+ {#if !analysis} +

+ Todavía no se ha generado ningún SBOM con syft en este servicio. +

+ {:else if analysis.status === 'failed'} +
+ {analysis.error || 'La generación del SBOM falló.'} +
+ {:else if analysis.status === 'in_progress'} +

+ La generación del SBOM está en curso. +

+ {:else if components.length === 0} +

+ El SBOM no contiene componentes o el formato no es reconocido. +

+ {:else} +
+
+

Componentes

+

{components.length}

+
+
+

Ecosistemas

+

{componentTypes.length}

+
+
+

Con licencia

+

+ {licensedCount}/{components.length} +

+
+
+
+ + +
+
+ + + + + + + + + + + + {#each filteredComponents as component, index (component.purl + component.name + index)} + + + + + + + + {/each} + +
ComponenteVersiónTipoLicenciasUbicación
+ + {component.name} + + {#if component.purl} + {component.purl} + {/if} + {component.version} + {component.type} + + {component.licenses.length > 0 ? component.licenses.join(', ') : 'No declarada'} + + {component.locations[0] || '—'} +
+
+ {/if} +
diff --git a/src/lib/components/code-report/CodeReportSecrets.svelte b/src/lib/components/code-report/CodeReportSecrets.svelte new file mode 100644 index 0000000..3958991 --- /dev/null +++ b/src/lib/components/code-report/CodeReportSecrets.svelte @@ -0,0 +1,135 @@ + + +
+ {#if !analysis} +

+ Todavía no se ha ejecutado ningún análisis de secretos con gitleaks en este servicio. +

+ {:else if analysis.status === 'failed'} +
+ {analysis.error || 'El análisis de secretos falló.'} +
+ {:else if analysis.status === 'in_progress'} +

+ El análisis de secretos está en curso. +

+ {:else if secrets.length === 0} +

+ No se han detectado secretos expuestos en este análisis. +

+ {:else} +
+
+ {secrets.length} secreto{secrets.length === 1 + ? '' + : 's'} expuesto{secrets.length === 1 ? '' : 's'} +
+ +
+
+ {#each filteredSecrets as secret, index (secret.id + index)} +
+ + {secret.severity} + {secret.ruleId} + {secret.title} + {secret.file}{secret.lineStart !== null ? `:${secret.lineStart}` : ''} + +
+
+
+
Archivo
+
+ {secret.file} +
+
+
+
Líneas
+
+ {secret.lineStart !== null + ? secret.lineEnd !== null && secret.lineEnd !== secret.lineStart + ? `${secret.lineStart}-${secret.lineEnd}` + : secret.lineStart + : 'No indicadas'} +
+
+
+
Entropía
+
+ {secret.entropy !== null ? secret.entropy.toFixed(2) : 'No indicada'} +
+
+ {#if secret.commit} +
+
Commit
+
+ {secret.commit.slice(0, 10)} +
+
+ {/if} + {#if secret.author} +
+
Autor
+
{secret.author}
+
+ {/if} + {#if secret.date} +
+
Fecha
+
+ {new Date(secret.date).toLocaleString()} +
+
+ {/if} +
+ {#if secret.match} +

+ Coincidencia (enmascarada) +

+
{secret.match}
+ {/if} +

+ Rota esta credencial aunque la elimines del código: sigue presente en el historial de + git. +

+
+
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/code-report/CodeReportSummary.svelte b/src/lib/components/code-report/CodeReportSummary.svelte new file mode 100644 index 0000000..e6f1a03 --- /dev/null +++ b/src/lib/components/code-report/CodeReportSummary.svelte @@ -0,0 +1,178 @@ + + +
+
+
+

+ Estado de seguridad +

+

{riskLevel.label}

+

+ Puntuación calculada según la severidad de las vulnerabilidades detectadas. +

+
+
+

Riesgo

+

{riskScore}

+

puntos ponderados

+
+
+ +
+
+
+

+ Información del servicio +

+ {#if service}

{service.name}

+

{service.slug}

+ {#if service.description}

+ {service.description} +

{/if}{#if service.tags?.length}
+ {#each service.tags as tag}{tag}{/each} +
{/if}{:else}

Información no disponible.

{/if} +
+
+
+
+

+ Información del repositorio +

+

+ {analysis?.gitInfo?.repositoryUrl ? 'Repositorio conectado' : 'Sin repositorio conectado'} +

+
+ {#if analysis?.gitInfo?.repositoryUrl}{@const ProviderIcon = providerIcon( + analysis.gitInfo.repositoryUrl, + )}{/if} +
+ {#if analysis?.gitInfo?.repositoryUrl}{analysis.gitInfo.repositoryUrl}{:else}

+ Este análisis no incluye un repositorio configurado. +

{/if}{#if analysis?.gitInfo?.branch}

+ Rama {analysis.gitInfo.branch} +

{/if} +
+
+

+ Histórico de ejecuciones +

+

+ {analysisHistoryLength} análisis completado{analysisHistoryLength === 1 ? '' : 's'} +

+ {#if toolRuns.length > 0}
    + {#each toolRuns as run (run.tool)}
  • +
    + +

    + {run.createdAt ? new Date(run.createdAt).toLocaleString() : 'Nunca ejecutado'} +

    +
    + {run.status ? (runStatusLabels[run.status] ?? run.status) : 'Sin datos'} +
  • {/each} +
{:else if analysis}

+ Último: {analysis.tool}·{new Date(analysis.createdAt).toLocaleString()} +

{/if} +
+
+
+
+

Evolución del riesgo

+
+ {#if historyPoints.length > 0}{:else}
+ Todavía no hay historial suficiente. +
{/if} +
+
+
+

Indicadores clave

+
+ {#each severityKeys as severity}
+

{summary?.vulnerabilities[severity]}

+

+ {severity} +

+
{/each} +
+
+
+
Dependencias
+
{summary?.dependencies}
+
+
+
Archivos afectados
+
{fileCount}
+
+
+
+
diff --git a/src/lib/components/code-report/CodeReportToolBadge.svelte b/src/lib/components/code-report/CodeReportToolBadge.svelte new file mode 100644 index 0000000..dff6a44 --- /dev/null +++ b/src/lib/components/code-report/CodeReportToolBadge.svelte @@ -0,0 +1,37 @@ + + + + + {preset.label} + diff --git a/src/lib/components/code-report/CodeReportVulnerabilities.svelte b/src/lib/components/code-report/CodeReportVulnerabilities.svelte new file mode 100644 index 0000000..300e937 --- /dev/null +++ b/src/lib/components/code-report/CodeReportVulnerabilities.svelte @@ -0,0 +1,129 @@ + + +
+ {#if analysis.status === 'failed' && analysis.error}
+ {analysis.error} +
{:else if vulnerabilities.length === 0}

+ No se han detectado vulnerabilidades en este análisis. +

{:else}
+ +
+
+ {#each filteredVulnerabilities as finding, index (finding.id + finding.target + finding.packageName + index)}
+ {finding.severity}{finding.id}{finding.title}{findingStatus(finding)}{finding.cvssScore !== null + ? `CVSS ${finding.cvssScore.toFixed(1)}` + : 'Sin CVSS'}CVE ↗ +
+

{finding.description || finding.title}

+
+
+

+ Ubicación detectada +

+

{finding.packagePath}

+ {#if finding.packageIdentifier}

+ Identificador: {finding.packageIdentifier} +

{/if}{#if finding.lineStart !== null}

+ Línea{finding.lineEnd !== null && finding.lineEnd !== finding.lineStart + ? `s ${finding.lineStart}-${finding.lineEnd}` + : ` ${finding.lineStart}`} +

{:else}

+ Este informe no incluye línea ni fragmento de código. +

{/if}{#if finding.codeSnippet}
{finding.codeSnippet}
{/if} +
+
+
+
Paquete
+
{finding.packageName}
+
+
+
Estado Trivy
+
{finding.status}
+
+
+
Versión instalada
+
+ {finding.installedVersion} +
+
+
+
Versión corregida
+
+ {finding.fixedVersion || 'No indicada'} +
+
+
+
CWE
+
+ {finding.cweIds.length > 0 ? finding.cweIds.join(', ') : 'No indicado'} +
+
+
+
+
+ Abrir {finding.id} en NVD ↗{#if finding.primaryUrl}Ver advisory ↗{/if} +
+
+
{/each} +
{/if} +
diff --git a/src/lib/database/schemas.ts b/src/lib/database/schemas.ts index c4b7f51..fd0d3c4 100644 --- a/src/lib/database/schemas.ts +++ b/src/lib/database/schemas.ts @@ -121,6 +121,9 @@ export const CodeReportServiceEntity = entity('code_report_services', { tags: json() .notNull() .$defaultFn(() => []), + tools: json() + .notNull() + .$defaultFn(() => ['trivy']), createdAt: timestamp() .notNull() .$defaultFn(() => new Date().toISOString()), diff --git a/src/modules/code-report/application/code-report-analysis.service.ts b/src/modules/code-report/application/code-report-analysis.service.ts index 4c3aadc..e0484fa 100644 --- a/src/modules/code-report/application/code-report-analysis.service.ts +++ b/src/modules/code-report/application/code-report-analysis.service.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import { CodeReportAnalysisRepository } from '../infrastructure/repositories/code-report-analysis.repository'; +import type { CodeReportAnalysisDomain } from '../domain/code-report-analysis.domain'; import type { CodeReportGitInfo } from '../domain/code-report-analysis.domain'; type ServiceLookup = { @@ -17,7 +18,34 @@ export class CodeReportAnalysisService { return analyses.map((analysis) => analysis.toJson()); } + async getLatestByTool(serviceId: string, tools: string[]) { + const analyses = await Promise.all( + tools.map((tool) => this.repository.findLatestByServiceIdAndTool(serviceId, tool)), + ); + + return tools.reduce | null>>( + (accumulator, tool, index) => { + const analysis = analyses[index]; + accumulator[tool] = analysis ? analysis.toJson() : null; + return accumulator; + }, + {}, + ); + } + + async listByProject(serviceIds: string[]) { + const analyses = await Promise.all( + serviceIds.map((serviceId) => this.listByService(serviceId)), + ); + return analyses + .flat() + .sort( + (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), + ); + } + async getById(id: string) { + console.log('🔍 Fetching analysis by ID:', id); const analysis = await this.repository.findById(id); if (!analysis) { throw new Error('Analysis not found'); @@ -27,15 +55,15 @@ export class CodeReportAnalysisService { // called when a scan tool starts running against a service, reports 'in_progress' with no result yet async startAnalysis(input: { serviceId: string; tool: string; gitInfo?: CodeReportGitInfo }) { - const serviceId = input.serviceId?.trim(); - if (!serviceId) { - throw new Error('Service is required'); - } + // const serviceId = input.serviceId?.trim(); + // if (!serviceId) { + // throw new Error('Service is required'); + // } - const service = await this.serviceLookup.findById(serviceId); - if (!service) { - throw new Error('Service not found'); - } + // const service = await this.serviceLookup.findById(serviceId); + // if (!service) { + // throw new Error('Service not found'); + // } const tool = input.tool?.trim(); if (!tool) { @@ -45,13 +73,13 @@ export class CodeReportAnalysisService { const id = crypto.randomUUID(); await this.repository.create({ id, - serviceId, + serviceId: input.serviceId, tool, status: 'in_progress', gitInfo: input.gitInfo, }); - - return this.getById(id); + return { id, serviceId: input.serviceId, tool, status: 'in_progress', gitInfo: input.gitInfo }; + // return this.getById(id); } // called when the tool finishes successfully with the raw JSON result diff --git a/src/modules/code-report/application/code-report-cve.service.ts b/src/modules/code-report/application/code-report-cve.service.ts new file mode 100644 index 0000000..0f23bf9 --- /dev/null +++ b/src/modules/code-report/application/code-report-cve.service.ts @@ -0,0 +1,26 @@ +import { collectCveOccurrences, type CompletedAnalysis, type CveOccurrence } from '$lib/code-report/cve-aggregation'; +import type { CodeReportService } from './code-report.service'; +import type { CodeReportAnalysisService } from './code-report-analysis.service'; + +export class CodeReportCveService { + constructor( + private readonly codeReportService: CodeReportService, + private readonly codeReportAnalysisService: CodeReportAnalysisService, + ) {} + + // only trivy analyses carry CVE-style vulnerability findings today + async getProjectCveOccurrences(projectId: string): Promise> { + const services = await this.codeReportService.listByProject(projectId); + + const analysisEntries = await Promise.all( + services.map(async (service) => { + const latest = await this.codeReportAnalysisService.getLatestByTool(service.id, ['trivy']); + const analysis = latest.trivy; + const completed: CompletedAnalysis = analysis?.status === 'completed' ? analysis : null; + return [service.id, completed] as const; + }), + ); + + return collectCveOccurrences(services, new Map(analysisEntries)); + } +} diff --git a/src/modules/code-report/application/code-report.service.ts b/src/modules/code-report/application/code-report.service.ts index 186ed36..e2ae81c 100644 --- a/src/modules/code-report/application/code-report.service.ts +++ b/src/modules/code-report/application/code-report.service.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; import { CodeReportServiceRepository } from '../infrastructure/repositories/code-report-service.repository'; - +import { ProjectService } from '../../projects/application/project.service'; type AnalysisCleanup = { deleteAllByService(serviceId: string): Promise; }; @@ -8,6 +8,7 @@ type AnalysisCleanup = { export class CodeReportService { constructor( private readonly repository: CodeReportServiceRepository, + private readonly projectService: ProjectService, private readonly analysisCleanup?: AnalysisCleanup, ) {} @@ -23,9 +24,19 @@ export class CodeReportService { } return service.toJson(); } - - async getByProjectAndSlug(projectId: string, slug: string) { - const service = await this.repository.findBySlug(projectId, slug); + async getByProjectIdAndSlug(projectId: string, serviceSlug: string) { + const service = await this.repository.findBySlug(projectId, serviceSlug); + if (!service) { + throw new Error('Service not found'); + } + return service.toJson(); + } + async getByProjectAndSlug(projectSlug: string, serviceSlug: string) { + const project = await this.projectService.getProjectBySlug(projectSlug); + if (!project) { + throw new Error('Project not found'); + } + const service = await this.repository.findBySlug(project.id, serviceSlug); if (!service) { throw new Error('Service not found'); } @@ -40,14 +51,14 @@ export class CodeReportService { } async createService(input: { - projectId: string; + project: string; name: string; slug?: string; description?: string; tags?: string[]; }) { - const projectId = input.projectId?.trim(); - if (!projectId) { + const projectSlug = input.project?.trim(); + if (!projectSlug) { throw new Error('Project is required'); } @@ -60,7 +71,11 @@ export class CodeReportService { if (!slug) { throw new Error('Service slug is required'); } - + const project = await this.projectService.getProjectBySlug(projectSlug); + if (!project) { + throw new Error('Project not found'); + } + const projectId = project.id; const existing = await this.repository.findBySlug(projectId, slug); if (existing) { throw new Error('A service with this slug already exists in this project'); @@ -73,6 +88,7 @@ export class CodeReportService { name, description: input.description?.trim() || undefined, tags: this.normalizeTags(input.tags), + tools: ['trivy', 'gitleaks', 'sbom'] }); const created = await this.repository.findBySlug(projectId, slug); diff --git a/src/modules/code-report/domain/code-report-service.domain.ts b/src/modules/code-report/domain/code-report-service.domain.ts index 46ae1af..25a0ee9 100644 --- a/src/modules/code-report/domain/code-report-service.domain.ts +++ b/src/modules/code-report/domain/code-report-service.domain.ts @@ -7,6 +7,7 @@ export class CodeReportServiceDomain extends Domain { public name: string = ''; public description?: string | null = null; public tags: string[] = []; + public tools: string[] = []; public project: ProjectDomain | null = null; constructor(data: any) { @@ -17,6 +18,7 @@ export class CodeReportServiceDomain extends Domain { this.description = data.description; this.tags = Array.isArray(data.tags) ? data.tags : []; this.project = data.project ? new ProjectDomain(data.project) : null; + this.tools = Array.isArray(data.tools) ? data.tools : []; } toJson() { @@ -30,6 +32,7 @@ export class CodeReportServiceDomain extends Domain { createdAt: this.createdAt, updatedAt: this.updatedAt, project: this.project ? this.project.toJson() : null, + tools: this.tools, }; } } diff --git a/src/modules/code-report/index.ts b/src/modules/code-report/index.ts index 009684f..d5a1654 100644 --- a/src/modules/code-report/index.ts +++ b/src/modules/code-report/index.ts @@ -1,7 +1,9 @@ import { CodeReportService } from './application/code-report.service'; import { CodeReportAnalysisService } from './application/code-report-analysis.service'; +import { CodeReportCveService } from './application/code-report-cve.service'; import { CodeReportServiceRepository } from './infrastructure/repositories/code-report-service.repository'; import { CodeReportAnalysisRepository } from './infrastructure/repositories/code-report-analysis.repository'; +import { projectService } from '../projects'; const codeReportServiceRepository = new CodeReportServiceRepository(); const codeReportAnalysisRepository = new CodeReportAnalysisRepository(); @@ -12,5 +14,10 @@ export const codeReportAnalysisService = new CodeReportAnalysisService( ); export const codeReportService = new CodeReportService( codeReportServiceRepository, + projectService, + codeReportAnalysisService, +); +export const codeReportCveService = new CodeReportCveService( + codeReportService, codeReportAnalysisService, ); diff --git a/src/modules/code-report/infrastructure/repositories/code-report-analysis.repository.ts b/src/modules/code-report/infrastructure/repositories/code-report-analysis.repository.ts index ff0fb08..dc3201d 100644 --- a/src/modules/code-report/infrastructure/repositories/code-report-analysis.repository.ts +++ b/src/modules/code-report/infrastructure/repositories/code-report-analysis.repository.ts @@ -22,6 +22,20 @@ export class CodeReportAnalysisRepository extends Repository { return row ? new CodeReportAnalysisDomain(row) : null; } + async findLatestByServiceIdAndTool( + serviceId: string, + tool: string, + ): Promise { + const result = await this.db + .select() + .from(CodeReportAnalysisEntity) + .where({ serviceId, tool }) + .orderBy('createdAt', 'desc') + .limit(1); + const row = result.rows[0]; + return row ? new CodeReportAnalysisDomain(row) : null; + } + async create(input: { id: string; serviceId: string; diff --git a/src/modules/code-report/infrastructure/repositories/code-report-service.repository.ts b/src/modules/code-report/infrastructure/repositories/code-report-service.repository.ts index 0f6a532..e7bf44d 100644 --- a/src/modules/code-report/infrastructure/repositories/code-report-service.repository.ts +++ b/src/modules/code-report/infrastructure/repositories/code-report-service.repository.ts @@ -63,6 +63,7 @@ export class CodeReportServiceRepository extends Repository { name: string; description?: string; tags?: string[]; + tools?: string[]; }): Promise { await this.db.insert(CodeReportServiceEntity).values({ id: input.id, @@ -71,6 +72,7 @@ export class CodeReportServiceRepository extends Repository { name: input.name, description: input.description, tags: input.tags ?? [], + tools: input.tools ?? [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -78,7 +80,7 @@ export class CodeReportServiceRepository extends Repository { async update( id: string, - changes: { name?: string; slug?: string; description?: string; tags?: string[] }, + changes: { name?: string; slug?: string; description?: string; tags?: string[]; tools?: string[] }, ): Promise { await this.db .update(CodeReportServiceEntity) diff --git a/src/routes/api/code-report/analyse-result/+server.ts b/src/routes/api/code-report/analyse-result/+server.ts deleted file mode 100644 index b0f0ff6..0000000 --- a/src/routes/api/code-report/analyse-result/+server.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { codeReportService, codeReportAnalysisService } from '../../../../modules/code-report'; -import { apiKeysService } from '../../../../modules/auth'; - -type AnalyseResultBody = { - service?: string; - createService?: boolean; - gitInfo?: { - repository?: string; - repositoryUrl?: string; - branch?: string; - commit?: string; - commitMessage?: string; - author?: string; - }; - status?: string; - error?: unknown; - result?: unknown; -}; - -const STATUS_MAP: Record = { - 'in progress': 'in_progress', - in_progress: 'in_progress', - completed: 'completed', - failed: 'failed', -}; - -function normalizeGitInfo(gitInfo: AnalyseResultBody['gitInfo']) { - if (!gitInfo) return undefined; - return { - repositoryUrl: gitInfo.repositoryUrl || gitInfo.repository || null, - branch: gitInfo.branch ?? null, - commit: gitInfo.commit ?? null, - commitMessage: gitInfo.commitMessage ?? null, - author: gitInfo.author ?? null, - }; -} - -function normalizeError(error: unknown): string | undefined { - if (error === undefined || error === null) return undefined; - if (typeof error === 'string') return error; - try { - return JSON.stringify(error); - } catch { - return String(error); - } -} - -// single machine-to-machine endpoint for CI/CD tools: authenticates via a project-scoped -// server access key (Authorization: Bearer gvs_...), not a browser session -export async function POST({ request }) { - const authHeader = request.headers.get('authorization') || ''; - const token = authHeader.replace(/^Bearer\s+/i, '').trim(); - - if (!token) { - return json({ error: 'Missing API key' }, { status: 401 }); - } - - const apiKey = await apiKeysService.resolveApiKey(token); - if (!apiKey || !apiKey.projectId) { - return json({ error: 'Invalid or unscoped API key' }, { status: 401 }); - } - - const body = (await request.json()) as AnalyseResultBody; - - const slug = String(body.service || '').trim(); - if (!slug) { - return json({ error: 'service is required' }, { status: 400 }); - } - - const status = - STATUS_MAP[ - String(body.status || '') - .toLowerCase() - .trim() - ]; - if (!status) { - return json( - { error: "status must be 'in progress', 'completed' or 'failed'" }, - { status: 400 }, - ); - } - - try { - let service = await codeReportService.findBySlugGlobal(slug); - - if (!service) { - if (!body.createService) { - return json({ error: 'Service not found' }, { status: 404 }); - } - service = await codeReportService.createService({ - projectId: apiKey.projectId, - name: slug, - slug, - }); - } else if (service.projectId !== apiKey.projectId) { - return json({ error: 'Service does not belong to this API key project' }, { status: 403 }); - } - - const analysis = await codeReportAnalysisService.reportAnalysis({ - serviceId: service.id, - status, - gitInfo: normalizeGitInfo(body.gitInfo), - result: body.result, - error: normalizeError(body.error), - }); - - return json({ success: true, service: { id: service.id, slug: service.slug }, analysis }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return json({ error: message }, { status: 400 }); - } -} diff --git a/src/routes/api/code-report/scan/+server.ts b/src/routes/api/code-report/scan/+server.ts new file mode 100644 index 0000000..b8d553d --- /dev/null +++ b/src/routes/api/code-report/scan/+server.ts @@ -0,0 +1,136 @@ +import { json } from '@sveltejs/kit'; +import { codeReportService, codeReportAnalysisService } from '../../../../modules/code-report'; +import { apiKeysService } from '../../../../modules/auth'; + +type AnalyseResultBody = { + service: string; + project: string; + analysisId?: string; + gitInfo?: { + repositoryUrl?: string; + branch?: string; + commit?: string; + version?: string; + author?: string; + }; + status?: string; + error?: string | null; + result?: unknown; + tool: string; +}; + +const STATUS_MAP: Record = { + start: 'start', + in_progress: 'in_progress', + completed: 'completed', + failed: 'failed', +}; + +function normalizeGitInfo(gitInfo: AnalyseResultBody['gitInfo']) { + if (!gitInfo) return undefined; + return { + repositoryUrl: gitInfo.repositoryUrl || null, + branch: gitInfo.branch ?? null, + commit: gitInfo.commit ?? null, + author: gitInfo.author ?? null, + version: gitInfo.version ?? null, + }; +} + + +// single machine-to-machine endpoint for CI/CD tools: authenticates via a project-scoped +// server access key (Authorization: Bearer gvs_...), not a browser session +export async function POST({ request }) { + + // console.log('REQUEST BODY:', await request.clone().text()); // Log the request body for debugging + // const authHeader = request.headers.get('authorization') || ''; + // const token = authHeader.replace(/^Bearer\s+/i, '').trim(); + + // if (!token) { + // return json({ error: 'Missing API key' }, { status: 401 }); + // } + + // const apiKey = await apiKeysService.resolveApiKey(token); + // if (!apiKey || !apiKey.projectId) { + // return json({ error: 'Invalid or unscoped API key' }, { status: 401 }); + // } + + const body = (await request.json()) as AnalyseResultBody; + + const status = + STATUS_MAP[ + String(body.status || '') + .toLowerCase() + .trim() + ]; + if (!status) { + return json( + { error: "status must be 'in_progress', 'completed' or 'failed'" }, + { status: 400 }, + ); + } + + let message = ''; + let analysis; + let tools = [] + if(['start', 'in_progress'].includes(status)) { + //use service and project to find or create the service + if (!body.service) { + return json({ error: 'service is required' }, { status: 400 }); + } + if (!body.project) { + return json({ error: 'project is required' }, { status: 400 }); + } + + let serviceCodeReport = await codeReportService.getByProjectAndSlug(body.project, body.service).catch(async (err) => { + console.log('err', err) + return await codeReportService.createService({ + project: body.project, + name: body.service, + }); + }); + tools = serviceCodeReport?.tools || [] + console.log('✅ Service found or created:', serviceCodeReport); + if(status === 'start') { + message = 'Scan started'; + } else if(status === 'in_progress') { + console.log('Starting analysis for service:', serviceCodeReport?.id, 'with tool:', body.tool); + analysis = await codeReportAnalysisService.startAnalysis({ + serviceId: serviceCodeReport?.id || '', + tool: body.tool, + gitInfo: normalizeGitInfo(body.gitInfo), + }); + console.log('ANALYSIS', analysis) + message = `Scan in progress with tool ${body.tool}.`; + } + } else { + // completed or failed + // use analysisId to find + if(!body.analysisId) { + return json({ error: 'analysisId is required for failed status' }, { status: 400 }); + } + if(status === 'failed') { + await codeReportAnalysisService.failAnalysis(body.analysisId, { + error: body.error || 'Unknown error', + gitInfo: normalizeGitInfo(body.gitInfo), + }); + message = 'Scan failed saved'; + } else if (status === 'completed') { + await codeReportAnalysisService.completeAnalysis(body.analysisId, { + result: body.result, + summary: undefined, + }); + } + } + + console.log('[POST] /api/code-report/scan completed with status:', status); + return json({ + success: true, + message: message, + analysis: { + status, + id: analysis?.id + }, + tools, + }); +} diff --git a/src/routes/org/[org]/projects/[slug]/+layout.svelte b/src/routes/org/[org]/projects/[slug]/+layout.svelte index e3a0656..514f486 100644 --- a/src/routes/org/[org]/projects/[slug]/+layout.svelte +++ b/src/routes/org/[org]/projects/[slug]/+layout.svelte @@ -34,7 +34,7 @@ }, { label: 'Open Report', - href: `/org/${orgSlug}/projects/${project.slug}/code-report/services`, + href: `/org/${orgSlug}/projects/${project.slug}/code-report/dashboard`, icon: BarChart3, subtitle: 'Reportes de vulnerabilidades y dependencias del proyecto.', }, diff --git a/src/routes/org/[org]/projects/[slug]/code-report/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/+page.server.ts index a81e7d2..96b57f2 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/+page.server.ts @@ -1,5 +1,5 @@ import { redirect } from '@sveltejs/kit'; export function load({ params }) { - throw redirect(302, `/org/${params.org}/projects/${params.slug}/code-report/services`); + throw redirect(302, `/org/${params.org}/projects/${params.slug}/code-report/dashboard`); } diff --git a/src/routes/org/[org]/projects/[slug]/code-report/cves/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/cves/+page.server.ts new file mode 100644 index 0000000..b949012 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/cves/+page.server.ts @@ -0,0 +1,22 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '../../../../../../../modules/auth'; +import { codeReportCveService } from '../../../../../../../modules/code-report'; +import { summarizeCves } from '$lib/code-report/cve-aggregation'; + +export async function load({ parent, locals }) { + const { project } = await parent(); + + const canRead = await cancanService.canSessionUser(locals.user, 'openreport:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const occurrencesByCve = await codeReportCveService.getProjectCveOccurrences(project.id); + + return { cves: summarizeCves(occurrencesByCve) }; +} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/cves/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/cves/+page.svelte new file mode 100644 index 0000000..454c56d --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/cves/+page.svelte @@ -0,0 +1,181 @@ + + + + Code Report - CVEs - GitVault Suite + + +
+
+ +

+ Este listado muestra los CVEs detectados en el último análisis de cada servicio. + Los servicios sin un análisis reciente completado no aparecen reflejados aquí. +

+
+ +
+ + +
+ + {#if filteredCves.length === 0} +
+ +

+ {data.cves.length === 0 + ? 'Todavía no se han detectado CVEs en este proyecto.' + : 'Sin resultados para tu búsqueda.'} +

+
+ {:else} +
+ + + + + + + + + + + + {#each paginatedCves as cve (cve.id)} + + + + + + + + {/each} + +
CVESeveridadCVSSServicios afectadosApariciones
+ + {cve.id} + + {#if cve.title} +

{cve.title}

+ {/if} +
+ + {#if cve.severity === 'critical'}{/if} + {cve.severity} + + + {cve.cvssScore !== null ? cve.cvssScore.toFixed(1) : '—'} + {cve.affectedServiceCount}{cve.occurrenceCount}
+
+ +
+
+ Mostrando {rangeStart}-{rangeEnd} de {filteredCves.length} + +
+ +
+ + Página {currentPage} de {totalPages} + +
+
+ {/if} +
diff --git a/src/routes/org/[org]/projects/[slug]/code-report/cves/[cve]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/cves/[cve]/+page.server.ts new file mode 100644 index 0000000..e615a7d --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/cves/[cve]/+page.server.ts @@ -0,0 +1,82 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '../../../../../../../../modules/auth'; +import { codeReportCveService } from '../../../../../../../../modules/code-report'; +import { + highestCvssScore, + highestEpssPercentile, + highestEpssScore, + highestSeverity, +} from '$lib/code-report/cve-aggregation'; + +export async function load({ parent, locals, params }) { + const { project } = await parent(); + + const canRead = await cancanService.canSessionUser(locals.user, 'openreport:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const occurrencesByCve = await codeReportCveService.getProjectCveOccurrences(project.id); + const occurrences = occurrencesByCve.get(params.cve); + + if (!occurrences || occurrences.length === 0) { + throw error(404, 'CVE not found'); + } + + const first = occurrences[0].finding; + const publishedDate = + occurrences.map((occurrence) => occurrence.finding.publishedDate).find(Boolean) ?? null; + const lastModifiedDate = + occurrences.map((occurrence) => occurrence.finding.lastModifiedDate).find(Boolean) ?? null; + + const cve = { + id: params.cve, + title: first.title, + description: first.description, + severity: highestSeverity(occurrences), + cvssScore: highestCvssScore(occurrences), + epssScore: highestEpssScore(occurrences), + epssPercentile: highestEpssPercentile(occurrences), + primaryUrl: first.primaryUrl, + cveUrl: first.cveUrl, + cweIds: first.cweIds, + references: [...new Set(occurrences.flatMap((occurrence) => occurrence.finding.references))], + publishedDate, + lastModifiedDate, + }; + + // one remediation entry per distinct package, listing the fixed version(s) trivy reported + const remediations = [ + ...new Map( + occurrences.map((occurrence) => [ + occurrence.finding.packageName, + { + packageName: occurrence.finding.packageName, + installedVersion: occurrence.finding.installedVersion, + fixedVersion: occurrence.finding.fixedVersion, + status: occurrence.finding.status, + }, + ]), + ).values(), + ]; + + const affectedServices = occurrences.map((occurrence) => ({ + serviceId: occurrence.serviceId, + serviceSlug: occurrence.serviceSlug, + serviceName: occurrence.serviceName, + packageName: occurrence.finding.packageName, + installedVersion: occurrence.finding.installedVersion, + fixedVersion: occurrence.finding.fixedVersion, + target: occurrence.finding.target, + severity: occurrence.finding.severity, + status: occurrence.finding.status, + scannedAt: occurrence.scannedAt, + })); + + return { cve, remediations, affectedServices }; +} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/cves/[cve]/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/cves/[cve]/+page.svelte new file mode 100644 index 0000000..0816c12 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/cves/[cve]/+page.svelte @@ -0,0 +1,501 @@ + + + + {data.cve.id} - Code Report - GitVault Suite + + +
+ + Volver a CVEs + + +
+
+

{data.cve.id}

+ + {data.cve.severity} + + {#if data.cve.cvssScore !== null} + CVSS {data.cve.cvssScore.toFixed(1)} + {/if} +
+ {#if data.cve.title} +

{data.cve.title}

+ {/if} +
+ +
+ {#each tabs as tab} + + {/each} +
+ + {#if activeTab === 'info'} +
+
+ +
+

Severidad

+

{data.cve.severity}

+
+
+ {#if data.cve.publishedDate} +
+ +
+

Publicado

+

{new Date(data.cve.publishedDate).toLocaleDateString()}

+
+
+ {/if} +
+ +
+

Overview

+

{data.cve.description || 'Sin descripción disponible.'}

+ {#if data.cve.lastModifiedDate} +

+ Última actualización: {new Date(data.cve.lastModifiedDate).toLocaleDateString()} +

+ {/if} +
+ +
+

CWE

+ {#if data.cve.cweIds.length === 0} +

No se ha indicado una clasificación CWE para este CVE.

+ {:else} +
+ {#each data.cve.cweIds as cweId (cweId)} + + {cweId} + + {/each} +
+ {/if} +
+ +
+
+
+ + CVSS + + + +
+

+ {data.cve.cvssScore !== null ? data.cve.cvssScore.toFixed(1) : '—'} +

+
+
+
+

Escala 0-10

+
+ +
+
+ + EPSS Score + + + +
+

+ {data.cve.epssScore !== null ? `${(data.cve.epssScore * 100).toFixed(3)}%` : '—'} +

+
+
+
+

Probabilidad de explotación

+
+ +
+
+ + EPSS Percentil + + + +
+

+ {data.cve.epssPercentile !== null ? `${(data.cve.epssPercentile * 100).toFixed(1)}%` : '—'} +

+
+
+
+

Frente al resto de CVEs conocidas

+
+
+ +
+

+ Cómo solucionarlo +

+ {#if data.remediations.some((remediation) => remediation.fixedVersion)} +
    + {#each data.remediations as remediation (remediation.packageName)} +
  • + {remediation.packageName} + {#if remediation.fixedVersion} +
    + {remediation.installedVersion} + + {#each splitVersions(remediation.fixedVersion) as version (version)} + + {version} + + {/each} +
    + {:else} + — todavía no hay una versión corregida publicada. + {/if} +
  • + {/each} +
+ {:else} +

+ Ninguno de los paquetes afectados tiene aún una versión corregida publicada. Revisa el advisory + para posibles mitigaciones alternativas. +

+ {/if} +
+ +
+

References

+ {#if data.cve.references.length === 0 && !data.cve.primaryUrl} +

No hay referencias disponibles para este CVE.

+ {:else} + + {/if} +
+ {:else} +
+ +
+ +
+ + + + + + + + + + + + + {#each paginatedAffectedServices as service (service.serviceId + service.target + service.packageName)} + {@const target = splitTarget(service.target)} + + + + + + + + + {/each} + {#if paginatedAffectedServices.length === 0} + + + + {/if} + +
ServicioPaqueteVersión instaladaVersión corregidaObjetivoÚltimo escaneo
+ + {service.serviceName} + + {service.packageName} + {service.installedVersion} + + {#if service.fixedVersion} +
+ {#each splitVersions(service.fixedVersion) as version (version)} + + {version} + + {/each} +
+ {:else} + No indicada + {/if} +
+ {#if target.dirParts.length > 0} +

+ {#each target.dirParts as part, i (i)}{part}{#if i < target.dirParts.length - 1}/{/if}{/each}/ +

+ {/if} +

{target.file}

+
+ {service.scannedAt ? new Date(service.scannedAt).toLocaleString() : '—'} +
+ {data.affectedServices.length === 0 + ? 'No hay servicios afectados.' + : 'Sin resultados para tu búsqueda.'} +
+
+ +
+
+ Mostrando {affectedServicesRangeStart}-{affectedServicesRangeEnd} de {filteredAffectedServices.length} + +
+ +
+ + + Página {affectedServicesPage} de {affectedServicesTotalPages} + + +
+
+ {/if} +
diff --git a/src/routes/org/[org]/projects/[slug]/code-report/dashboard/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/dashboard/+page.server.ts new file mode 100644 index 0000000..d900807 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/dashboard/+page.server.ts @@ -0,0 +1,119 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '../../../../../../../modules/auth'; +import { + codeReportAnalysisService, + codeReportCveService, + codeReportService, +} from '../../../../../../../modules/code-report'; +import { extractSecrets, summarizeAnalysisResult } from '$lib/code-report/analysis-summary'; +import { summarizeCves } from '$lib/code-report/cve-aggregation'; + +const STALE_AFTER_DAYS = 30; +const riskWeights = { critical: 10, high: 6, medium: 3, low: 1, unknown: 0 }; + +export async function load({ parent, locals }) { + const { project } = await parent(); + + const canRead = await cancanService.canSessionUser(locals.user, 'openreport:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const services = await codeReportService.listByProject(project.id); + + const serviceStats = await Promise.all( + services.map(async (service) => { + const latest = await codeReportAnalysisService.getLatestByTool(service.id, [ + 'trivy', + 'gitleaks', + ]); + const trivyAnalysis = latest.trivy?.status === 'completed' ? latest.trivy : null; + const gitleaksAnalysis = latest.gitleaks?.status === 'completed' ? latest.gitleaks : null; + + const summary = trivyAnalysis + ? summarizeAnalysisResult(trivyAnalysis.result) + : summarizeAnalysisResult(null); + const exposedSecrets = gitleaksAnalysis ? extractSecrets(gitleaksAnalysis.result).length : 0; + + const lastScanAt = [latest.trivy?.createdAt, latest.gitleaks?.createdAt] + .filter((value): value is string => Boolean(value)) + .sort((left, right) => new Date(right).getTime() - new Date(left).getTime())[0] ?? null; + + return { + id: service.id, + slug: service.slug, + name: service.name, + scanned: trivyAnalysis !== null, + severity: summary.vulnerabilities, + exposedSecrets, + lastScanAt, + }; + }), + ); + + const occurrencesByCve = await codeReportCveService.getProjectCveOccurrences(project.id); + const cves = summarizeCves(occurrencesByCve); + + const remediableCves = [...occurrencesByCve.values()].filter((occurrences) => + occurrences.some((occurrence) => occurrence.finding.fixedVersion), + ).length; + + const severityBreakdown = serviceStats.reduce( + (totals, service) => ({ + critical: totals.critical + service.severity.critical, + high: totals.high + service.severity.high, + medium: totals.medium + service.severity.medium, + low: totals.low + service.severity.low, + unknown: totals.unknown + service.severity.unknown, + }), + { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }, + ); + + const staleCutoff = Date.now() - STALE_AFTER_DAYS * 24 * 60 * 60 * 1000; + const staleServices = serviceStats + .filter((service) => !service.lastScanAt || new Date(service.lastScanAt).getTime() < staleCutoff) + .sort((left, right) => { + if (!left.lastScanAt) return -1; + if (!right.lastScanAt) return 1; + return new Date(left.lastScanAt).getTime() - new Date(right.lastScanAt).getTime(); + }); + + const riskiestServices = serviceStats + .map((service) => ({ + ...service, + riskScore: + service.severity.critical * riskWeights.critical + + service.severity.high * riskWeights.high + + service.severity.medium * riskWeights.medium + + service.severity.low * riskWeights.low, + })) + .filter((service) => service.riskScore > 0) + .sort((left, right) => right.riskScore - left.riskScore) + .slice(0, 5); + + const topCves = cves.slice(0, 8); + + return { + project, + kpis: { + totalServices: services.length, + scannedServices: serviceStats.filter((service) => service.scanned).length, + totalCves: cves.length, + criticalCount: severityBreakdown.critical, + highCount: severityBreakdown.high, + exposedSecrets: serviceStats.reduce((total, service) => total + service.exposedSecrets, 0), + remediationCoveragePercent: + cves.length > 0 ? Math.round((remediableCves / cves.length) * 100) : null, + staleServicesCount: staleServices.length, + }, + severityBreakdown, + topCves, + riskiestServices, + staleServices: staleServices.slice(0, 5), + }; +} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/dashboard/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/dashboard/+page.svelte new file mode 100644 index 0000000..36f38fa --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/dashboard/+page.svelte @@ -0,0 +1,333 @@ + + + + Code Report - Dashboard - GitVault Suite + + +
+
+
+
+ Servicios +
+

{data.kpis.totalServices}

+

+ {data.kpis.scannedServices} con al menos un análisis completado +

+
+ +
+
+ CVEs críticos + altos +
+

+ {data.kpis.criticalCount + data.kpis.highCount} +

+

de {data.kpis.totalCves} CVEs detectados en total

+
+ +
+
+ Secretos expuestos +
+

{data.kpis.exposedSecrets}

+

detectados en los últimos análisis de gitleaks

+
+ +
+
+ Cobertura de remediación +
+

+ {data.kpis.remediationCoveragePercent !== null + ? `${data.kpis.remediationCoveragePercent}%` + : '—'} +

+

CVEs con una versión corregida disponible

+
+
+ +
+
+
+

Distribución de severidad

+

Vulnerabilidades del último análisis de cada servicio.

+ {#if totalSeverity === 0} +

No se han detectado vulnerabilidades.

+ {:else} +
+
+ +
+ {totalSeverity} + Total +
+
+
    + {#each Object.entries(severityColors) as [key, color] (key)} +
  • + + + {key} + + {data.severityBreakdown[key]} +
  • + {/each} +
+
+ {/if} +
+ +
+

Security Policy

+

+ Umbrales de aceptación y políticas de seguridad para este proyecto. +

+
+ Próximamente +
+
+
+ +
+

+ CVEs más críticos +

+ {#if data.topCves.length === 0} +

No se han detectado CVEs en este proyecto.

+ {:else} + + + Ver todos los CVEs → + + {/if} +
+
+ +
+
+

+ Servicios con más riesgo +

+ {#if data.riskiestServices.length === 0} +

Ningún servicio tiene vulnerabilidades detectadas.

+ {:else} + + {/if} +
+ +
+

+ Servicios sin análisis recientes +

+

Sin un escaneo completado en los últimos 30 días.

+ {#if data.staleServices.length === 0} +

Todos los servicios tienen análisis recientes.

+ {:else} + + {/if} +
+
+
diff --git a/src/routes/org/[org]/projects/[slug]/code-report/history/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/history/+page.server.ts new file mode 100644 index 0000000..48bbe6c --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/history/+page.server.ts @@ -0,0 +1,30 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '../../../../../../../modules/auth'; +import { + codeReportAnalysisService, + codeReportService, +} from '../../../../../../../modules/code-report'; + +export async function load({ parent, locals }) { + const { project } = await parent(); + if ( + !(await cancanService.canSessionUser(locals.user, 'openreport:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + })) + ) + throw error(403, 'Forbidden'); + const services = await codeReportService.listByProject(project.id); + const analyses = await codeReportAnalysisService.listByProject( + services.map((service) => service.id), + ); + const serviceById = new Map(services.map((service) => [service.id, service])); + return { + services, + analyses: analyses.map((analysis) => ({ + ...analysis, + service: serviceById.get(analysis.serviceId), + })), + }; +} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/history/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/history/+page.svelte new file mode 100644 index 0000000..25723c9 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/history/+page.svelte @@ -0,0 +1,233 @@ + + +Histórico de Code Report - GitVault Suite +
+ Volver a servicios +
+

Code Report

+

Histórico de análisis

+

+ Consulta todos los informes del proyecto y abre cualquier ejecución. +

+
+
+ +
+ + {#if openDropdown === 'service'} +
+ + {#each data.services as service} + + {/each} +
+ {/if} +
+
+ + {#if openDropdown === 'status'} +
+ {#each statusOptions as option} + + {/each} +
+ {/if} +
+ +
+

+ {filteredAnalyses.length} informe{filteredAnalyses.length === 1 ? '' : 's'} +

+ +
diff --git a/src/routes/org/[org]/projects/[slug]/code-report/history/[analysisID]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/history/[analysisID]/+page.server.ts new file mode 100644 index 0000000..286f164 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/history/[analysisID]/+page.server.ts @@ -0,0 +1,27 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '../../../../../../../../modules/auth'; +import { + codeReportAnalysisService, + codeReportService, +} from '../../../../../../../../modules/code-report'; + +export async function load({ parent, params, locals }) { + const { project } = await parent(); + if ( + !(await cancanService.canSessionUser(locals.user, 'openreport:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + })) + ) + throw error(403, 'Forbidden'); + const analysis = await codeReportAnalysisService.getById(params.analysisID); + const services = await codeReportService.listByProject(project.id); + const service = services.find((item) => item.id === analysis.serviceId); + if (!service) throw error(404, 'Analysis not found'); + return { + service, + analysis, + analysisHistory: await codeReportAnalysisService.listByService(service.id), + }; +} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/history/[analysisID]/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/history/[analysisID]/+page.svelte new file mode 100644 index 0000000..054a43d --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/code-report/history/[analysisID]/+page.svelte @@ -0,0 +1,34 @@ + + +{data.service.name} - Histórico de Code Report +
+ Volver al histórico +
+

Informe consultado

+

{data.service.name}

+
+ {data.analysis.id}{new Date(data.analysis.createdAt).toLocaleString()}{data.analysis.status} +
+
+ +
diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts index 3e1d10f..7cb97b9 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts @@ -1,7 +1,8 @@ import { error, fail } from '@sveltejs/kit'; -import { codeReportService } from '../../../../../../../modules/code-report'; +import { codeReportService, codeReportAnalysisService } from '../../../../../../../modules/code-report'; import { projectService } from '../../../../../../../modules/projects'; import { cancanService } from '../../../../../../../modules/auth'; +import { summarizeAnalysisResult } from '$lib/code-report/analysis-summary'; export async function load({ parent, locals }) { const { project } = await parent(); @@ -18,7 +19,22 @@ export async function load({ parent, locals }) { const services = await codeReportService.listByProject(project.id); - return { services }; + const servicesWithSeverity = await Promise.all( + services.map(async (service) => { + const latest = await codeReportAnalysisService.getLatestByTool(service.id, ['trivy']); + const analysis = latest.trivy; + const summary = + analysis?.status === 'completed' ? summarizeAnalysisResult(analysis.result) : null; + + return { + ...service, + lastScanAt: analysis?.createdAt ?? null, + severity: summary?.vulnerabilities ?? null, + }; + }), + ); + + return { services: servicesWithSeverity }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte index c93e06f..d0a8df8 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte @@ -3,14 +3,31 @@ import { enhance } from '$app/forms'; import { Boxes, Plus, Search, X } from 'lucide-svelte'; + type SeverityCounts = { + critical: number; + high: number; + medium: number; + low: number; + unknown: number; + }; + type ServiceRow = { id: string; slug: string; name: string; description?: string | null; tags: string[]; + lastScanAt?: string | null; + severity?: SeverityCounts | null; }; + const severityStyles: { key: keyof SeverityCounts; label: string; className: string }[] = [ + { key: 'critical', label: 'Critical', className: 'border-red-200 bg-red-50 text-red-700' }, + { key: 'high', label: 'High', className: 'border-orange-200 bg-orange-50 text-orange-700' }, + { key: 'medium', label: 'Medium', className: 'border-amber-200 bg-amber-50 text-amber-700' }, + { key: 'low', label: 'Low', className: 'border-slate-200 bg-slate-50 text-slate-600' }, + ]; + export let data: { services: ServiceRow[] }; export let form: { success?: boolean; @@ -136,6 +153,31 @@ {/each} {/if} +
+ {#if service.severity} +
+ {#each severityStyles as severity} +
+

+ {service.severity[severity.key]} +

+

+ {severity.label} +

+
+ {/each} +
+ {#if service.lastScanAt} +

+ Último escaneo: {new Date(service.lastScanAt).toLocaleString()} +

+ {/if} + {:else} +

Sin análisis de vulnerabilidades todavía.

+ {/if} +
{/each} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts index c4f5054..5ade227 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts @@ -20,11 +20,29 @@ export async function load({ parent, params, locals }) { } try { - const service = await codeReportService.getByProjectAndSlug(project.id, params.serviceSlug); + const service = await codeReportService.getByProjectIdAndSlug(project.id, params.serviceSlug); const analyses = await codeReportAnalysisService.listByService(service.id); const latestAnalysis = analyses[0] ?? null; - - return { service, latestAnalysis }; + const analysisHistory = analyses + .filter((analysis) => analysis.status === 'completed') + .map((analysis) => ({ + id: analysis.id, + createdAt: analysis.createdAt, + result: analysis.result, + summary: analysis.summary, + status: analysis.status, + tool: analysis.tool, + gitInfo: analysis.gitInfo, + error: analysis.error, + updatedAt: analysis.updatedAt, + })); + + const latestByTool = await codeReportAnalysisService.getLatestByTool( + service.id, + service.tools ?? [], + ); + + return { service, latestAnalysis, latestByTool, analysisHistory }; } catch { throw error(404, 'Service not found'); } diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte index 2590700..565b732 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte @@ -1,55 +1,17 @@ - - {data.service.name} - Code Report - GitVault Suite - +{data.service.name} - Code Report - GitVault Suite
-
+
- - Volver a servicios + Volver a servicios - -
+
- - Ver histórico + Ver histórico -
-
-
-

{data.service.name}

-

- Slug: - {data.service.slug} -

- {#if data.service.description} -

Description

-

- {data.service.description} -

- {/if} - {#if data.service.tags.length > 0} -
- {#each data.service.tags as tag} - - {tag} - - {/each} -
- {/if} - - {#if repositoryUrl && repositoryIcon} - - - - {/if} -
- -
-

- - Vulnerabilidades detectadas -

- {#if analysisSummary} -
-
-

{analysisSummary.vulnerabilities.critical}

-

Critical

-
-
-

{analysisSummary.vulnerabilities.high}

-

High

-
-
-

{analysisSummary.vulnerabilities.medium}

-

Medium

-
-
-

{analysisSummary.vulnerabilities.low}

-

Low

-
-
- {:else} -

Sin datos todavía.

- {/if} -
- -
-

Resumen

- {#if analysisSummary} -
-
-
- Total vulnerabilidades -
-
- {analysisSummary.totalVulnerabilities} -
-
-
-
- Secretos expuestos -
-
{analysisSummary.exposedSecrets}
-
-
-
- Dependencias -
-
{analysisSummary.dependencies}
-
-
-
- Archivos analizados -
-
{analysisSummary.targetsScanned}
-
-
- {:else} -

Sin datos todavía.

- {/if} -
-
- -
-

Último análisis

- - {#if !data.latestAnalysis} -

Todavía no se ha ejecutado ningún análisis.

- {:else} -
- - {data.latestAnalysis.status} - - {data.latestAnalysis.tool} - - - {new Date(data.latestAnalysis.createdAt).toLocaleString()} - -
- - {#if data.latestAnalysis.gitInfo} -
- - {#if data.latestAnalysis.gitInfo.repositoryUrl} - {data.latestAnalysis.gitInfo.repositoryUrl} - {/if} - {#if data.latestAnalysis.gitInfo.branch} - @ {data.latestAnalysis.gitInfo.branch} - {/if} - {#if data.latestAnalysis.gitInfo.commit} - {data.latestAnalysis.gitInfo.commit.slice(0, 7)} - {/if} -
- {/if} - - {#if data.latestAnalysis.status === 'failed' && data.latestAnalysis.error} -
- - {data.latestAnalysis.error} -
- {:else} -
{JSON.stringify(
-            data.latestAnalysis.result ?? {},
-            null,
-            2,
-          )}
- {/if} - {/if} -
+
{#if deleteModalOpen}
-

Borrar servicio

- - -
-

- Vas a borrar {data.service.name} y - todos sus análisis. Esta acción no se - puede deshacer. + Vas a borrar {data.service.name} y todos sus análisis. Esta acción no se puede + deshacer.

- - {#if deleteError} -

{deleteError}

- {/if} - + {#if deleteError}

+ {deleteError} +

{/if}
- Cancelar - - {deleting ? 'Borrando...' : 'Borrar servicio y análisis'} - -
-
-
-{/if} - -{#if uploadModalOpen} -
-
-
-

Subir análisis

- -
- -
{ - uploading = true; - return async ({ update }) => { - await update(); - uploading = false; - }; - }} - class="mt-4 space-y-4" - > - {#if uploadError} -

{uploadError}

- {/if} - - - - - -
- - -