458 lines
15 KiB
Swift
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
|
|
}
|
|
}
|