From 3c35f49b52eb576a82cb604d5240ed7348aac0d3 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 22 Oct 2026 13:25:59 +0100 Subject: [PATCH] Updates to scanner with CVE saving and auto update --- .../ContentView.swift.backup-20261022-131636 | 330 ---- PHPGuard/ReportPDF.swift | 1379 ++++------------- .../ReportPDF.swift.backup-20261022-132039 | 64 - 3 files changed, 299 insertions(+), 1474 deletions(-) delete mode 100644 PHPGuard/ContentView.swift.backup-20261022-131636 delete mode 100644 PHPGuard/ReportPDF.swift.backup-20261022-132039 diff --git a/PHPGuard/ContentView.swift.backup-20261022-131636 b/PHPGuard/ContentView.swift.backup-20261022-131636 deleted file mode 100644 index a250a62..0000000 --- a/PHPGuard/ContentView.swift.backup-20261022-131636 +++ /dev/null @@ -1,330 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @EnvironmentObject var store: AuditStore - @State private var selection: UUID? - @State private var showingEditor = false - @State private var editing: Server? - @State private var showClearHistoryConfirmation = false - @AppStorage("phpguard.confirmClear") private var confirmClearHistory = true - private func addServer() { editing = nil; showingEditor = true } - var body: some View { - NavigationSplitView { - List(selection: $selection) { - Section("Servers") { - ForEach(store.servers) { server in - Label { - HStack { - Text(server.name) - Spacer() - if store.scanning.contains(server.id) { ProgressView().controlSize(.small) } - else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) } - } - } icon: { Image(systemName: "server.rack") } - .tag(server.id) - .contextMenu { - Button("Scan") { store.scan(server) } - Button("Edit") { editing = server; showingEditor = true } - Divider() - Button("Remove", role: .destructive) { - store.servers.removeAll { $0.id == server.id } - if selection == server.id { selection = nil } - } - } - } - } - Section("Navigation") { - Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) - Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) - } - } - .navigationTitle("PHPGuard") - .safeAreaInset(edge: .bottom) { - Button(action: addServer) { - Label("Add Server", systemImage: "plus.circle.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .padding(12) - .background(.bar) - } - } detail: { - if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") { - cveView - } else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") { - historyView - } else if let server = store.servers.first(where: { $0.id == selection }) { - serverView(server) - } else { dashboard } - } - .sheet(isPresented: $showingEditor) { - ServerEditor(existing: editing) { server in - if let index = store.servers.firstIndex(where: { $0.id == server.id }) { store.servers[index] = server } - else { store.servers.append(server) } - selection = server.id - } - } - .alert("Scan error", isPresented: Binding(get: { store.lastError != nil }, set: { if !$0 { store.lastError = nil } })) { - Button("OK") { store.lastError = nil } - } message: { Text(store.lastError ?? "") } - } - private var dashboard: some View { - VStack(alignment: .leading, spacing: 24) { - Text("Security overview").font(.largeTitle.bold()) - HStack(spacing: 16) { - stat("Servers", "\(store.servers.count)", "server.rack") - stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield") - stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle") - } - Text("Read-only PHP inventory, APT upgrade checks and running Docker container discovery. Vendor advisory matching remains unimplemented; no server is certified secure.") - .foregroundStyle(.secondary) - HStack { - Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") } - .buttonStyle(.borderedProminent) - Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) - Button("Import JSON scan") { store.importReport() } - } - Spacer() - }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - private func stat(_ title: String, _ value: String, _ icon: String) -> some View { - VStack(alignment: .leading, spacing: 12) { - Label(title, systemImage: icon).foregroundStyle(.secondary) - Text(value).font(.system(size: 36, weight: .bold)) - }.padding(20).frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14)) - } - private func serverView(_ server: Server) -> some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - HStack { - VStack(alignment: .leading) { - Text(server.name).font(.largeTitle.bold()) - Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary) - } - Spacer() - Button("Edit") { editing = server; showingEditor = true } - Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent) - .disabled(store.scanning.contains(server.id)) - } - if let result = store.latest(for: server) { - if let error = result.error { - Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red) - } else { - HStack(spacing: 12) { - Label("Scan completed", systemImage: "checkmark.shield.fill") - .foregroundStyle(.green) - Text(result.scannedAt.formatted()).foregroundStyle(.secondary) - Spacer() - Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") } - .buttonStyle(.borderedProminent) - Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") } - } - .padding(16) - .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) - Text("OS: \(result.os) • Host: \(result.hostname)") - dataSection("PHP installations", result.phpVersions) - dataSection("PHP-FPM services", result.fpmServices) - dataSection("Nginx FastCGI routes", result.nginxUpstreams) - dataSection("Installed packages", result.packageVersions) - dataSection("Available PHP package upgrades", result.availableUpdates) - dataSection("APT package origins and candidate revisions", result.packageOrigins) - Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) - dataSection("PHP detected in running Docker containers", result.dockerPHP) - Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary) - dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage) - dataSection("Security triage — follow-up actions", result.securityTriage) - HStack { - Text("Security findings").font(.title2.bold()) - Spacer() - Text("\(result.findings.count) total").foregroundStyle(.secondary) - } - ForEach(result.findings) { finding in - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 10) { - Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill") - Text(finding.severity.uppercased()).font(.caption.bold()) - Text(finding.title).font(.headline) - } - .foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue)) - Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled) - }.padding(12).frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) - } - - } - } else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) } - }.padding(28).frame(maxWidth: .infinity, alignment: .leading) - } - } - private func dataSection(_ title: String, _ values: [String]) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 4, height: 22) - Text(title).font(.headline) - Spacer() - Text("\(values.count)").font(.caption).foregroundStyle(.secondary) - } - if values.isEmpty { Text("None detected").foregroundStyle(.secondary) } - else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } } - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14)) - } - private var cveView: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text("PHP CVE research").font(.largeTitle.bold()) - Spacer() - if store.fetchingCVEs { ProgressView() } - Button("Refresh from NVD") { store.refreshCVEs() }.disabled(store.fetchingCVEs) - } - Text("Research candidates from the NVD keyword index. These records are NOT matched to installed PHP versions, Ubuntu backports or Docker images. Do not interpret them as confirmed server vulnerabilities.") - .foregroundStyle(.secondary) - if let date = store.cveLastChecked { - Text("Last updated: \(date.formatted()) · Saved locally · Automatically refreshed at launch") - .foregroundStyle(.secondary) - } else { - Text("No cached CVE research yet · Automatically fetched at launch") - .foregroundStyle(.secondary) - } - if let error = store.cveError { Text(error).foregroundStyle(.red) } - List(store.discoveredCVEs) { cve in - VStack(alignment: .leading, spacing: 6) { - HStack { - Link(cve.id, destination: URL(string: cve.referenceURL)!) - .font(.headline) - Spacer() - Text(cve.severity + (cve.score.map { " (\($0))" } ?? "")) - Text(String(cve.published.prefix(10))).foregroundStyle(.secondary) - } - Text(cve.description).lineLimit(4).textSelection(.enabled) - }.padding(.vertical, 5) - } - }.padding(24) - } - private var historyView: some View { - VStack(spacing: 0) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Scan history").font(.largeTitle.bold()) - Text("\(store.results.count) saved scans").foregroundStyle(.secondary) - } - Spacer() - Button("Clear History", role: .destructive) { - if confirmClearHistory { showClearHistoryConfirmation = true } - else { store.clearHistory() } - }.disabled(store.results.isEmpty) - }.padding(20) - List { - ForEach(store.results) { result in - HStack(spacing: 12) { - Image(systemName: result.error == nil ? "checkmark.shield.fill" : "xmark.circle.fill") - .foregroundStyle(result.error == nil ? .green : .red) - VStack(alignment: .leading, spacing: 4) { - Text(result.serverName).font(.headline) - Text(result.scannedAt.formatted()).foregroundStyle(.secondary) - if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) } - else { Text("\(result.findings.count) findings · \(result.phpVersions.count) PHP installations") - .font(.caption).foregroundStyle(.secondary) } - } - Spacer() - Button("PDF") { store.exportPDF(result) }.disabled(result.error != nil) - Button("JSON") { store.export(result) }.disabled(result.error != nil) - }.padding(.vertical, 5) - }.onDelete(perform: store.removeHistory) - } - } - .confirmationDialog("Clear all scan history?", isPresented: $showClearHistoryConfirmation) { - Button("Clear History", role: .destructive) { store.clearHistory() } - } message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") } - } - -} - -struct ServerEditor: View { - @Environment(\.dismiss) private var dismiss - let existing: Server? - let onSave: (Server) -> Void - @State private var server: Server - init(existing: Server?, onSave: @escaping (Server) -> Void) { - self.existing = existing - self.onSave = onSave - _server = State(initialValue: existing ?? Server(name: "", host: "")) - } - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack") - .font(.title.bold()) - Text("Connect using an existing SSH key. No agent installation required.") - .foregroundStyle(.secondary) - Form { - Section("Connection") { - TextField("Server name (e.g. Pegasus)", text: $server.name) - TextField("Hostname or IP address", text: $server.host) - .textContentType(.URL) - TextField("SSH username", text: $server.user) - TextField("SSH port", value: $server.port, format: .number) - } - Section("Advanced (optional)") { - HStack { - TextField("SSH private key path", text: $server.identityFile) - Button("Choose…") { - let panel = NSOpenPanel() - panel.canChooseDirectories = false - panel.canChooseFiles = true - if panel.runModal() == .OK, let url = panel.url { - server.identityFile = url.path - } - } - } - Text("Leave blank to use your existing SSH configuration and keys.") - .font(.caption).foregroundStyle(.secondary) - } - }.formStyle(.grouped) - if !isValid { - Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle") - .foregroundStyle(.secondary).font(.caption) - } - Text("Host key verification is strict. Connect in Terminal first if this server is new.") - .font(.caption).foregroundStyle(.secondary) - HStack { - Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) - Spacer() - Button(existing == nil ? "Add Server" : "Save Changes") { - server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines) - server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) - server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) - onSave(server) - dismiss() - } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - .disabled(!isValid) - } - }.padding(24).frame(width: 580) - } - private var isValid: Bool { - let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) - let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) - return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && !host.isEmpty && !host.hasPrefix("-") - && host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil - && user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil - && (1...65535).contains(server.port) - } -} - -struct SettingsView: View { - var body: some View { - Form { - LabeledContent("SSH", value: "System OpenSSH, key-only") - LabeledContent("Host verification", value: "Strict") - LabeledContent("Scan mode", value: "Read-only") - Text("Server inventory and reports are stored locally in Application Support/PHPGuard.") - .foregroundStyle(.secondary) - }.padding(24).frame(width: 500, height: 220) - } -} diff --git a/PHPGuard/ReportPDF.swift b/PHPGuard/ReportPDF.swift index 8f112c0..b063d6a 100644 --- a/PHPGuard/ReportPDF.swift +++ b/PHPGuard/ReportPDF.swift @@ -1,1157 +1,376 @@ - import AppKit import CoreGraphics @MainActor enum ReportPDF { - - static func write( - _ report: AuditResult, - to url: URL, - accent: String - ) throws { - - let renderer = SecurityReportRenderer( - report: report, - accent: accent - ) - - try renderer.generate(to: url) + static func write(_ report: AuditResult, to url: URL, accent: String) throws { + try ProfessionalReport(report: report, accent: accent).write(to: url) } } @MainActor -private final class SecurityReportRenderer { - +private final class ProfessionalReport { private let report: AuditResult - - private let pageWidth: CGFloat = 595 - private let pageHeight: CGFloat = 842 - private let margin: CGFloat = 48 - private let contentWidth: CGFloat = 499 - - private let navy = NSColor( - calibratedRed: 0.055, - green: 0.090, - blue: 0.155, - alpha: 1 - ) - - private let ink = NSColor( - calibratedRed: 0.12, - green: 0.17, - blue: 0.25, - alpha: 1 - ) - - private let muted = NSColor( - calibratedRed: 0.40, - green: 0.46, - blue: 0.54, - alpha: 1 - ) - - private let pale = NSColor( - calibratedRed: 0.955, - green: 0.965, - blue: 0.980, - alpha: 1 - ) - - private let accentColor: NSColor - + private let w: CGFloat = 595 + private let h: CGFloat = 842 + private let left: CGFloat = 46 + private let right: CGFloat = 549 + private let bottom: CGFloat = 784 + private let navy = NSColor(calibratedRed: 0.065, green: 0.105, blue: 0.175, alpha: 1) + private let ink = NSColor(calibratedRed: 0.13, green: 0.18, blue: 0.26, alpha: 1) + private let grey = NSColor(calibratedRed: 0.40, green: 0.45, blue: 0.52, alpha: 1) + private let light = NSColor(calibratedRed: 0.955, green: 0.966, blue: 0.98, alpha: 1) + private let accent: NSColor private var context: CGContext! private var y: CGFloat = 0 - private var pageNumber = 0 - private var pageOpen = false + private var page = 0 + private var isOpen = false init(report: AuditResult, accent: String) { self.report = report - switch accent { - case "Teal": - accentColor = NSColor( - calibratedRed: 0.00, - green: 0.66, - blue: 0.62, - alpha: 1 - ) - case "Purple": - accentColor = NSColor( - calibratedRed: 0.47, - green: 0.37, - blue: 0.84, - alpha: 1 - ) - default: - accentColor = NSColor( - calibratedRed: 0.20, - green: 0.43, - blue: 0.86, - alpha: 1 - ) + case "Teal": self.accent = NSColor(calibratedRed: 0.0, green: 0.58, blue: 0.56, alpha: 1) + case "Purple": self.accent = NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.77, alpha: 1) + default: self.accent = NSColor(calibratedRed: 0.16, green: 0.40, blue: 0.80, alpha: 1) } } - private func font( - _ size: CGFloat, - weight: NSFont.Weight = .regular - ) -> NSFont { - NSFont.systemFont(ofSize: size, weight: weight) + private var genuineFindings: [Finding] { + report.findings.filter { finding in + let title = finding.title.lowercased() + let detail = finding.detail.lowercased() + // A failed attempt to execute PHP is not evidence that PHP is installed. + return !(title.contains("container php runtime detected") && + (detail.contains("executable file not found") || + detail.contains("oci runtime exec failed") || + detail.contains("php: not found"))) + } } - private func attributes( - size: CGFloat, - weight: NSFont.Weight = .regular, - color: NSColor = .black - ) -> [NSAttributedString.Key: Any] { - - let paragraph = NSMutableParagraphStyle() - paragraph.lineSpacing = 3 - paragraph.lineBreakMode = .byCharWrapping - - return [ - .font: font(size, weight: weight), - .foregroundColor: color, - .paragraphStyle: paragraph - ] + private var containerResults: [(name: String, state: String, detail: String)] { + report.dockerPHP.map { entry in + let lower = entry.lowercased() + let state: String + if lower.contains("executable file not found") || lower.contains("oci runtime exec failed") || lower.contains("php: not found") { + state = "PHP CLI not detected" + } else if lower.contains("php ") || lower.contains("php/") { + state = "PHP reported" + } else { + state = "Unverified" + } + let name = entry.components(separatedBy: " [").first ?? entry + return (name, state, entry) + } } - private func textHeight( - _ text: String, - width: CGFloat, - size: CGFloat, - weight: NSFont.Weight = .regular - ) -> CGFloat { - - let value = NSAttributedString( - string: text, - attributes: attributes( - size: size, - weight: weight - ) - ) - - let bounds = value.boundingRect( - with: CGSize( - width: width, - height: 100000 - ), - options: [ - .usesLineFragmentOrigin, - .usesFontLeading - ] - ) - - return max(size + 5, ceil(bounds.height) + 4) - } - - private func drawText( - _ text: String, - x: CGFloat, - y position: CGFloat, - width: CGFloat, - size: CGFloat = 10, - weight: NSFont.Weight = .regular, - color: NSColor = .black - ) -> CGFloat { - - let value = NSAttributedString( - string: text, - attributes: attributes( - size: size, - weight: weight, - color: color - ) - ) - - let height = textHeight( - text, - width: width, - size: size, - weight: weight - ) - - value.draw( - with: CGRect( - x: x, - y: position, - width: width, - height: height - ), - options: [ - .usesLineFragmentOrigin, - .usesFontLeading - ] - ) - - return height - } - - private func rectangle( - x: CGFloat, - y: CGFloat, - width: CGFloat, - height: CGFloat, - color: NSColor, - radius: CGFloat = 0 - ) { - - let rect = CGRect( - x: x, - y: y, - width: width, - height: height - ) - - let path = NSBezierPath( - roundedRect: rect, - xRadius: radius, - yRadius: radius - ) - - color.setFill() - path.fill() - } - - private func rule( - at position: CGFloat, - color: NSColor? = nil - ) { - - rectangle( - x: margin, - y: position, - width: contentWidth, - height: 1, - color: color ?? NSColor( - calibratedWhite: 0.88, - alpha: 1 - ) - ) - } - - private func withGraphics( - _ operation: () -> Void - ) { - + private func withCanvas(_ draw: () -> Void) { context.saveGState() NSGraphicsContext.saveGraphicsState() - - context.translateBy(x: 0, y: pageHeight) + context.translateBy(x: 0, y: h) context.scaleBy(x: 1, y: -1) - - NSGraphicsContext.current = NSGraphicsContext( - cgContext: context, - flipped: true - ) - - operation() - + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true) + draw() NSGraphicsContext.restoreGraphicsState() context.restoreGState() } - private func beginPage( - title: String = "SECURITY ASSESSMENT" - ) { - - context.beginPDFPage(nil) - pageOpen = true - pageNumber += 1 - y = 105 - - withGraphics { - - rectangle( - x: 0, - y: 0, - width: pageWidth, - height: 9, - color: accentColor - ) - - _ = drawText( - "PHPGUARD", - x: margin, - y: 29, - width: 200, - size: 15, - weight: .bold, - color: navy - ) - - _ = drawText( - title.uppercased(), - x: 290, - y: 33, - width: 257, - size: 8, - weight: .semibold, - color: muted - ) - - rule(at: 70) - } + private func fill(_ rect: CGRect, _ color: NSColor, radius: CGFloat = 0) { + color.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() } - private func finishPage() { - - guard pageOpen else { return } - - withGraphics { - - rule(at: 790) - - _ = drawText( - "PHPGuard / \(report.serverName)", - x: margin, - y: 802, - width: 280, - size: 8, - color: muted - ) - - _ = drawText( - "ASSESSMENT • \(pageNumber)", - x: 390, - y: 802, - width: 157, - size: 8, - weight: .medium, - color: muted - ) - } - - context.endPDFPage() - pageOpen = false + private func attributed(_ string: String, size: CGFloat, bold: Bool = false, color: NSColor? = nil, breakMode: NSLineBreakMode = .byCharWrapping) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 2.5 + paragraph.lineBreakMode = breakMode + return NSAttributedString(string: string, attributes: [ + .font: NSFont.systemFont(ofSize: size, weight: bold ? .semibold : .regular), + .foregroundColor: color ?? ink, + .paragraphStyle: paragraph + ]) } - private func ensureSpace( - _ required: CGFloat, - title: String = "SECURITY ASSESSMENT" - ) { - - if y + required > 775 { - finishPage() - beginPage(title: title) - } - } - - private func heading( - _ title: String, - subtitle: String? = nil - ) { - - let subtitleHeight = subtitle.map { - textHeight($0, width: contentWidth, size: 9) - } ?? 0 - - ensureSpace(65 + subtitleHeight) - - withGraphics { - - rectangle( - x: margin, - y: y + 3, - width: 4, - height: 24, - color: accentColor, - radius: 2 - ) - - _ = drawText( - title, - x: margin + 16, - y: y, - width: contentWidth - 16, - size: 18, - weight: .bold, - color: navy - ) - - if let subtitle { - _ = drawText( - subtitle, - x: margin + 16, - y: y + 30, - width: contentWidth - 16, - size: 9, - color: muted - ) - } - } - - y += 45 + subtitleHeight - } - - private func sectionTitle(_ title: String) { - - ensureSpace(42) - - withGraphics { - _ = drawText( - title.uppercased(), - x: margin, - y: y, - width: contentWidth, - size: 10, - weight: .bold, - color: accentColor - ) - } - - y += 25 - } - - private func paragraph( - _ text: String, - size: CGFloat = 10, - color: NSColor? = nil - ) { - - let width = contentWidth - - let height = textHeight( - text, - width: width, - size: size + private func height(_ string: String, width: CGFloat, size: CGFloat, bold: Bool = false) -> CGFloat { + let box = attributed(string, size: size, bold: bold).boundingRect( + with: CGSize(width: width, height: 20000), + options: [.usesLineFragmentOrigin, .usesFontLeading] ) - - ensureSpace(height + 10) - - withGraphics { - _ = drawText( - text, - x: margin, - y: y, - width: width, - size: size, - color: color ?? ink - ) - } - - y += height + 10 + return max(size + 4, ceil(box.height) + 3) } - private func detailRow( - label: String, - value: String - ) { + @discardableResult + private func text(_ string: String, x: CGFloat, y: CGFloat, width: CGFloat, size: CGFloat = 10, bold: Bool = false, color: NSColor? = nil) -> CGFloat { + let lineHeight = height(string, width: width, size: size, bold: bold) + attributed(string, size: size, bold: bold, color: color).draw( + with: CGRect(x: x, y: y, width: width, height: lineHeight + 1), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ) + return lineHeight + } + private func begin(_ chapter: String) { + context.beginPDFPage(nil) + isOpen = true + page += 1 + y = 106 + withCanvas { + fill(CGRect(x: 0, y: 0, width: w, height: 7), accent) + text("PHPGUARD", x: left, y: 28, width: 190, size: 15, bold: true, color: navy) + text(chapter.uppercased(), x: 292, y: 33, width: 257, size: 8, bold: true, color: grey) + fill(CGRect(x: left, y: 72, width: right-left, height: 1), NSColor(calibratedWhite: 0.88, alpha: 1)) + } + } + + private func end() { + guard isOpen else { return } + withCanvas { + fill(CGRect(x: left, y: 790, width: right-left, height: 1), NSColor(calibratedWhite: 0.88, alpha: 1)) + text("PHPGuard | \(report.serverName) | Vendor verification required", x: left, y: 803, width: 395, size: 7.5, color: grey) + text("PAGE \(page)", x: 474, y: 803, width: 75, size: 8, bold: true, color: grey) + } + context.endPDFPage() + isOpen = false + } + + private func space(_ required: CGFloat, chapter: String) { + if y + required > bottom { + end() + begin(chapter) + } + } + + private func title(_ string: String, subtitle: String, chapter: String) { + space(85, chapter: chapter) + withCanvas { + fill(CGRect(x: left, y: y+3, width: 4, height: 26), accent, radius: 2) + text(string, x: left+15, y: y, width: right-left-15, size: 20, bold: true, color: navy) + text(subtitle, x: left+15, y: y+34, width: right-left-15, size: 9, color: grey) + } + y += 77 + } + + private func section(_ name: String, chapter: String) { + space(36, chapter: chapter) + withCanvas { text(name.uppercased(), x: left, y: y, width: right-left, size: 10, bold: true, color: accent) } + y += 26 + } + + private func paragraph(_ string: String, chapter: String, size: CGFloat = 9.5) { + let needed = height(string, width: right-left, size: size) + 11 + space(needed, chapter: chapter) + withCanvas { text(string, x: left, y: y, width: right-left, size: size) } + y += needed + } + + private func tableRow(_ label: String, _ value: String, chapter: String, alternate: Bool = false) { let labelWidth: CGFloat = 145 - let valueWidth = contentWidth - labelWidth - 20 - - let height = max( - textHeight( - label, - width: labelWidth, - size: 9 - ), - textHeight( - value, - width: valueWidth, - size: 9 - ) - ) + 18 - - ensureSpace(height) - - withGraphics { - - rectangle( - x: margin, - y: y, - width: contentWidth, - height: height - 2, - color: pale, - radius: 5 - ) - - _ = drawText( - label, - x: margin + 12, - y: y + 9, - width: labelWidth, - size: 9, - weight: .semibold, - color: muted - ) - - _ = drawText( - value, - x: margin + labelWidth + 14, - y: y + 9, - width: valueWidth - 14, - size: 9, - color: ink - ) + let valueWidth: CGFloat = right-left-labelWidth-32 + let rowHeight = max(height(label, width: labelWidth, size: 9, bold: true), height(value, width: valueWidth, size: 9)) + 19 + space(rowHeight+4, chapter: chapter) + withCanvas { + fill(CGRect(x: left, y: y, width: right-left, height: rowHeight), alternate ? light : NSColor.white, radius: 4) + text(label, x: left+12, y: y+9, width: labelWidth, size: 9, bold: true, color: grey) + text(value, x: left+labelWidth+17, y: y+9, width: valueWidth, size: 9) } - - y += height + 3 + y += rowHeight+4 } - private func evidence( - _ items: [String], - empty: String = "No entries recorded" - ) { - - if items.isEmpty { - paragraph(empty, size: 9, color: muted) + private func items(_ values: [String], chapter: String, empty: String = "No entries recorded") { + if values.isEmpty { + paragraph(empty, chapter: chapter) return } - - for (index, item) in items.enumerated() { - - let width = contentWidth - 32 - - let height = textHeight( - item, - width: width, - size: 9 - ) + 14 - - ensureSpace(height) - - withGraphics { - - if index.isMultiple(of: 2) { - rectangle( - x: margin, - y: y, - width: contentWidth, - height: height, - color: pale, - radius: 4 - ) - } - - rectangle( - x: margin + 9, - y: y + 12, - width: 4, - height: 4, - color: accentColor, - radius: 2 - ) - - _ = drawText( - item, - x: margin + 22, - y: y + 7, - width: width, - size: 9, - color: ink - ) + for (index, value) in values.enumerated() { + let needed = height(value, width: right-left-32, size: 8.7) + 18 + space(needed+3, chapter: chapter) + withCanvas { + if index.isMultiple(of: 2) { fill(CGRect(x: left, y: y, width: right-left, height: needed), light, radius: 4) } + fill(CGRect(x: left+10, y: y+12, width: 4, height: 4), accent, radius: 2) + text(value, x: left+24, y: y+8, width: right-left-35, size: 8.7) } - - y += height + 3 + y += needed+3 } - - y += 10 + y += 8 } - private func severityColor( - _ severity: String - ) -> NSColor { - - switch severity.lowercased() { - - case "critical": - return .systemRed - - case "high": - return NSColor( - calibratedRed: 0.83, - green: 0.25, - blue: 0.23, - alpha: 1 - ) - - case "medium", "warning": - return .systemOrange - - case "low": - return .systemYellow - - default: - return accentColor + private func severityColor(_ value: String) -> NSColor { + switch value.lowercased() { + case "critical", "high": return .systemRed + case "medium", "warning": return .systemOrange + case "low": return NSColor(calibratedRed: 0.57, green: 0.43, blue: 0.07, alpha: 1) + default: return accent } } - private func findingCard(_ finding: Finding) { - - let detailHeight = textHeight( - finding.detail, - width: contentWidth - 32, - size: 9 - ) - - let titleHeight = textHeight( - finding.title, - width: contentWidth - 140, - size: 11, - weight: .semibold - ) - - let cardHeight = 65 + titleHeight + detailHeight - - ensureSpace(cardHeight + 12, title: "SECURITY FINDINGS") - - withGraphics { - - rectangle( - x: margin, - y: y, - width: contentWidth, - height: cardHeight, - color: pale, - radius: 9 - ) - - rectangle( - x: margin, - y: y, - width: 5, - height: cardHeight, - color: severityColor(finding.severity), - radius: 2 - ) - - _ = drawText( - finding.title, - x: margin + 18, - y: y + 15, - width: contentWidth - 145, - size: 11, - weight: .semibold, - color: navy - ) - - _ = drawText( - finding.severity.uppercased(), - x: margin + contentWidth - 112, - y: y + 17, - width: 95, - size: 9, - weight: .bold, - color: severityColor(finding.severity) - ) - - _ = drawText( - finding.detail, - x: margin + 18, - y: y + 29 + titleHeight, - width: contentWidth - 36, - size: 9, - color: ink - ) + private func finding(_ value: Finding) { + let titleH = height(value.title, width: 350, size: 11, bold: true) + let detailH = height(value.detail, width: right-left-36, size: 9) + let cardH = titleH+detailH+53 + space(cardH+12, chapter: "Security findings") + withCanvas { + fill(CGRect(x: left, y: y, width: right-left, height: cardH), light, radius: 8) + fill(CGRect(x: left, y: y, width: 5, height: cardH), severityColor(value.severity), radius: 2) + text(value.title, x: left+18, y: y+15, width: 350, size: 11, bold: true, color: navy) + text(value.severity.uppercased(), x: right-106, y: y+17, width: 92, size: 9, bold: true, color: severityColor(value.severity)) + text(value.detail, x: left+18, y: y+25+titleH, width: right-left-36, size: 9) } - - y += cardHeight + 12 - } - - private func metric( - _ value: String, - label: String, - x: CGFloat, - width: CGFloat - ) { - - rectangle( - x: x, - y: 445, - width: width, - height: 104, - color: NSColor( - calibratedRed: 0.10, - green: 0.15, - blue: 0.23, - alpha: 1 - ), - radius: 10 - ) - - _ = drawText( - value, - x: x + 16, - y: 463, - width: width - 32, - size: 29, - weight: .bold, - color: .white - ) - - _ = drawText( - label.uppercased(), - x: x + 16, - y: 514, - width: width - 32, - size: 8, - weight: .medium, - color: NSColor( - calibratedWhite: 0.72, - alpha: 1 - ) - ) + y += cardH+12 } private func cover() { - context.beginPDFPage(nil) - pageOpen = true - pageNumber += 1 - - withGraphics { - - rectangle( - x: 0, - y: 0, - width: pageWidth, - height: pageHeight, - color: navy - ) - - rectangle( - x: 0, - y: 0, - width: pageWidth, - height: 12, - color: accentColor - ) - - _ = drawText( - "PHPGUARD", - x: margin, - y: 57, - width: 350, - size: 23, - weight: .heavy, - color: .white - ) - - _ = drawText( - "SERVER SECURITY INTELLIGENCE", - x: margin, - y: 94, - width: 450, - size: 9, - weight: .medium, - color: accentColor - ) - - rectangle( - x: margin, - y: 160, - width: 52, - height: 4, - color: accentColor, - radius: 2 - ) - - _ = drawText( - "SECURITY\nASSESSMENT", - x: margin, - y: 188, - width: contentWidth, - size: 36, - weight: .bold, - color: .white - ) - - _ = drawText( - report.serverName, - x: margin, - y: 310, - width: contentWidth, - size: 24, - weight: .semibold, - color: .white - ) - - _ = drawText( - report.hostname, - x: margin, - y: 350, - width: contentWidth, - size: 12, - color: NSColor( - calibratedWhite: 0.70, - alpha: 1 - ) - ) - - rectangle( - x: margin, - y: 392, - width: contentWidth, - height: 35, - color: NSColor( - calibratedRed: 0.20, - green: 0.24, - blue: 0.32, - alpha: 1 - ), - radius: 6 - ) - - _ = drawText( - "ASSESSMENT STATUS: VERIFICATION REQUIRED", - x: margin + 13, - y: 403, - width: contentWidth - 26, - size: 10, - weight: .bold, - color: .white - ) - - let gap: CGFloat = 10 - let metricWidth = (contentWidth - 2 * gap) / 3 - - metric( - "\(report.phpVersions.count)", - label: "PHP runtimes", - x: margin, - width: metricWidth - ) - - metric( - "\(report.findings.count)", - label: "Findings", - x: margin + metricWidth + gap, - width: metricWidth - ) - - metric( - "\(report.availableUpdates.count)", - label: "Updates recorded", - x: margin + (metricWidth + gap) * 2, - width: metricWidth - ) - - _ = drawText( - "ASSESSMENT DETAILS", - x: margin, - y: 598, - width: contentWidth, - size: 10, - weight: .bold, - color: accentColor - ) - - _ = drawText( - "Operating system\n\(report.os)", - x: margin, - y: 626, - width: contentWidth, - size: 11, - color: .white - ) - - _ = drawText( - "Assessment date\n\(report.scannedAt.formatted(date: .abbreviated, time: .standard))", - x: margin, - y: 686, - width: contentWidth, - size: 11, - color: .white - ) - - _ = drawText( - "CVE exposure is not verified by version inventory alone.", - x: margin, - y: 775, - width: contentWidth, - size: 9, - color: NSColor( - calibratedWhite: 0.72, - alpha: 1 - ) - ) - } - - context.endPDFPage() - pageOpen = false - } - - private func executiveSummary() { - - beginPage(title: "EXECUTIVE SUMMARY") - - heading( - "Executive Summary", - subtitle: "Assessment overview and key observations" - ) - - paragraph( - "PHPGuard collected PHP runtime, package, service and security investigation evidence from \(report.serverName). The results represent the checks completed during this scan, not a guarantee that the server is secure." - ) - - sectionTitle("Assessment Overview") - - detailRow( - label: "Server", - value: report.serverName - ) - - detailRow( - label: "Hostname", - value: report.hostname - ) - - detailRow( - label: "Operating system", - value: report.os - ) - - detailRow( - label: "PHP runtimes", - value: "\(report.phpVersions.count) detected" - ) - - detailRow( - label: "Security findings", - value: "\(report.findings.count) recorded" - ) - - detailRow( - label: "Package updates", - value: "\(report.availableUpdates.count) recorded" - ) - - sectionTitle("Security Assessment") - - paragraph( - "Verification required. PHP package versions and available updates do not independently establish CVE exposure. Vendor security advisories, package origins and backported fixes must be checked before determining vulnerability status." - ) - - sectionTitle("Investigation Coverage") - - detailRow( - label: "Package assessment", - value: report.packageAuditStatus - ) - - detailRow( - label: "Docker assessment", - value: report.dockerAuditStatus - ) - - detailRow( - label: "Investigation", - value: report.investigationStatus - ) - - finishPage() - } - - private func findingsSection() { - - beginPage(title: "SECURITY FINDINGS") - - heading( - "Security Findings", - subtitle: "\(report.findings.count) observations recorded during the assessment" - ) - - if report.findings.isEmpty { - - paragraph( - "No findings were recorded by the completed checks. This does not establish that the server is free from vulnerabilities." - ) - - } else { - - for finding in report.findings { - findingCard(finding) + page += 1 + withCanvas { + fill(CGRect(x: 0, y: 0, width: w, height: h), navy) + fill(CGRect(x: 0, y: 0, width: w, height: 10), accent) + text("PHPGUARD", x: left, y: 60, width: 430, size: 25, bold: true, color: .white) + text("INFRASTRUCTURE SECURITY ASSESSMENT", x: left, y: 103, width: 470, size: 10, bold: true, color: accent) + fill(CGRect(x: left, y: 180, width: 65, height: 4), accent, radius: 2) + text("PHP SECURITY\nASSESSMENT", x: left, y: 209, width: 490, size: 38, bold: true, color: .white) + text(report.serverName, x: left, y: 338, width: 490, size: 25, bold: true, color: .white) + text(report.hostname, x: left, y: 379, width: 490, size: 12, color: NSColor(calibratedWhite: 0.75, alpha: 1)) + fill(CGRect(x: left, y: 430, width: right-left, height: 46), NSColor(calibratedRed: 0.16, green: 0.22, blue: 0.31, alpha: 1), radius: 7) + text("STATUS / VERIFICATION REQUIRED", x: left+17, y: 445, width: 460, size: 12, bold: true, color: .white) + let labels = [("\(report.phpVersions.count)", "HOST PHP RUNTIMES"), ("\(genuineFindings.count)", "REVIEW ITEMS"), ("\(report.availableUpdates.count)", "UPDATES RECORDED")] + let boxWidth: CGFloat = 160 + for (index, item) in labels.enumerated() { + let x = left + CGFloat(index)*171 + fill(CGRect(x: x, y: 505, width: boxWidth, height: 110), NSColor(calibratedRed: 0.13, green: 0.19, blue: 0.27, alpha: 1), radius: 8) + text(item.0, x: x+15, y: 526, width: 125, size: 29, bold: true, color: .white) + text(item.1, x: x+15, y: 580, width: 135, size: 8, bold: true, color: NSColor(calibratedWhite: 0.77, alpha: 1)) } + text("OPERATING SYSTEM", x: left, y: 665, width: 230, size: 8, bold: true, color: accent) + text(report.os.isEmpty ? "Not reported" : report.os, x: left, y: 682, width: 490, size: 11, color: .white) + text("SCAN DATE", x: left, y: 722, width: 230, size: 8, bold: true, color: accent) + text(report.scannedAt.formatted(date: .abbreviated, time: .standard), x: left, y: 739, width: 490, size: 11, color: .white) + text("Evidence-based assessment. CVE exposure requires vendor advisory verification.", x: left, y: 797, width: 490, size: 8, color: NSColor(calibratedWhite: 0.75, alpha: 1)) } - - sectionTitle("Security Triage") - - evidence( - report.securityTriage, - empty: "No additional triage guidance recorded." - ) - - finishPage() + context.endPDFPage() } - private func environmentSection() { - - beginPage(title: "PHP ENVIRONMENT") - - heading( - "PHP Environment", - subtitle: "Runtime, service and web-server configuration" - ) - - sectionTitle("Detected PHP Installations") - evidence(report.phpVersions) - - sectionTitle("PHP-FPM Services") - evidence(report.fpmServices) - - sectionTitle("Nginx FastCGI Configuration") - evidence(report.nginxUpstreams) - - sectionTitle("Available Package Updates") - evidence( - report.availableUpdates, - empty: "No available package updates were recorded." - ) - - finishPage() + private func summary() { + begin("Executive summary") + title("Executive Summary", subtitle: "Scope, key results and verification status", chapter: "Executive summary") + paragraph("PHPGuard collected runtime, package, service and investigation evidence from \(report.serverName). These observations describe the checks completed; they are not a certification that the server is secure.", chapter: "Executive summary") + section("Asset and assessment", chapter: "Executive summary") + for (index, row) in [ + ("Server", report.serverName), ("Hostname", report.hostname), ("Operating system", report.os), + ("Scan date", report.scannedAt.formatted(date: .abbreviated, time: .standard)), + ("Host PHP runtimes", "\(report.phpVersions.count)"), + ("Review items", "\(genuineFindings.count)"), + ("Updates recorded", "\(report.availableUpdates.count)") + ].enumerated() { tableRow(row.0, row.1.isEmpty ? "Not reported" : row.1, chapter: "Executive summary", alternate: index.isMultiple(of: 2)) } + section("Assessment position", chapter: "Executive summary") + paragraph("Verification required. Package version strings and available updates do not prove or disprove CVE exposure. Compare installed revisions and container images with applicable vendor advisories, including distribution backports.", chapter: "Executive summary") + section("Coverage limitations", chapter: "Executive summary") + tableRow("Package review", report.packageAuditStatus, chapter: "Executive summary", alternate: true) + tableRow("Docker review", report.dockerAuditStatus, chapter: "Executive summary") + tableRow("Investigation", report.investigationStatus, chapter: "Executive summary", alternate: true) + end() } - private func coverageSection() { - - beginPage(title: "SECURITY COVERAGE") - - heading( - "Security Coverage", - subtitle: "Assessment limitations and recommended follow-up" - ) - - sectionTitle("Coverage Status") - - detailRow( - label: "Package inventory", - value: report.packageAuditStatus - ) - - detailRow( - label: "Container coverage", - value: report.dockerAuditStatus - ) - - detailRow( - label: "Investigation", - value: report.investigationStatus - ) - - sectionTitle("Recommended Actions") - - evidence([ - "Verify installed PHP package revisions against applicable Ubuntu Security Notices or third-party vendor advisories.", - "Review each recorded security finding and determine whether the observed condition requires remediation.", - "Investigate unavailable or incomplete checks before treating the assessment as comprehensive.", - "Review PHP-FPM services and confirm that configured Nginx upstreams match the intended active runtimes.", - "Review recently modified PHP files against expected application deployments and maintenance activity.", - "If PHP containers are present, assess their runtime versions and image updates independently of host packages." - ]) - - finishPage() + private func findingsPage() { + begin("Security findings") + title("Findings & Observations", subtitle: "Prioritised recorded results; no inferred vulnerabilities", chapter: "Security findings") + if genuineFindings.isEmpty { paragraph("No actionable findings were recorded by the completed checks. This does not establish an absence of vulnerabilities.", chapter: "Security findings") } + for item in genuineFindings { finding(item) } + section("Triage guidance", chapter: "Security findings") + items(report.securityTriage, chapter: "Security findings", empty: "No triage guidance recorded") + end() } - private func appendixSection( - title: String, - subtitle: String, - items: [String], - empty: String = "No entries recorded" - ) { + private func environment() { + begin("PHP environment") + title("PHP Environment", subtitle: "Host runtimes, PHP-FPM and web-server integration", chapter: "PHP environment") + section("Host PHP installations", chapter: "PHP environment") + items(report.phpVersions, chapter: "PHP environment") + section("PHP-FPM service observations", chapter: "PHP environment") + items(report.fpmServices, chapter: "PHP environment") + section("Nginx FastCGI upstreams", chapter: "PHP environment") + items(report.nginxUpstreams, chapter: "PHP environment") + section("Available package updates", chapter: "PHP environment") + items(report.availableUpdates, chapter: "PHP environment", empty: "No package updates recorded by this scan (not proof that every package is current).") + end() + } - heading(title, subtitle: subtitle) - evidence(items, empty: empty) + private func containers() { + begin("Container assessment") + title("Container Assessment", subtitle: "PHP detection is separated from container execution errors", chapter: "Container assessment") + paragraph("An OCI execution failure or missing PHP executable is not evidence of a PHP installation or a vulnerability. Container image security must be evaluated independently of host APT updates.", chapter: "Container assessment") + let detected = containerResults.filter { $0.state == "PHP reported" }.count + let notDetected = containerResults.filter { $0.state == "PHP CLI not detected" }.count + tableRow("PHP reported", "\(detected) container(s)", chapter: "Container assessment", alternate: true) + tableRow("PHP CLI not detected", "\(notDetected) container(s)", chapter: "Container assessment") + tableRow("Other / unverified", "\(containerResults.count-detected-notDetected) container(s)", chapter: "Container assessment", alternate: true) + section("Container inventory", chapter: "Container assessment") + if containerResults.isEmpty { paragraph("No container runtime entries recorded.", chapter: "Container assessment") } + for item in containerResults { tableRow(item.name, item.state, chapter: "Container assessment") } + section("Recorded container evidence", chapter: "Container assessment") + items(report.dockerPHP, chapter: "Container assessment") + section("Coverage notes", chapter: "Container assessment") + items(report.dockerCoverage, chapter: "Container assessment", empty: report.dockerAuditStatus) + end() + } + + private func actions() { + begin("Remediation and coverage") + title("Recommended Actions", subtitle: "Follow-up checks based on recorded assessment limitations", chapter: "Remediation and coverage") + let recommendations = [ + ("01 Verify advisories", "Match installed PHP package revisions to the applicable Ubuntu or third-party maintainer advisories. Check backported fixes before assigning CVEs."), + ("02 Review PHP containers", "Verify detected PHP versions against their image maintainers' fixed releases. Update and redeploy images separately from host packages."), + ("03 Resolve coverage gaps", "Review incomplete or inaccessible checks, including Nginx error logs, before describing the assessment as comprehensive."), + ("04 Confirm runtime routing", "Check that configured Nginx FastCGI upstreams point to the intended running PHP-FPM services."), + ("05 Validate change activity", "Compare recently modified PHP files with deployment logs, expected maintenance and application release activity.") + ] + for row in recommendations { + section(row.0, chapter: "Remediation and coverage") + paragraph(row.1, chapter: "Remediation and coverage") + } + section("Recorded investigation status", chapter: "Remediation and coverage") + tableRow("Package assessment", report.packageAuditStatus, chapter: "Remediation and coverage", alternate: true) + tableRow("Docker assessment", report.dockerAuditStatus, chapter: "Remediation and coverage") + tableRow("Investigation", report.investigationStatus, chapter: "Remediation and coverage", alternate: true) + end() } private func appendix() { - - beginPage(title: "TECHNICAL APPENDIX") - - heading( - "Technical Appendix", - subtitle: "Supporting evidence collected during the scan" - ) - - paragraph( - "The following entries preserve the collected scan evidence. Package inventories, paths and service output may require further interpretation." - ) - - appendixSection( - title: "Installed Packages", - subtitle: "Detected PHP-related package inventory", - items: report.packageVersions - ) - - appendixSection( - title: "APT Package Origins", - subtitle: "Installed versions, candidates and repository evidence", - items: report.packageOrigins - ) - - appendixSection( - title: "Docker PHP", - subtitle: "Container runtime inventory", - items: report.dockerPHP - ) - - appendixSection( - title: "Docker Coverage", - subtitle: "Container investigation evidence", - items: report.dockerCoverage - ) - - appendixSection( - title: "Recently Modified PHP Files", - subtitle: "\(report.recentPHPFiles.count) file entries recorded", - items: report.recentPHPFiles - ) - - appendixSection( - title: "Web Error Signals", - subtitle: "Recorded application and web-server observations", - items: report.webErrorSignals - ) - - finishPage() + begin("Technical evidence") + title("Technical Evidence", subtitle: "Collected source observations and package details", chapter: "Technical evidence") + let groups: [(String, [String], String)] = [ + ("Installed packages", report.packageVersions, "No packages recorded"), + ("APT package origins", report.packageOrigins, "No package origins recorded"), + ("Recent PHP file changes", report.recentPHPFiles, "No recent files recorded"), + ("Web error signals", report.webErrorSignals, "No web error signals recorded") + ] + for group in groups { + section(group.0, chapter: "Technical evidence") + items(group.1, chapter: "Technical evidence", empty: group.2) + } + if let error = report.error, !error.isEmpty { + section("Scan errors", chapter: "Technical evidence") + paragraph(error, chapter: "Technical evidence") + } + end() } - func generate(to url: URL) throws { - - let data = NSMutableData() - - guard let consumer = CGDataConsumer( - data: data as CFMutableData - ) else { - throw NSError( - domain: "PHPGuardPDF", - code: 1 - ) + func write(to url: URL) throws { + let output = NSMutableData() + guard let consumer = CGDataConsumer(data: output as CFMutableData) else { + throw NSError(domain: "PHPGuardPDF", code: 1) } - - var mediaBox = CGRect( - x: 0, - y: 0, - width: pageWidth, - height: pageHeight - ) - - guard let pdfContext = CGContext( - consumer: consumer, - mediaBox: &mediaBox, - nil - ) else { - throw NSError( - domain: "PHPGuardPDF", - code: 2 - ) + var mediaBox = CGRect(x: 0, y: 0, width: w, height: h) + guard let pdf = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { + throw NSError(domain: "PHPGuardPDF", code: 2) } - - context = pdfContext - + context = pdf cover() - executiveSummary() - findingsSection() - environmentSection() - coverageSection() + summary() + findingsPage() + environment() + containers() + actions() appendix() - context.closePDF() - - try (data as Data).write( - to: url, - options: .atomic - ) + try (output as Data).write(to: url, options: .atomic) } } diff --git a/PHPGuard/ReportPDF.swift.backup-20261022-132039 b/PHPGuard/ReportPDF.swift.backup-20261022-132039 deleted file mode 100644 index 864eac7..0000000 --- a/PHPGuard/ReportPDF.swift.backup-20261022-132039 +++ /dev/null @@ -1,64 +0,0 @@ -import AppKit -import CoreGraphics - -@MainActor enum ReportPDF { - static func write(_ report: AuditResult, to url: URL, accent: String) throws { - let data = NSMutableData() - guard let consumer = CGDataConsumer(data: data as CFMutableData) else { throw NSError(domain: "PHPGuardPDF", code: 1) } - var box = CGRect(x: 0, y: 0, width: 595, height: 842) - guard let ctx = CGContext(consumer: consumer, mediaBox: &box, nil) else { throw NSError(domain: "PHPGuardPDF", code: 2) } - let primary: NSColor = accent == "Teal" ? .systemTeal : accent == "Purple" ? .systemPurple : .systemBlue - let margin: CGFloat = 44 - var y: CGFloat = 0 - var page = 0 - func startPage() { - ctx.beginPDFPage(nil); page += 1; y = 48 - ctx.setFillColor(primary.cgColor) - ctx.fill(CGRect(x: 0, y: 0, width: 595, height: 13)) - let heading = NSAttributedString(string: "PHPGUARD / SECURITY ASSESSMENT", attributes: [.font: NSFont.boldSystemFont(ofSize: 10), .foregroundColor: primary]) - ctx.saveGState(); NSGraphicsContext.saveGraphicsState(); NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true); ctx.translateBy(x: 0, y: 842); ctx.scaleBy(x: 1, y: -1) - heading.draw(at: CGPoint(x: margin, y: 25)) - NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState() - } - func endPage() { - let footer = NSAttributedString(string: "PHPGuard • Research findings require vendor advisory verification Page \(page)", attributes: [.font: NSFont.systemFont(ofSize: 8), .foregroundColor: NSColor.secondaryLabelColor]) - ctx.saveGState(); NSGraphicsContext.saveGraphicsState(); NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true); ctx.translateBy(x: 0, y: 842); ctx.scaleBy(x: 1, y: -1) - footer.draw(at: CGPoint(x: margin, y: 808)) - NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState(); ctx.endPDFPage() - } - func line(_ text: String, size: CGFloat = 10, bold: Bool = false, color: NSColor = .labelColor) { - let attrs: [NSAttributedString.Key: Any] = [.font: bold ? NSFont.boldSystemFont(ofSize: size) : NSFont.systemFont(ofSize: size), .foregroundColor: color] - let value = NSAttributedString(string: text, attributes: attrs) - let bound = value.boundingRect(with: CGSize(width: 507, height: 10000), options: [.usesLineFragmentOrigin, .usesFontLeading]) - let height = max(size + 5, ceil(bound.height) + 5) - if y + height > 790 { endPage(); startPage() } - ctx.saveGState(); NSGraphicsContext.saveGraphicsState(); NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true); ctx.translateBy(x: 0, y: 842); ctx.scaleBy(x: 1, y: -1) - value.draw(with: CGRect(x: margin, y: y, width: 507, height: height), options: [.usesLineFragmentOrigin, .usesFontLeading]) - NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState(); y += height - } - func section(_ title: String, _ rows: [String]) { - y += 14; line(title.uppercased(), size: 12, bold: true, color: primary) - if rows.isEmpty { line("No entries recorded", color: .secondaryLabelColor) } - else { for row in rows { line("• " + row) } } - } - startPage() - y += 18; line(report.serverName, size: 25, bold: true) - line("Host: \(report.hostname) OS: \(report.os)", color: .secondaryLabelColor) - line("Scan date: \(report.scannedAt.formatted(date: .abbreviated, time: .standard))", color: .secondaryLabelColor) - section("Executive summary", ["\(report.phpVersions.count) PHP runtimes • \(report.findings.count) findings • \(report.availableUpdates.count) package updates", "CVE status is unverified until matched with vendor advisories."]) - section("PHP installations", report.phpVersions) - section("PHP-FPM services", report.fpmServices) - section("Available package updates", report.availableUpdates) - section("Security findings", report.findings.map { "[\($0.severity)] \($0.title): \($0.detail)" }) - section("Security triage", report.securityTriage) - section("Nginx FastCGI", report.nginxUpstreams) - section("Installed packages", report.packageVersions) - section("APT package origins", report.packageOrigins) - section("Docker PHP", report.dockerPHP) - section("Docker coverage", report.dockerCoverage) - section("Recent PHP files", report.recentPHPFiles) - section("Web error signals", report.webErrorSignals) - section("Assessment notes", [report.packageAuditStatus, report.dockerAuditStatus, report.investigationStatus]) - endPage(); ctx.closePDF(); try (data as Data).write(to: url, options: .atomic) - } -}