RepositoryClient.kt
package com.depanalyzer.repository
import com.depanalyzer.parser.Ecosystem
import com.depanalyzer.parser.ParsedDependency
import com.depanalyzer.security.InputSafety
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
import org.w3c.dom.Element
import org.xml.sax.InputSource
import tools.jackson.databind.json.JsonMapper
import java.io.IOException
import java.io.StringReader
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import javax.xml.parsers.DocumentBuilderFactory
class RepositoryClient(
connectTimeoutSeconds: Long = 10,
readTimeoutSeconds: Long = 10,
private val trustedCredentialHosts: Set<String> =
InputSafety.parseTrustedCredentialHosts(System.getenv(InputSafety.CREDENTIAL_HOST_ALLOWLIST_ENV)),
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(connectTimeoutSeconds, TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.build()
) {
private data class MavenMetadataValues(
val latest: String?,
val release: String?,
val versions: List<String>
)
private val jsonMapper = JsonMapper.builder().build()
fun getLatestVersion(dependency: ParsedDependency, repositories: List<ProjectRepository> = emptyList()): String? {
return when (dependency.ecosystem) {
Ecosystem.MAVEN -> repositories.firstNotNullOfOrNull { repo ->
getLatestVersion(repo, dependency.groupId, dependency.artifactId)
}
Ecosystem.NPM -> {
val packageName = dependency.packageName
getLatestNpmVersion(packageName)
}
Ecosystem.PYPI -> {
getLatestPypiVersion(dependency.packageName)
}
}
}
fun getLatestVersion(repository: ProjectRepository, groupId: String, artifactId: String): String? {
val metadata = fetchMetadata(repository, groupId, artifactId) ?: return null
val candidates = buildList {
metadata.release?.let(::add)
metadata.latest?.let(::add)
metadata.versions.asReversed().forEach(::add)
}
return candidates
.map(String::trim)
.firstOrNull(InputSafety::isSafeVersion)
}
private fun getLatestNpmVersion(packageName: String): String? {
val encodedName = URLEncoder.encode(packageName, Charsets.UTF_8).replace("+", "%20")
val url = "https://registry.npmjs.org/$encodedName"
val request = Request.Builder().url(url).build()
return try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body.string()
val root = jsonMapper.readTree(body)
val latest = root.path("dist-tags").path("latest").textOrEmpty()
latest.takeIf(InputSafety::isSafeVersion)
}
} catch (_: Exception) {
null
}
}
private fun getLatestPypiVersion(packageName: String): String? {
val encodedName = URLEncoder.encode(packageName, Charsets.UTF_8).replace("+", "%20")
val url = "https://pypi.org/pypi/$encodedName/json"
val request = Request.Builder().url(url).build()
return try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body.string()
val root = jsonMapper.readTree(body)
val latest = root.path("info").path("version").textOrEmpty()
latest.takeIf(InputSafety::isSafeVersion)
}
} catch (_: Exception) {
null
}
}
private fun fetchMetadata(
repository: ProjectRepository,
groupId: String,
artifactId: String
): MavenMetadataValues? {
if (!InputSafety.isAllowedRepositoryUrl(repository.url)) return null
val url = buildMetadataUrl(repository.url, groupId, artifactId)
if (!InputSafety.isAllowedRepositoryUrl(url)) return null
val requestBuilder = runCatching { Request.Builder().url(url) }.getOrNull() ?: return null
val allowCredentials = InputSafety.isTrustedCredentialDestination(url, trustedCredentialHosts)
if (allowCredentials && repository.username != null && repository.password != null) {
requestBuilder.header("Authorization", Credentials.basic(repository.username, repository.password))
}
val request = requestBuilder.build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body.string()
return parseMavenMetadata(body)
}
} catch (_: IOException) {
return null
}
}
private fun parseMavenMetadata(xml: String): MavenMetadataValues? {
return runCatching {
val factory = DocumentBuilderFactory.newInstance().apply {
isNamespaceAware = false
isXIncludeAware = false
isExpandEntityReferences = false
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
setFeature("http://xml.org/sax/features/external-general-entities", false)
setFeature("http://xml.org/sax/features/external-parameter-entities", false)
setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
}
val document = factory.newDocumentBuilder().parse(InputSource(StringReader(xml)))
val root = document.documentElement ?: return null
val latest = firstText(root, "latest")
val release = firstText(root, "release")
val versions = allVersionTexts(root)
MavenMetadataValues(
latest = latest,
release = release,
versions = versions
)
}.getOrNull()
}
private fun firstText(root: Element, tagName: String): String? {
val nodes = root.getElementsByTagName(tagName)
if (nodes.length == 0) return null
return nodes.item(0)?.textContent?.trim()?.takeIf { it.isNotEmpty() }
}
private fun allVersionTexts(root: Element): List<String> {
val nodes = root.getElementsByTagName("version")
val values = mutableListOf<String>()
for (index in 0 until nodes.length) {
val value = nodes.item(index)?.textContent?.trim()
if (!value.isNullOrEmpty()) {
values += value
}
}
return values
}
private fun tools.jackson.databind.JsonNode.textOrEmpty(): String = scalarText().trim()
private fun tools.jackson.databind.JsonNode.scalarText(): String = when {
isNull || isMissingNode -> ""
else -> toString().removeSurrounding("\"")
}
private fun buildMetadataUrl(baseUrl: String, groupId: String, artifactId: String): String {
val groupPath = groupId.replace('.', '/')
val cleanBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
return "$cleanBaseUrl$groupPath/$artifactId/maven-metadata.xml"
}
}