NpmAuditClient.kt
package com.depanalyzer.repository
import com.depanalyzer.parser.Ecosystem
import com.depanalyzer.parser.ParsedDependency
import com.depanalyzer.report.AffectedDependency
import com.depanalyzer.report.Vulnerability
import com.depanalyzer.report.VulnerabilitySeverity
import com.depanalyzer.report.VulnerabilitySource
import tools.jackson.databind.JsonNode
import tools.jackson.databind.json.JsonMapper
import java.io.File
import java.time.Instant
import java.util.concurrent.TimeUnit
data class NpmAuditProcessResult(val exitCode: Int, val stdout: String, val stderr: String)
class NpmAuditClient(
private val runner: (File) -> NpmAuditProcessResult = ::runNpmAudit
) {
private val mapper = JsonMapper.builder().build()
fun getVulnerabilities(
projectDir: File,
dependencies: List<ParsedDependency>
): Map<String, List<Vulnerability>> {
require(File(projectDir, "package-lock.json").isFile) {
"npm audit requiere package-lock.json"
}
val result = runner(projectDir)
require(result.exitCode == 0 || result.exitCode == 1) {
"npm audit fallo con codigo ${result.exitCode}: ${result.stderr.takeLast(1000)}"
}
val root = mapper.readTree(result.stdout)
require(root.path("auditReportVersion").intValue() == 2) {
"npm audit devolvio un formato no compatible"
}
val entries = root.path("vulnerabilities")
if (!entries.isObject) return emptyMap()
val byName = entries.properties().associate { it.key to it.value }
val dependenciesByName = dependencies
.filter { it.ecosystem == Ecosystem.NPM && !it.version.isNullOrBlank() }
.groupBy(ParsedDependency::packageName)
val resultMap = mutableMapOf<String, MutableList<Vulnerability>>()
byName.forEach { (packageName, entry) ->
val advisories = resolveAdvisories(packageName, byName, mutableSetOf())
dependenciesByName[packageName].orEmpty().forEach { dependency ->
val version = requireNotNull(dependency.version)
val affected = AffectedDependency(
dependency.groupId,
dependency.artifactId,
version,
Ecosystem.NPM
)
val key = "${dependency.groupId}:${dependency.artifactId}:$version"
val mapped = advisories.map { advisory -> advisory.toVulnerability(affected, entry) }
if (mapped.isNotEmpty()) {
resultMap.getOrPut(key) { mutableListOf() }.addAll(mapped)
}
}
}
return resultMap.mapValues { (_, values) -> values.distinctBy(Vulnerability::cveId) }
}
private fun resolveAdvisories(
packageName: String,
entries: Map<String, JsonNode>,
visiting: MutableSet<String>
): List<JsonNode> {
if (!visiting.add(packageName)) return emptyList()
val entry = entries[packageName] ?: return emptyList()
val result = mutableListOf<JsonNode>()
entry.path("via").forEach { via ->
if (via.isTextual) {
result.addAll(resolveAdvisories(via.stringValue(), entries, visiting))
} else if (via.isObject) {
result.add(via)
}
}
visiting.remove(packageName)
return result.distinctBy { advisoryId(it) }
}
private fun JsonNode.toVulnerability(
affected: AffectedDependency,
packageEntry: JsonNode
): Vulnerability {
val id = advisoryId(this)
val score = path("cvss").path("score").takeUnless { it.isMissingNode || it.isNull }?.doubleValue()
val severityText = path("severity").textOrBlank().ifBlank { packageEntry.path("severity").textOrBlank() }
val severity = when (severityText.lowercase()) {
"critical" -> VulnerabilitySeverity.CRITICAL
"high" -> VulnerabilitySeverity.HIGH
"moderate", "medium" -> VulnerabilitySeverity.MEDIUM
"low", "info" -> VulnerabilitySeverity.LOW
else -> VulnerabilitySeverity.fromCvssScore(score)
}
val title = path("title").textOrBlank().ifBlank { "Vulnerabilidad reportada por npm audit" }
return Vulnerability(
cveId = id,
severity = severity,
cvssScore = score,
description = title,
affectedDependency = affected,
source = VulnerabilitySource.NPM_AUDIT,
retrievedAt = Instant.now(),
referenceUrl = path("url").textOrBlank().ifBlank { null },
advisoryId = id,
title = title,
cwes = path("cwe").mapNotNull { it.textOrBlank().ifBlank { null } },
aliases = emptyList()
)
}
private fun advisoryId(node: JsonNode): String {
val url = node.path("url").textOrBlank()
val fromUrl = url.substringAfterLast('/').takeIf { it.startsWith("GHSA-") || it.startsWith("CVE-") }
return fromUrl ?: "NPM-${node.path("source").textOrBlank().ifBlank { "UNKNOWN" }}"
}
private fun JsonNode.textOrBlank(): String =
if (isMissingNode || isNull) "" else stringValue().trim()
companion object {
private fun runNpmAudit(projectDir: File): NpmAuditProcessResult {
val npm = if (System.getProperty("os.name").lowercase().contains("win")) "npm.cmd" else "npm"
val process = ProcessBuilder(npm, "audit", "--json", "--ignore-scripts")
.directory(projectDir)
.start()
val stdout = process.inputStream.bufferedReader().readText()
val stderr = process.errorStream.bufferedReader().readText()
if (!process.waitFor(5, TimeUnit.MINUTES)) {
process.destroyForcibly()
error("npm audit excedio 5 minutos")
}
return NpmAuditProcessResult(process.exitValue(), stdout, stderr)
}
}
}