181 lines
6.3 KiB
Swift
181 lines
6.3 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? }
|
|
}
|