Files
PHPGuard/backups/ubuntu-version-verification-20261022-153012/VulnerabilityService.swift
T

458 lines
15 KiB
Swift

import Foundation
enum VulnerabilityService {
enum ServiceError: LocalizedError {
case invalidResponse
case httpError(Int)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from the vulnerability database."
case .httpError(let code):
return "Vulnerability database returned HTTP \(code)."
}
}
}
private struct NVDResponse: Decodable {
let vulnerabilities: [Entry]
}
private struct Entry: Decodable {
let cve: CVE
}
private struct CVE: Decodable {
let id: String
let published: String
let descriptions: [Description]
let metrics: Metrics?
}
private struct Description: Decodable {
let lang: String
let value: String
}
private struct Metrics: Decodable {
let cvssMetricV31: [Metric]?
let cvssMetricV30: [Metric]?
let cvssMetricV2: [Metric]?
}
private struct Metric: Decodable {
let cvssData: CVSSData
}
private struct CVSSData: Decodable {
let baseScore: Double?
let baseSeverity: String?
}
static func discover() async throws -> [CVERecord] {
var components = URLComponents(
string: "https://services.nvd.nist.gov/rest/json/cves/2.0"
)!
components.queryItems = [
URLQueryItem(name: "keywordSearch", value: "PHP"),
URLQueryItem(name: "resultsPerPage", value: "100")
]
guard let url = components.url else {
throw ServiceError.invalidResponse
}
var request = URLRequest(url: url)
request.timeoutInterval = 30
request.setValue(
"application/json",
forHTTPHeaderField: "Accept"
)
request.setValue(
"PHPGuard/1.4",
forHTTPHeaderField: "User-Agent"
)
let (data, response) = try await URLSession.shared.data(
for: request
)
guard let http = response as? HTTPURLResponse else {
throw ServiceError.invalidResponse
}
guard (200...299).contains(http.statusCode) else {
throw ServiceError.httpError(http.statusCode)
}
let decoded = try JSONDecoder().decode(
NVDResponse.self,
from: data
)
return decoded.vulnerabilities.map { entry in
let cve = entry.cve
let description = cve.descriptions.first {
$0.lang == "en"
}?.value ?? "No description available."
let metric =
cve.metrics?.cvssMetricV31?.first ??
cve.metrics?.cvssMetricV30?.first ??
cve.metrics?.cvssMetricV2?.first
return CVERecord(
id: cve.id,
description: description,
published: cve.published,
severity: metric?.cvssData.baseSeverity ?? "UNKNOWN",
score: metric?.cvssData.baseScore
)
}
.sorted { $0.published > $1.published }
}
}
extension VulnerabilityService {
// Returns upstream PHP CVE candidates, not confirmed Ubuntu/Debian exposures.
static func assessPHPVersion(_ version: String, runtime: String) async throws -> [CVEServerAssessment] {
let parts = version.split(separator: ".")
guard parts.count >= 3,
parts.prefix(3).allSatisfy({ Int($0) != nil }) else { return [] }
let canonical = parts.prefix(3).joined(separator: ".")
var components = URLComponents(string: "https://services.nvd.nist.gov/rest/json/cves/2.0")!
components.queryItems = [
URLQueryItem(name: "cpeName", value: "cpe:2.3:a:php:php:\(canonical):*:*:*:*:*:*:*"),
URLQueryItem(name: "resultsPerPage", value: "2000")
]
guard let url = components.url else { throw ServiceError.invalidResponse }
var request = URLRequest(url: url)
request.timeoutInterval = 45
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("PHPGuard/1.5", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse }
guard (200...299).contains(http.statusCode) else { throw ServiceError.httpError(http.statusCode) }
let payload = try JSONDecoder().decode(VersionResponse.self, from: data)
// An incomplete NVD response must not be presented as full coverage.
guard payload.totalResults <= payload.vulnerabilities.count else {
throw ServiceError.invalidResponse
}
return payload.vulnerabilities.map { entry in
let cve = entry.cve
let metric = cve.metrics?.cvssMetricV31?.first ?? cve.metrics?.cvssMetricV30?.first ?? cve.metrics?.cvssMetricV2?.first
return CVEServerAssessment(
cveID: cve.id, runtime: runtime,
severity: metric?.cvssData.baseSeverity ?? "UNKNOWN",
score: metric?.cvssData.baseScore,
summary: cve.descriptions.first(where: { $0.lang == "en" })?.value ?? "No English description",
status: "Potentially affected",
evidence: "NVD PHP product CPE matches upstream PHP \(canonical). Distribution package revisions, backported patches, and container image fixes have NOT been verified."
)
}
}
private struct VersionResponse: Decodable {
let totalResults: Int
let vulnerabilities: [VersionEntry]
}
private struct VersionEntry: Decodable { let cve: VersionCVE }
private struct VersionCVE: Decodable {
let id: String
let descriptions: [VersionDescription]
let metrics: VersionMetrics?
}
private struct VersionDescription: Decodable { let lang: String; let value: String }
private struct VersionMetrics: Decodable {
let cvssMetricV31: [VersionMetric]?
let cvssMetricV30: [VersionMetric]?
let cvssMetricV2: [VersionMetric]?
}
private struct VersionMetric: Decodable { let cvssData: VersionCVSS }
private struct VersionCVSS: Decodable { let baseScore: Double?; let baseSeverity: String? }
}
// MARK: - Ubuntu Security Advisory Verification
extension VulnerabilityService {
private struct UbuntuAdvisory: Decodable {
let packages: [UbuntuPackage]?
}
private struct UbuntuPackage: Decodable {
let name: String
let statuses: [UbuntuStatus]?
}
private struct UbuntuStatus: Decodable {
let release_codename: String
let status: String
let description: String?
let pocket: String?
}
private struct InstalledPackage {
let binary: String
let version: String
let source: String
let sourceVersion: String
}
private static func installedPackages(
_ lines: [String]
) -> [InstalledPackage] {
lines.compactMap { line in
let fields = line.split(
separator: "|",
omittingEmptySubsequences: false
).map(String.init)
guard fields.count >= 4 else {
return nil
}
return InstalledPackage(
binary: fields[0],
version: fields[1],
source: fields[2],
sourceVersion: fields[3]
)
}
}
static func assessUbuntu(
_ assessments: [CVEServerAssessment],
report: AuditResult
) async -> [CVEServerAssessment] {
guard report.ubuntuReleaseID == "ubuntu",
!report.ubuntuCodename.isEmpty else {
return assessments.map { original in
var item = original
item.vendorStatus = "Unverified"
item.vendorEvidence =
"Ubuntu release could not be established."
return item
}
}
let installed = installedPackages(report.packageSources)
guard !installed.isEmpty else {
return assessments.map { original in
var item = original
item.vendorStatus = "Unverified"
item.vendorEvidence =
"No installed PHP source packages were collected."
return item
}
}
var output: [CVEServerAssessment] = []
// Cache repeated CVEs across different PHP runtimes.
var cache: [String: UbuntuAdvisory] = [:]
for original in assessments {
var item = original
item.vendorStatus = "Unverified"
// Ubuntu host packages must never be used to certify
// a Docker runtime as patched.
let runtimeVersion = original.runtime
.replacingOccurrences(of: "PHP ", with: "")
let hostHasVersion = report.phpVersions.contains {
$0.contains(runtimeVersion)
}
guard hostHasVersion else {
item.vendorEvidence =
"Runtime not confirmed as a host PHP installation. " +
"Docker image security must be checked separately."
output.append(item)
continue
}
guard original.cveID.range(
of: #"^CVE-[0-9]{4}-[0-9]{4,}$"#,
options: .regularExpression
) != nil else {
item.vendorEvidence = "Invalid CVE identifier."
output.append(item)
continue
}
do {
let advisory: UbuntuAdvisory
if let existing = cache[original.cveID] {
advisory = existing
} else {
let address =
"https://ubuntu.com/security/cves/" +
original.cveID + ".json"
guard let url = URL(string: address) else {
throw ServiceError.invalidResponse
}
var request = URLRequest(url: url)
request.timeoutInterval = 20
request.setValue(
"application/json",
forHTTPHeaderField: "Accept"
)
let (data, response) =
try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
throw ServiceError.invalidResponse
}
advisory = try JSONDecoder().decode(
UbuntuAdvisory.self,
from: data
)
cache[original.cveID] = advisory
}
var matches: [(String, String)] = []
for package in installed {
guard let advisoryPackage =
advisory.packages?.first(where: {
$0.name == package.source
}) else {
continue
}
let statuses = (
advisoryPackage.statuses ?? []
).filter {
$0.release_codename ==
report.ubuntuCodename
}
for status in statuses {
let state = status.status.lowercased()
let description =
status.description ?? ""
let evidence =
"\(package.binary) \(package.version), " +
"source \(package.source) " +
"\(package.sourceVersion), " +
"Ubuntu \(report.ubuntuCodename): " +
"\(status.status)" +
(
description.isEmpty
? ""
: " (\(description))"
)
switch state {
case "not-affected":
matches.append((
"Not affected",
evidence
))
case "released", "fixed":
matches.append((
"Ubuntu fix published",
evidence +
". Installed revision has not " +
"been compared with the fixed revision."
))
case "needed":
matches.append((
"Ubuntu fix needed",
evidence
))
case "dne":
matches.append((
"Package not in Ubuntu release",
evidence +
". Check third-party repositories."
))
default:
matches.append((
"Unverified",
evidence
))
}
}
}
if matches.count == 1 {
item.vendorStatus = matches[0].0
item.vendorEvidence = matches[0].1
} else if matches.isEmpty {
item.vendorStatus = "Unverified"
item.vendorEvidence =
"No matching Ubuntu security advisory " +
"for installed PHP source packages."
} else {
// Different PHP packages can have different
// security states. Avoid a false patched verdict.
item.vendorStatus = "Multiple package states"
item.vendorEvidence = matches
.map { $0.1 }
.joined(separator: "; ")
}
} catch {
item.vendorStatus = "Unverified"
item.vendorEvidence =
"Ubuntu advisory lookup failed: " +
error.localizedDescription
}
output.append(item)
}
return output
}
}