From 2c38b5edee339e5fe9060a835d9772a71606dc85 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 3 Apr 2026 17:14:00 -0500 Subject: Add HTTP header grading and protocol metadata --- DomainDig/HTTPHeadersService.swift | 100 ++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 6 deletions(-) (limited to 'DomainDig/HTTPHeadersService.swift') diff --git a/DomainDig/HTTPHeadersService.swift b/DomainDig/HTTPHeadersService.swift index a297165..04efce6 100644 --- a/DomainDig/HTTPHeadersService.swift +++ b/DomainDig/HTTPHeadersService.swift @@ -1,22 +1,110 @@ import Foundation +enum HTTPSecurityGrade: String { + case a = "A" + case b = "B" + case c = "C" + case d = "D" + case f = "F" + + static func grade(for headers: [HTTPHeader]) -> HTTPSecurityGrade { + let presentHeaderNames = Set(headers.map { $0.name.lowercased() }) + let presentCount = HTTPHeader.securityHeaders.intersection(presentHeaderNames).count + + switch presentCount { + case 5: + return .a + case 4: + return .b + case 3: + return .c + case 2: + return .d + default: + return .f + } + } +} + +struct HTTPHeadersResult { + let headers: [HTTPHeader] + let statusCode: Int? + let responseTimeMs: Int? + let httpProtocol: String? + let http3Advertised: Bool +} + struct HTTPHeadersService { - static func fetch(domain: String) async throws -> [HTTPHeader] { + static func fetch(domain: String) async throws -> HTTPHeadersResult { let url = URL(string: "https://\(domain)")! var request = URLRequest(url: url, timeoutInterval: 10) request.httpMethod = "HEAD" + let metricsDelegate = TaskMetricsDelegate() + let startTime = Date() - let (_, response) = try await URLSession.shared.data(for: request) + let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate) + let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000)) guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } - return httpResponse.allHeaderFields.compactMap { key, value in - guard let name = key as? String, - let val = value as? String else { return nil } - return HTTPHeader(name: name, value: val) + let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in + guard let name = entry.key as? String, + let value = entry.value as? String else { return nil } + return HTTPHeader(name: name, value: value) } .sorted { $0.name.lowercased() < $1.name.lowercased() } + + let networkProtocolName = metricsDelegate.metrics?.transactionMetrics + .compactMap { $0.networkProtocolName } + .last + let detectedProtocol = protocolLabel(for: networkProtocolName) + let altSvcValue = headerValue(named: "alt-svc", in: httpResponse) + let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true + + return HTTPHeadersResult( + headers: headers, + statusCode: httpResponse.statusCode, + responseTimeMs: responseTimeMs, + httpProtocol: detectedProtocol, + http3Advertised: http3Advertised + ) + } + + private static func protocolLabel(for networkProtocolName: String?) -> String? { + guard let networkProtocolName else { return nil } + + let normalized = networkProtocolName.lowercased() + if normalized == "h2" { + return "HTTP/2" + } + if normalized == "h3" || normalized.hasPrefix("quic") { + return "HTTP/3" + } + if normalized.hasPrefix("http/") { + return normalized.uppercased() + } + + return networkProtocolName.uppercased() + } + + private static func headerValue(named name: String, in response: HTTPURLResponse) -> String? { + response.allHeaderFields.first { key, _ in + guard let headerName = key as? String else { return false } + return headerName.caseInsensitiveCompare(name) == .orderedSame + }?.value as? String + } +} + +private final class TaskMetricsDelegate: NSObject, URLSessionTaskDelegate { + private(set) var metrics: URLSessionTaskMetrics? + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) { + self.metrics = metrics } } -- cgit v1.2.3