377 lines
20 KiB
Swift
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)
|
|
}
|
|
}
|