Files
PHPGuard/PHPGuard/ReportPDF.swift
T

377 lines
20 KiB
Swift

import AppKit
import CoreGraphics
@MainActor
enum ReportPDF {
static func write(_ report: AuditResult, to url: URL, accent: String) throws {
try ProfessionalReport(report: report, accent: accent).write(to: url)
}
}
@MainActor
private final class ProfessionalReport {
private let report: AuditResult
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 page = 0
private var isOpen = false
init(report: AuditResult, accent: String) {
self.report = report
switch accent {
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 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 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 withCanvas(_ draw: () -> Void) {
context.saveGState()
NSGraphicsContext.saveGraphicsState()
context.translateBy(x: 0, y: h)
context.scaleBy(x: 1, y: -1)
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
draw()
NSGraphicsContext.restoreGraphicsState()
context.restoreGState()
}
private func fill(_ rect: CGRect, _ color: NSColor, radius: CGFloat = 0) {
color.setFill()
NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill()
}
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 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]
)
return max(size + 4, ceil(box.height) + 3)
}
@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: 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 += rowHeight+4
}
private func items(_ values: [String], chapter: String, empty: String = "No entries recorded") {
if values.isEmpty {
paragraph(empty, chapter: chapter)
return
}
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 += needed+3
}
y += 8
}
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 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 += cardH+12
}
private func cover() {
context.beginPDFPage(nil)
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))
}
context.endPDFPage()
}
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 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 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()
}
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() {
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 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: w, height: h)
guard let pdf = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
throw NSError(domain: "PHPGuardPDF", code: 2)
}
context = pdf
cover()
summary()
findingsPage()
environment()
containers()
actions()
appendix()
context.closePDF()
try (output as Data).write(to: url, options: .atomic)
}
}