InputSafety.kt

package com.depanalyzer.security

import java.io.File
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.URI

object InputSafety {
    const val CREDENTIAL_HOST_ALLOWLIST_ENV = "DEPANALYZER_TRUSTED_CREDENTIAL_HOSTS"

    private const val MAX_VERSION_LENGTH = 128
    private val SAFE_VERSION_REGEX = Regex("^[A-Za-z0-9][A-Za-z0-9._+\\-]{0,127}$")
    private val IPV4_REGEX = Regex("^\\d{1,3}(?:\\.\\d{1,3}){3}$")
    private const val LOOPBACK_V6 = "::1"

    fun isSafeVersion(version: String): Boolean {
        if (version.isBlank() || version.length > MAX_VERSION_LENGTH) return false
        if (version != version.trim()) return false
        return SAFE_VERSION_REGEX.matches(version)
    }

    fun isAllowedRepositoryUrl(url: String): Boolean {
        val uri = parseUri(url) ?: return false
        val scheme = uri.scheme?.lowercase() ?: return false
        if (scheme != "https" && scheme != "http") return false

        val host = normalizedHost(uri) ?: return false
        if (host.isBlank() || isLocalHost(host)) return false

        val explicitPort = uri.port
        if (explicitPort != -1 && explicitPort !in setOf(80, 443)) return false

        val literal = parseIpLiteral(host)
        return !(literal != null && isPrivateOrReserved(literal))
    }

    fun parseTrustedCredentialHosts(raw: String?): Set<String> {
        if (raw.isNullOrBlank()) return emptySet()

        return raw.split(',')
            .map { normalizeTrustedHostEntry(it) }
            .filter { it.isNotBlank() }
            .toSet()
    }

    fun isTrustedCredentialDestination(url: String, trustedHosts: Set<String>): Boolean {
        if (trustedHosts.isEmpty()) return false
        val uri = parseUri(url) ?: return false

        val scheme = uri.scheme?.lowercase() ?: return false
        if (scheme != "https") return false

        val host = normalizedHost(uri) ?: return false
        return hostMatchesTrustedList(host, trustedHosts)
    }

    fun isWithinParentBoundary(rootPom: File, candidateParentPom: File): Boolean {
        if (candidateParentPom.name != "pom.xml") return false

        val projectDir = rootPom.parentFile?.canonicalFile ?: return false
        val boundaryRoot = projectDir.parentFile?.canonicalFile ?: projectDir

        return candidateParentPom.canonicalFile.toPath().startsWith(boundaryRoot.toPath())
    }

    private fun isLocalHost(host: String): Boolean {
        return host == "localhost" || host.endsWith(".localhost") || host == LOOPBACK_V6
    }

    private fun parseUri(url: String): URI? {
        return runCatching { URI(url.trim()) }.getOrNull()
    }

    private fun normalizedHost(uri: URI): String? {
        return uri.host?.trim()?.lowercase()
    }

    private fun normalizeTrustedHostEntry(entry: String): String {
        val trimmed = entry.trim().lowercase()
        if (trimmed.isBlank()) return ""

        if ("://" in trimmed) {
            val host = runCatching { URI(trimmed).host }.getOrNull()?.trim()?.lowercase().orEmpty()
            return host
        }

        return trimmed
    }

    private fun hostMatchesTrustedList(host: String, trustedHosts: Set<String>): Boolean {
        return trustedHosts.any { rule ->
            when {
                rule.startsWith('.') -> {
                    val suffix = rule.removePrefix(".")
                    suffix.isNotBlank() && (host == suffix || host.endsWith(".$suffix"))
                }

                else -> host == rule
            }
        }
    }

    private fun parseIpLiteral(host: String): InetAddress? {
        if (IPV4_REGEX.matches(host)) {
            val bytes = host.split('.').mapNotNull { part -> part.toIntOrNull() }
            if (bytes.size != 4 || bytes.any { it !in 0..255 }) return null
            return InetAddress.getByAddress(
                byteArrayOf(
                    bytes[0].toByte(),
                    bytes[1].toByte(),
                    bytes[2].toByte(),
                    bytes[3].toByte()
                )
            )
        }

        if (':' in host) {
            val normalized = host.removePrefix("[").removeSuffix("]")
            val parsed = runCatching { InetAddress.getByName(normalized) }.getOrNull() ?: return null
            if (parsed is Inet6Address) return parsed
        }

        return null
    }

    private fun isPrivateOrReserved(address: InetAddress): Boolean {
        if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isMulticastAddress) {
            return true
        }

        return when (address) {
            is Inet4Address -> isPrivateOrReservedIpv4(address)
            is Inet6Address -> isPrivateOrReservedIpv6(address)
            else -> true
        }
    }

    private fun isPrivateOrReservedIpv4(address: Inet4Address): Boolean {
        val b = address.address.map { it.toInt() and 0xFF }

        val first = b[0]
        val second = b[1]

        return first == 10 ||
                first == 127 ||
                (first == 169 && second == 254) ||
                (first == 172 && second in 16..31) ||
                (first == 192 && second == 168) ||
                (first == 100 && second in 64..127) ||
                (first == 198 && second in 18..19) ||
                first == 0 ||
                first >= 224
    }

    private fun isPrivateOrReservedIpv6(address: Inet6Address): Boolean {
        if (address.isSiteLocalAddress) return true

        val firstByte = address.address[0].toInt() and 0xFF
        val isUniqueLocal = (firstByte and 0xFE) == 0xFC

        return isUniqueLocal
    }
}