ProjectAnalyzer.kt
package com.depanalyzer.core
import com.depanalyzer.cli.ProgressTracker
import com.depanalyzer.cli.VulnerabilitySourceMode
import com.depanalyzer.core.graph.ChainResolver
import com.depanalyzer.core.graph.DependencyGraphBuilder
import com.depanalyzer.parser.*
import com.depanalyzer.parser.gradle.GradleIntegration
import com.depanalyzer.parser.maven.MavenIntegration
import com.depanalyzer.parser.npm.NpmIntegration
import com.depanalyzer.parser.python.PythonIntegration
import com.depanalyzer.report.*
import com.depanalyzer.repository.*
import java.io.File
import java.nio.file.Path
import kotlin.io.path.name
class ProjectAnalyzer(
private val repositoryClient: RepositoryClient = RepositoryClient(),
private val ossIndexClient: OssIndexClient = OssIndexClient(),
private val nvdClient: NvdClient = NvdClient(),
private val projectDetector: ProjectDetector = ProjectDetector()
) {
fun analyze(
projectDir: Path,
includeChains: Boolean = false,
disableMaven: Boolean = false,
disableGradle: Boolean = false,
verbose: Boolean = false,
treeMaxDepth: Int? = null,
treeExpandMode: TreeExpandMode = TreeExpandMode.ALL,
timeoutSeconds: Long = 1800L,
vulnerabilitySourceMode: VulnerabilitySourceMode = VulnerabilitySourceMode.AUTO,
showCommandOutput: Boolean = false,
onPartialReport: ((DependencyReport) -> Unit)? = null
): DependencyReport {
ProgressTracker.advanceProgress("Detección")
val type = projectDetector.detect(projectDir)
val dirFile = projectDir.toFile()
ProgressTracker.logDetected("Proyecto detectado: $type")
ProgressTracker.advanceProgress("Parseo")
val (dependencies, rootNodes) = when (type) {
ProjectType.MAVEN -> {
ProgressTracker.logProcessing("Analizando proyecto Maven...")
val mavenNodes = MavenIntegration.analyzeMavenProject(
projectDir = dirFile,
enableMaven = !disableMaven,
verbose = verbose,
timeoutSeconds = timeoutSeconds,
showCommandOutput = showCommandOutput
)
val parsedDeps = mavenNodes.flatMap { node ->
flattenNodeTree(node)
}
Pair(parsedDeps, mavenNodes)
}
ProjectType.GRADLE_GROOVY -> {
ProgressTracker.logProcessing("Analizando proyecto Gradle (build.gradle)...")
val gradleNodes = GradleIntegration.analyzeGradleProject(
projectDir = dirFile,
enableGradle = !disableGradle,
verbose = verbose,
timeoutSeconds = timeoutSeconds,
showCommandOutput = showCommandOutput
)
val parsedDeps = gradleNodes.flatMap { node ->
flattenNodeTree(node)
}
Pair(parsedDeps, gradleNodes)
}
ProjectType.GRADLE_KOTLIN -> {
ProgressTracker.logProcessing("Analizando proyecto Gradle (build.gradle.kts)...")
val gradleNodes = GradleIntegration.analyzeGradleProject(
projectDir = dirFile,
enableGradle = !disableGradle,
verbose = verbose,
timeoutSeconds = timeoutSeconds,
showCommandOutput = showCommandOutput
)
val parsedDeps = gradleNodes.flatMap { node ->
flattenNodeTree(node)
}
Pair(parsedDeps, gradleNodes)
}
ProjectType.NPM -> {
ProgressTracker.logProcessing("Analizando proyecto Node.js (package.json)...")
NpmIntegration.analyzeNpmProject(dirFile)
}
ProjectType.PYTHON_POETRY -> {
ProgressTracker.logProcessing("Analizando proyecto Python (pyproject.toml/poetry.lock)...")
PythonIntegration.analyzePoetryProject(dirFile)
}
ProjectType.PYTHON_REQUIREMENTS -> {
ProgressTracker.logProcessing("Analizando proyecto Python (requirements.txt)...")
PythonIntegration.analyzeRequirementsProject(dirFile)
}
}
ProgressTracker.advanceProgress("Resolución de repos")
val repositories = when (type) {
ProjectType.MAVEN -> {
val parser = PomDependencyParser()
val pomFile = File(dirFile, "pom.xml")
parser.repositories(pomFile)
}
ProjectType.GRADLE_GROOVY -> {
val buildFile = File(dirFile, "build.gradle")
GradleRepositoryParser().parse(buildFile)
}
ProjectType.GRADLE_KOTLIN -> {
val buildFile = File(dirFile, "build.gradle.kts")
GradleRepositoryParser().parse(buildFile)
}
ProjectType.NPM,
ProjectType.PYTHON_POETRY,
ProjectType.PYTHON_REQUIREMENTS -> {
emptyList()
}
}
val upToDate = mutableListOf<DependencyInfo>()
val outdated = mutableListOf<OutdatedDependency>()
val directDependencies = rootNodes.map { root ->
ParsedDependency(
groupId = root.groupId,
artifactId = root.artifactId,
version = root.version,
scope = root.scope ?: "compile",
section = if (root.isDependencyManagement) DependencySection.DEPENDENCY_MANAGEMENT else DependencySection.DEPENDENCIES,
ecosystem = root.ecosystem
)
}.distinctBy { "${it.ecosystem}:${it.groupId}:${it.artifactId}:${it.version}" }
val initialTree = buildDependencyTree(
vulnerabilityMap = emptyMap(),
outdatedMap = emptyList(),
maxDepth = treeMaxDepth,
expandMode = treeExpandMode,
rootNodes = rootNodes
)
emitPartial(
onPartialReport,
DependencyReport(
projectName = projectDir.name,
dependencyTree = initialTree
)
)
ProgressTracker.advanceProgress("Consulta de versiones")
val distinctDependencies = dependencies.distinctBy { "${it.ecosystem}:${it.groupId}:${it.artifactId}" }
distinctDependencies.forEachIndexed { index, dep ->
val currentVersion = dep.version
if (currentVersion != null && !isVariable(currentVersion)) {
val latest = findLatestVersion(repositories, dep)
if (latest != null && latest != currentVersion) {
outdated.add(
OutdatedDependency(
groupId = dep.groupId,
artifactId = dep.artifactId,
currentVersion = currentVersion,
latestVersion = latest,
ecosystem = dep.ecosystem
)
)
} else {
upToDate.add(
DependencyInfo(
groupId = dep.groupId,
artifactId = dep.artifactId,
version = currentVersion,
ecosystem = dep.ecosystem
)
)
}
} else {
upToDate.add(
DependencyInfo(
groupId = dep.groupId,
artifactId = dep.artifactId,
version = currentVersion ?: "unknown",
ecosystem = dep.ecosystem
)
)
}
val shouldEmitProgressiveSnapshot = onPartialReport != null &&
((index + 1) % 4 == 0 || index == distinctDependencies.lastIndex)
if (shouldEmitProgressiveSnapshot) {
val progressiveTree = buildDependencyTree(
vulnerabilityMap = emptyMap(),
outdatedMap = outdated,
maxDepth = treeMaxDepth,
expandMode = treeExpandMode,
rootNodes = rootNodes
)
emitPartial(
onPartialReport,
DependencyReport(
projectName = projectDir.name,
upToDate = upToDate.toList(),
outdated = outdated.toList(),
dependencyTree = progressiveTree
)
)
}
}
val versionedTree = buildDependencyTree(
vulnerabilityMap = emptyMap(),
outdatedMap = outdated,
maxDepth = treeMaxDepth,
expandMode = treeExpandMode,
rootNodes = rootNodes
)
emitPartial(
onPartialReport,
DependencyReport(
projectName = projectDir.name,
upToDate = upToDate,
outdated = outdated,
dependencyTree = versionedTree
)
)
ProgressTracker.advanceProgress("Árbol transitivo")
// El árbol parcial ya se generó y emitió en `versionedTree` para la UI.
ProgressTracker.logSecurity("Consultando vulnerabilidades...")
ProgressTracker.advanceProgress("CVEs")
val mavenDependencies = dependencies.filter { it.ecosystem == Ecosystem.MAVEN }
val hasNonMaven = dependencies.any { it.ecosystem != Ecosystem.MAVEN }
val vulnerabilityMap = when (vulnerabilitySourceMode) {
VulnerabilitySourceMode.OSS_ONLY -> {
try {
ossIndexClient.getVulnerabilities(dependencies, failOnError = true)
} catch (e: Exception) {
throw IllegalStateException("[OSS] no se pudo obtener vulnerabilidades: ${e.message}", e)
}
}
VulnerabilitySourceMode.NVD_ONLY -> {
if (mavenDependencies.isEmpty()) {
ProgressTracker.logWarning("NVD solo aplica a dependencias Maven. CVE analysis skipped.")
emptyMap()
} else {
try {
nvdClient.getVulnerabilities(mavenDependencies)
} catch (e: Exception) {
throw IllegalStateException("[NVD] no se pudo obtener vulnerabilidades: ${e.message}", e)
}
}
}
VulnerabilitySourceMode.AUTO -> {
val ossVulns = runCatching {
ossIndexClient.getVulnerabilities(dependencies, failOnError = true)
}.getOrNull()
if (ossVulns != null) {
val nvdVulns = if (mavenDependencies.isNotEmpty()) {
runCatching {
ProgressTracker.logSecurity("Enriqueciendo con datos de NVD...")
nvdClient.getVulnerabilities(mavenDependencies)
}.getOrElse {
if (verbose) {
System.err.println(" NVD enrichment failed: ${it.message}")
}
emptyMap()
}
} else {
if (hasNonMaven && verbose) {
System.err.println(" NVD enrichment skipped for non-Maven ecosystems")
}
emptyMap()
}
VulnerabilityMerger.mergeVulnerabilities(ossVulns, nvdVulns)
} else {
runCatching {
if (mavenDependencies.isEmpty()) emptyMap() else nvdClient.getVulnerabilities(mavenDependencies)
}
.getOrElse {
ProgressTracker.logWarning("No se pudo consultar OSS ni NVD. Vulnerability analysis skipped.")
if (verbose) {
System.err.println(" Details OSS/NVD: ${it.message}")
}
emptyMap()
}
}
}
}
val (directVulnerable, transitiveVulnerable) = classifyVulnerabilities(
dependencies = dependencies,
directDependencies = directDependencies,
vulnerabilityMap = vulnerabilityMap
)
val cveTree = buildDependencyTree(
vulnerabilityMap = vulnerabilityMap,
outdatedMap = outdated,
maxDepth = treeMaxDepth,
expandMode = treeExpandMode,
rootNodes = rootNodes
)
emitPartial(
onPartialReport,
DependencyReport(
projectName = projectDir.name,
upToDate = upToDate,
outdated = outdated,
directVulnerable = directVulnerable,
transitiveVulnerable = transitiveVulnerable,
dependencyTree = cveTree
)
)
val chains = if (includeChains) {
buildVulnerabilityChains(dependencies, directDependencies, vulnerabilityMap)
} else {
emptyList()
}
ProgressTracker.logBuilding("Construyendo reporte final...")
val dependencyTree = buildDependencyTree(
vulnerabilityMap = vulnerabilityMap,
outdatedMap = outdated,
maxDepth = treeMaxDepth,
expandMode = treeExpandMode,
rootNodes = rootNodes
)
val report = DependencyReport(
projectName = projectDir.name,
upToDate = upToDate,
outdated = outdated,
directVulnerable = directVulnerable,
transitiveVulnerable = transitiveVulnerable,
vulnerabilityChains = chains,
dependencyTree = dependencyTree
)
val finalReport = attachDirectSourceLocations(
report = report,
projectType = type,
projectDir = dirFile,
directDependencies = directDependencies
)
emitPartial(onPartialReport, finalReport)
return finalReport
}
private fun emitPartial(
onPartialReport: ((DependencyReport) -> Unit)?,
report: DependencyReport
) {
if (onPartialReport == null) return
runCatching { onPartialReport(report) }
}
private fun buildDependencyTree(
vulnerabilityMap: Map<String, List<Vulnerability>>,
outdatedMap: List<OutdatedDependency>,
maxDepth: Int?,
expandMode: TreeExpandMode,
rootNodes: List<com.depanalyzer.core.graph.DependencyNode>
): List<DependencyTreeNode>? {
if (rootNodes.isEmpty()) {
return null
}
val outdatedByCoordinate = outdatedMap.associateBy { "${it.groupId}:${it.artifactId}:${it.currentVersion}" }
val builder = DependencyTreeBuilder(
vulnerabilities = vulnerabilityMap,
outdatedMap = outdatedByCoordinate
)
return builder.buildTree(rootNodes, maxDepth, expandMode).takeIf { it.isNotEmpty() }
}
private fun classifyVulnerabilities(
dependencies: List<ParsedDependency>,
directDependencies: List<ParsedDependency>,
vulnerabilityMap: Map<String, List<Vulnerability>>
): Pair<List<VulnerableDependency>, List<VulnerableDependency>> {
val direct = mutableListOf<VulnerableDependency>()
val transitive = mutableListOf<VulnerableDependency>()
val directCoordinates = directDependencies.map { "${it.groupId}:${it.artifactId}:${it.version}" }.toSet()
vulnerabilityMap.forEach { (coordinates, vulnerabilities) ->
val dep = dependencies.find { "${it.groupId}:${it.artifactId}:${it.version}" == coordinates }
?: return@forEach
val vulnerableDep = VulnerableDependency(
groupId = dep.groupId,
artifactId = dep.artifactId,
version = dep.version!!,
vulnerabilities = vulnerabilities,
dependencyChain = buildDependencyChain(coordinates, dependencies, directDependencies),
ecosystem = dep.ecosystem
)
if (coordinates in directCoordinates) {
direct.add(vulnerableDep)
} else {
transitive.add(vulnerableDep)
}
}
return Pair(direct, transitive)
}
private fun buildDependencyChain(
targetCoordinates: String,
allDependencies: List<ParsedDependency>,
directDependencies: List<ParsedDependency>
): List<String>? {
if (directDependencies.any { "${it.groupId}:${it.artifactId}:${it.version}" == targetCoordinates }) {
return null
}
val target = allDependencies.find { "${it.groupId}:${it.artifactId}:${it.version}" == targetCoordinates }
?: return null
return listOf(target.groupId + ":" + target.artifactId)
}
private fun buildVulnerabilityChains(
dependencies: List<ParsedDependency>,
directDependencies: List<ParsedDependency>,
vulnerabilityMap: Map<String, List<Vulnerability>>
): List<com.depanalyzer.core.graph.VulnerabilityChain> {
val builder = DependencyGraphBuilder()
val graph = builder.buildGraph(
directDependencies = directDependencies,
allDependencies = dependencies,
vulnerabilities = vulnerabilityMap
)
return ChainResolver.resolveAllChains(graph, vulnerabilityMap)
}
private fun findLatestVersion(repos: List<ProjectRepository>, dependency: ParsedDependency): String? {
return repositoryClient.getLatestVersion(dependency, repos)
}
private fun isVariable(version: String): Boolean {
return version.startsWith("$") ||
version.startsWith($$"${") ||
version.any { it in "^~><=*!,| " }
}
private fun flattenNodeTree(node: com.depanalyzer.core.graph.DependencyNode): List<ParsedDependency> {
val result = mutableListOf<ParsedDependency>()
result.add(
ParsedDependency(
groupId = node.groupId,
artifactId = node.artifactId,
version = node.version,
scope = node.scope ?: "compile",
section = if (node.isDependencyManagement) DependencySection.DEPENDENCY_MANAGEMENT else DependencySection.DEPENDENCIES,
ecosystem = node.ecosystem
)
)
node.children.forEach { child ->
result.addAll(flattenNodeTree(child))
}
return result
}
private fun attachDirectSourceLocations(
report: DependencyReport,
projectType: ProjectType,
projectDir: File,
directDependencies: List<ParsedDependency>
): DependencyReport {
val buildFile = when (projectType) {
ProjectType.MAVEN -> File(projectDir, "pom.xml")
ProjectType.GRADLE_GROOVY -> File(projectDir, "build.gradle")
ProjectType.GRADLE_KOTLIN -> File(projectDir, "build.gradle.kts")
ProjectType.NPM -> File(projectDir, "package.json")
ProjectType.PYTHON_POETRY -> File(projectDir, "pyproject.toml")
ProjectType.PYTHON_REQUIREMENTS -> File(projectDir, "requirements.txt")
}
if (!buildFile.isFile) return report
val lines = runCatching { buildFile.readLines() }.getOrElse { return report }
val locations = directDependencies.associateNotNull { dependency ->
val match = lines.withIndex().firstOrNull { (_, line) ->
line.contains(dependency.artifactId, ignoreCase = dependency.ecosystem != Ecosystem.MAVEN)
} ?: return@associateNotNull null
val start = match.value.indexOf(
string = dependency.artifactId,
ignoreCase = dependency.ecosystem != Ecosystem.MAVEN
)
if (start < 0) return@associateNotNull null
dependency.locationKey() to DependencySourceLocation(
file = buildFile.name,
line = match.index + 1,
startColumn = start + 1,
endColumn = start + dependency.artifactId.length + 1
)
}
return report.copy(
upToDate = report.upToDate.map { dependency ->
dependency.copy(sourceLocation = locations[dependency.locationKey()])
},
outdated = report.outdated.map { dependency ->
dependency.copy(sourceLocation = locations[dependency.locationKey()])
},
directVulnerable = report.directVulnerable.map { dependency ->
dependency.copy(sourceLocation = locations[dependency.locationKey()])
}
)
}
private fun ParsedDependency.locationKey(): String = "$ecosystem:$groupId:$artifactId"
private fun DependencyInfo.locationKey(): String = "$ecosystem:$groupId:$artifactId"
private fun OutdatedDependency.locationKey(): String = "$ecosystem:$groupId:$artifactId"
private fun VulnerableDependency.locationKey(): String = "$ecosystem:$groupId:$artifactId"
private inline fun <T, K, V> Iterable<T>.associateNotNull(
transform: (T) -> Pair<K, V>?
): Map<K, V> {
val destination = LinkedHashMap<K, V>()
for (element in this) {
val pair = transform(element) ?: continue
destination[pair.first] = pair.second
}
return destination
}
}