coverme-security-scanner 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1235 @@
1
+ import PDFDocument from 'pdfkit';
2
+ import { createWriteStream } from 'fs';
3
+ import { colors, fonts, spacing, layout } from './styles.js';
4
+ export class PDFGenerator {
5
+ doc;
6
+ y = spacing.page.top;
7
+ pageCount = 1;
8
+ constructor() {
9
+ this.doc = new PDFDocument({
10
+ size: 'A4',
11
+ margins: {
12
+ top: spacing.page.top,
13
+ bottom: spacing.page.bottom,
14
+ left: spacing.page.margin,
15
+ right: spacing.page.margin,
16
+ },
17
+ bufferPages: true,
18
+ autoFirstPage: true,
19
+ });
20
+ }
21
+ newPage() {
22
+ this.doc.addPage();
23
+ this.pageCount++;
24
+ this.y = spacing.page.top;
25
+ }
26
+ async generate(report, outputPath) {
27
+ return new Promise((resolve, reject) => {
28
+ const stream = createWriteStream(outputPath);
29
+ this.doc.pipe(stream);
30
+ this.renderCoverPage(report);
31
+ this.renderTableOfContents(report);
32
+ this.renderExecutiveSummary(report);
33
+ if (report.architecture) {
34
+ this.renderArchitecture(report);
35
+ }
36
+ if (report.network) {
37
+ this.renderNetwork(report);
38
+ }
39
+ if (report.attackChains?.length) {
40
+ this.renderAttackChains(report);
41
+ }
42
+ if (report.riskMatrix?.length) {
43
+ this.renderRiskMatrix(report);
44
+ }
45
+ this.renderFindings(report);
46
+ if (report.threatModel?.length) {
47
+ this.renderThreatModel(report);
48
+ }
49
+ if (report.positiveObservations?.length) {
50
+ this.renderPositiveObservations(report);
51
+ }
52
+ if (report.remediation) {
53
+ this.renderRemediation(report);
54
+ }
55
+ if (report.complianceMapping?.length) {
56
+ this.renderComplianceMapping(report);
57
+ }
58
+ if (report.qualityReview) {
59
+ this.renderQualityReview(report);
60
+ }
61
+ if (report.resolvedIssues?.length) {
62
+ this.renderResolvedIssues(report);
63
+ }
64
+ if (report.privacyAnalysis?.length) {
65
+ this.renderPrivacyAnalysis(report);
66
+ }
67
+ // Summary page at the end
68
+ this.renderSummaryPage(report);
69
+ // Add page numbers and footers to all pages
70
+ this.addPageNumbersAndFooters();
71
+ this.doc.end();
72
+ stream.on('finish', resolve);
73
+ stream.on('error', reject);
74
+ });
75
+ }
76
+ // ─────────────────────────────────────────────────────────────────
77
+ // Cover Page
78
+ // ─────────────────────────────────────────────────────────────────
79
+ renderCoverPage(report) {
80
+ this.y = 200;
81
+ // Title
82
+ this.doc
83
+ .font(fonts.weights.bold)
84
+ .fontSize(fonts.sizes.title)
85
+ .fillColor(colors.text.primary)
86
+ .text('Security Assessment Report', spacing.page.margin, this.y, {
87
+ align: 'center',
88
+ width: layout.content.width,
89
+ });
90
+ this.y += 50;
91
+ // Project name
92
+ this.doc
93
+ .font(fonts.weights.normal)
94
+ .fontSize(fonts.sizes.h1)
95
+ .fillColor(colors.text.secondary)
96
+ .text(report.project, spacing.page.margin, this.y, {
97
+ align: 'center',
98
+ width: layout.content.width,
99
+ });
100
+ this.y += 80;
101
+ // Summary boxes
102
+ this.renderSeverityBoxes(report.summary);
103
+ this.y += 100;
104
+ // Metadata
105
+ this.renderMetadata(report);
106
+ this.newPage();
107
+ this.y = spacing.page.top;
108
+ }
109
+ renderSeverityBoxes(summary) {
110
+ const boxWidth = 90;
111
+ const boxHeight = 50;
112
+ const gap = 20;
113
+ const totalWidth = (boxWidth * 4) + (gap * 3);
114
+ const startX = (layout.page.width - totalWidth) / 2;
115
+ const boxes = [
116
+ { label: 'CRITICAL', count: summary.critical, severity: 'critical' },
117
+ { label: 'HIGH', count: summary.high, severity: 'high' },
118
+ { label: 'MEDIUM', count: summary.medium, severity: 'medium' },
119
+ { label: 'LOW', count: summary.low, severity: 'low' },
120
+ ];
121
+ boxes.forEach((box, i) => {
122
+ const x = startX + (i * (boxWidth + gap));
123
+ const style = colors.severity[box.severity];
124
+ // Box with subtle border
125
+ this.doc
126
+ .rect(x, this.y, boxWidth, boxHeight)
127
+ .stroke(colors.border);
128
+ // Count in severity color
129
+ this.doc
130
+ .font(fonts.weights.bold)
131
+ .fontSize(22)
132
+ .fillColor(style.text)
133
+ .text(box.count.toString(), x, this.y + 8, {
134
+ width: boxWidth,
135
+ align: 'center',
136
+ });
137
+ // Label
138
+ this.doc
139
+ .font(fonts.weights.normal)
140
+ .fontSize(fonts.sizes.small)
141
+ .fillColor(colors.text.secondary)
142
+ .text(box.label, x, this.y + 32, {
143
+ width: boxWidth,
144
+ align: 'center',
145
+ });
146
+ });
147
+ }
148
+ renderMetadata(report) {
149
+ const metadata = [
150
+ { label: 'Date', value: report.date },
151
+ { label: 'Branch', value: report.branch || 'main' },
152
+ { label: 'Scope', value: report.scope || 'Full codebase' },
153
+ ];
154
+ const startX = layout.page.width / 2 - 100;
155
+ metadata.forEach((item, i) => {
156
+ this.doc
157
+ .font(fonts.weights.normal)
158
+ .fontSize(fonts.sizes.body)
159
+ .fillColor(colors.text.muted)
160
+ .text(item.label, startX, this.y + (i * 20), { continued: true })
161
+ .font(fonts.weights.bold)
162
+ .fillColor(colors.text.secondary)
163
+ .text(` ${item.value}`);
164
+ });
165
+ }
166
+ // ─────────────────────────────────────────────────────────────────
167
+ // Table of Contents
168
+ // ─────────────────────────────────────────────────────────────────
169
+ renderTableOfContents(report) {
170
+ this.sectionTitle('Table of Contents');
171
+ // Build items with descriptions
172
+ const items = [
173
+ {
174
+ title: 'Executive Summary',
175
+ description: `Overall risk: ${report.overallRiskLevel.toUpperCase()}, ${report.summary.total} findings`
176
+ },
177
+ ...(report.architecture ? [{
178
+ title: 'Architecture Overview',
179
+ description: `${report.architecture.components?.length || 0} components, ${report.architecture.trustBoundaries?.length || 0} trust boundaries`
180
+ }] : []),
181
+ ...(report.network ? [{
182
+ title: 'Network Topology',
183
+ description: `${report.network.ports?.length || 0} ports, ${report.network.externalDeps?.length || 0} external deps`
184
+ }] : []),
185
+ ...(report.attackChains?.length ? [{
186
+ title: 'Attack Chains',
187
+ description: `${report.attackChains.length} chains identified`
188
+ }] : []),
189
+ {
190
+ title: 'Security Findings',
191
+ description: this.getFindingsSummary(report)
192
+ },
193
+ ...(report.threatModel?.length ? [{
194
+ title: 'Threat Model',
195
+ description: `STRIDE + DREAD analysis, ${report.threatModel.length} threats`
196
+ }] : []),
197
+ ...(report.qualityReview ? [{
198
+ title: 'Quality Review',
199
+ description: this.getQualitySummary(report)
200
+ }] : []),
201
+ ...(report.resolvedIssues?.length ? [{
202
+ title: 'Previously Resolved',
203
+ description: `${report.resolvedIssues.length} issues resolved`
204
+ }] : []),
205
+ ...(report.positiveObservations?.length ? [{
206
+ title: 'Positive Observations',
207
+ description: `${report.positiveObservations.length} security strengths`
208
+ }] : []),
209
+ ...(report.privacyAnalysis?.length ? [{
210
+ title: 'Privacy Analysis (LINDDUN)',
211
+ description: `${report.privacyAnalysis.length} privacy considerations`
212
+ }] : []),
213
+ ...(report.remediation ? [{
214
+ title: 'Remediation Roadmap',
215
+ description: this.getRemediationSummary(report)
216
+ }] : []),
217
+ ...(report.complianceMapping?.length ? [{
218
+ title: 'Compliance Mapping',
219
+ description: report.complianceMapping.map(c => c.framework).join(', ')
220
+ }] : []),
221
+ ];
222
+ // Render each item with description
223
+ items.forEach((item, i) => {
224
+ this.doc
225
+ .font(fonts.weights.bold)
226
+ .fontSize(fonts.sizes.body)
227
+ .fillColor(colors.text.primary)
228
+ .text(`${i + 1}. ${item.title}`, spacing.page.margin, this.y, { continued: true })
229
+ .font(fonts.weights.normal)
230
+ .fillColor(colors.text.muted)
231
+ .text(` - ${item.description}`);
232
+ this.y += 18;
233
+ });
234
+ this.y += spacing.paragraph;
235
+ // Summary table
236
+ this.renderTocSummaryTable(report);
237
+ this.newPage();
238
+ this.y = spacing.page.top;
239
+ }
240
+ getFindingsSummary(report) {
241
+ const parts = [];
242
+ if (report.summary.critical)
243
+ parts.push(`${report.summary.critical} critical`);
244
+ if (report.summary.high)
245
+ parts.push(`${report.summary.high} high`);
246
+ if (report.summary.medium)
247
+ parts.push(`${report.summary.medium} medium`);
248
+ if (report.summary.low)
249
+ parts.push(`${report.summary.low} low`);
250
+ return parts.join(', ') || 'No findings';
251
+ }
252
+ getQualitySummary(report) {
253
+ const q = report.qualityReview;
254
+ if (!q)
255
+ return '';
256
+ const parts = [];
257
+ if (q.deadCode?.length)
258
+ parts.push(`${q.deadCode.length} dead code`);
259
+ if (q.dryViolations?.length)
260
+ parts.push(`${q.dryViolations.length} DRY`);
261
+ if (q.deprecated?.length)
262
+ parts.push(`${q.deprecated.length} deprecated`);
263
+ return parts.join(', ') || 'Clean';
264
+ }
265
+ getRemediationSummary(report) {
266
+ const r = report.remediation;
267
+ if (!r)
268
+ return '';
269
+ const counts = [
270
+ r.p0?.length || 0,
271
+ r.p1?.length || 0,
272
+ r.p2?.length || 0,
273
+ r.p3?.length || 0,
274
+ ];
275
+ return `P0: ${counts[0]}, P1: ${counts[1]}, P2: ${counts[2]}, P3: ${counts[3]}`;
276
+ }
277
+ renderTocSummaryTable(report) {
278
+ const data = [
279
+ ['Source Reports', report.methodology || '-'],
280
+ ['Findings', report.summary.total.toString()],
281
+ ['Quality Items', this.getQualitySummary(report) || '0'],
282
+ ['Threat Model', report.threatModel?.length.toString() || '0'],
283
+ ];
284
+ const startX = spacing.page.margin;
285
+ const colWidths = [150, 345];
286
+ const rowHeight = 22;
287
+ data.forEach((row, i) => {
288
+ // Alternating background
289
+ if (i % 2 === 0) {
290
+ this.doc
291
+ .rect(startX, this.y, layout.content.width, rowHeight)
292
+ .fill(colors.table.altRow);
293
+ }
294
+ this.doc
295
+ .font(fonts.weights.bold)
296
+ .fontSize(fonts.sizes.small)
297
+ .fillColor(colors.text.primary)
298
+ .text(row[0], startX + 8, this.y + 6, { width: colWidths[0] - 16 });
299
+ this.doc
300
+ .font(fonts.weights.normal)
301
+ .fontSize(fonts.sizes.small)
302
+ .fillColor(colors.text.secondary)
303
+ .text(row[1], startX + colWidths[0] + 8, this.y + 6, { width: colWidths[1] - 16 });
304
+ this.y += rowHeight;
305
+ });
306
+ }
307
+ // ─────────────────────────────────────────────────────────────────
308
+ // Executive Summary
309
+ // ─────────────────────────────────────────────────────────────────
310
+ renderExecutiveSummary(report) {
311
+ this.sectionTitle('Executive Summary');
312
+ // Risk level with color badge
313
+ const riskStyle = colors.severity[report.overallRiskLevel];
314
+ this.doc
315
+ .font(fonts.weights.bold)
316
+ .fontSize(fonts.sizes.body)
317
+ .fillColor(colors.text.primary)
318
+ .text('Overall Risk: ', spacing.page.margin, this.y, { continued: true })
319
+ .fillColor(riskStyle.text)
320
+ .text(report.overallRiskLevel.toUpperCase());
321
+ this.y += 24;
322
+ // Summary text
323
+ this.doc
324
+ .font(fonts.weights.normal)
325
+ .fontSize(fonts.sizes.body)
326
+ .fillColor(colors.text.primary)
327
+ .text(report.executiveSummary, spacing.page.margin, this.y, {
328
+ width: layout.content.width,
329
+ lineGap: 4,
330
+ });
331
+ this.y = this.doc.y + spacing.paragraph;
332
+ // Previously resolved section (if exists)
333
+ if (report.resolvedIssues?.length) {
334
+ this.y += 8;
335
+ this.doc
336
+ .font(fonts.weights.bold)
337
+ .fontSize(fonts.sizes.small)
338
+ .fillColor(colors.severity.low.text)
339
+ .text('Previously Resolved:', spacing.page.margin, this.y);
340
+ this.y += 14;
341
+ const resolvedText = report.resolvedIssues
342
+ .slice(0, 3)
343
+ .map(r => `${r.id}: ${r.title}`)
344
+ .join(', ');
345
+ this.doc
346
+ .font(fonts.weights.normal)
347
+ .fontSize(fonts.sizes.body)
348
+ .fillColor(colors.text.secondary)
349
+ .text(resolvedText + (report.resolvedIssues.length > 3 ? ` (+${report.resolvedIssues.length - 3} more)` : ''), spacing.page.margin, this.y, {
350
+ width: layout.content.width,
351
+ });
352
+ this.y = this.doc.y + spacing.paragraph;
353
+ }
354
+ // Most significant issues callout
355
+ const criticalFindings = report.findings.filter(f => f.severity === 'critical');
356
+ if (criticalFindings.length > 0) {
357
+ this.y += 8;
358
+ this.doc
359
+ .font(fonts.weights.bold)
360
+ .fontSize(fonts.sizes.small)
361
+ .fillColor(colors.severity.critical.text)
362
+ .text('Most Significant Issues:', spacing.page.margin, this.y);
363
+ this.y += 14;
364
+ criticalFindings.slice(0, 3).forEach(f => {
365
+ this.doc
366
+ .font(fonts.weights.normal)
367
+ .fontSize(fonts.sizes.body)
368
+ .fillColor(colors.text.primary)
369
+ .text(`[${f.id}] ${f.title}`, spacing.page.margin, this.y);
370
+ this.y += 14;
371
+ });
372
+ }
373
+ this.y += spacing.paragraph;
374
+ // Top priorities table
375
+ if (report.topPriorities?.length) {
376
+ this.subTitle('Top Priorities');
377
+ this.renderSimpleTable(['#', 'Finding', 'Severity', 'Action'], report.topPriorities.map((p, i) => [
378
+ (i + 1).toString(),
379
+ p.finding,
380
+ p.severity.toUpperCase(),
381
+ p.action,
382
+ ]), [30, 200, 70, 195]);
383
+ }
384
+ this.checkPageBreak();
385
+ }
386
+ // ─────────────────────────────────────────────────────────────────
387
+ // Architecture
388
+ // ─────────────────────────────────────────────────────────────────
389
+ renderArchitecture(report) {
390
+ this.newPage();
391
+ this.y = spacing.page.top;
392
+ this.sectionTitle('Architecture Overview');
393
+ if (report.architecture?.overview) {
394
+ this.doc
395
+ .font(fonts.weights.normal)
396
+ .fontSize(fonts.sizes.body)
397
+ .fillColor(colors.text.primary)
398
+ .text(report.architecture.overview, spacing.page.margin, this.y, {
399
+ width: layout.content.width,
400
+ lineGap: 4,
401
+ });
402
+ this.y = this.doc.y + spacing.paragraph;
403
+ }
404
+ if (report.architecture?.components?.length) {
405
+ this.subTitle('Components');
406
+ this.renderSimpleTable(['Component', 'Technology', 'Description'], report.architecture.components.map(c => [c.name, c.technology, c.description]), [100, 150, 245]);
407
+ }
408
+ // Trust Boundaries
409
+ if (report.architecture?.trustBoundaries?.length) {
410
+ this.checkPageBreak(150);
411
+ this.subTitle('Trust Boundaries');
412
+ report.architecture.trustBoundaries.forEach(tb => {
413
+ this.checkPageBreak(60);
414
+ // TB-ID with colored badge based on trust level
415
+ const trustColor = tb.trustLevel === 'untrusted' ? colors.severity.critical.text :
416
+ tb.trustLevel === 'semi-trusted' ? colors.severity.medium.text :
417
+ colors.severity.low.text;
418
+ this.doc
419
+ .font(fonts.weights.bold)
420
+ .fontSize(fonts.sizes.body)
421
+ .fillColor(colors.accent)
422
+ .text(tb.id, spacing.page.margin, this.y, { continued: true })
423
+ .fillColor(colors.text.primary)
424
+ .text(` ${tb.boundary}`, { continued: true })
425
+ .font(fonts.weights.normal)
426
+ .fillColor(trustColor)
427
+ .text(` [${tb.trustLevel.toUpperCase()}]`);
428
+ this.y += 14;
429
+ this.doc
430
+ .font(fonts.weights.normal)
431
+ .fontSize(fonts.sizes.small)
432
+ .fillColor(colors.text.secondary)
433
+ .text(tb.description, spacing.page.margin + 20, this.y);
434
+ this.y += 18;
435
+ });
436
+ }
437
+ this.checkPageBreak();
438
+ }
439
+ // ─────────────────────────────────────────────────────────────────
440
+ // Network
441
+ // ─────────────────────────────────────────────────────────────────
442
+ renderNetwork(report) {
443
+ this.newPage();
444
+ this.y = spacing.page.top;
445
+ this.sectionTitle('Network Topology');
446
+ // ASCII diagram
447
+ if (report.network?.diagram) {
448
+ this.doc
449
+ .font(fonts.mono)
450
+ .fontSize(7)
451
+ .fillColor(colors.text.secondary)
452
+ .text(report.network.diagram, spacing.page.margin, this.y, {
453
+ width: layout.content.width,
454
+ });
455
+ this.y = this.doc.y + spacing.section;
456
+ }
457
+ // Ports table
458
+ if (report.network?.ports?.length) {
459
+ this.subTitle('Port Reference');
460
+ this.renderSimpleTable(['Port', 'Protocol', 'Component', 'Binding', 'Purpose'], report.network.ports.map(p => [
461
+ p.port.toString(),
462
+ p.protocol,
463
+ p.component,
464
+ p.binding,
465
+ p.purpose,
466
+ ]), [40, 50, 100, 70, 235]);
467
+ }
468
+ this.checkPageBreak();
469
+ }
470
+ // ─────────────────────────────────────────────────────────────────
471
+ // Attack Chains
472
+ // ─────────────────────────────────────────────────────────────────
473
+ renderAttackChains(report) {
474
+ this.newPage();
475
+ this.y = spacing.page.top;
476
+ this.sectionTitle('Attack Chains');
477
+ this.doc
478
+ .font(fonts.weights.normal)
479
+ .fontSize(fonts.sizes.body)
480
+ .fillColor(colors.text.secondary)
481
+ .text('These attack chains show how individual vulnerabilities can be combined for greater impact.', spacing.page.margin, this.y, {
482
+ width: layout.content.width,
483
+ });
484
+ this.y = this.doc.y + spacing.paragraph;
485
+ report.attackChains?.forEach((chain, idx) => {
486
+ this.checkPageBreak(200);
487
+ this.renderAttackChain(chain, idx + 1);
488
+ });
489
+ }
490
+ renderAttackChain(chain, num) {
491
+ // Calculate minimum height for header + description
492
+ const descHeight = this.estimateTextHeight(chain.description, layout.content.width);
493
+ const headerHeight = 32 + descHeight + 20;
494
+ this.checkPageBreak(headerHeight);
495
+ // Simple header line
496
+ this.doc
497
+ .font(fonts.weights.bold)
498
+ .fontSize(fonts.sizes.h3)
499
+ .fillColor(colors.text.primary)
500
+ .text(`AC-${num.toString().padStart(2, '0')}: ${chain.name}`, spacing.page.margin, this.y);
501
+ this.y += 16;
502
+ // Likelihood/Impact as simple text with color
503
+ const impactStyle = colors.severity[chain.impact] || colors.severity.high;
504
+ this.doc
505
+ .font(fonts.weights.normal)
506
+ .fontSize(fonts.sizes.small)
507
+ .fillColor(colors.text.secondary)
508
+ .text('Likelihood: ', spacing.page.margin, this.y, { continued: true })
509
+ .fillColor(impactStyle.text)
510
+ .text(chain.likelihood.toUpperCase(), { continued: true })
511
+ .fillColor(colors.text.secondary)
512
+ .text(' | Impact: ', { continued: true })
513
+ .fillColor(impactStyle.text)
514
+ .text(chain.impact.toUpperCase());
515
+ this.y += 16;
516
+ // Description
517
+ this.doc
518
+ .font(fonts.weights.normal)
519
+ .fontSize(fonts.sizes.body)
520
+ .fillColor(colors.text.primary)
521
+ .text(chain.description, spacing.page.margin, this.y, {
522
+ width: layout.content.width,
523
+ lineGap: 3,
524
+ });
525
+ this.y = this.doc.y + spacing.paragraph;
526
+ // Attack steps - simple list
527
+ this.renderAttackSteps(chain.steps);
528
+ // Mitigation strategy - keep together
529
+ if (chain.mitigationStrategy) {
530
+ const mitigationHeight = this.estimateTextHeight(chain.mitigationStrategy, layout.content.width) + 20;
531
+ this.checkPageBreak(mitigationHeight);
532
+ this.doc
533
+ .font(fonts.weights.bold)
534
+ .fontSize(fonts.sizes.small)
535
+ .fillColor(colors.severity.low.text)
536
+ .text('Mitigation:', spacing.page.margin, this.y);
537
+ this.y += 12;
538
+ this.doc
539
+ .font(fonts.weights.normal)
540
+ .fontSize(fonts.sizes.body)
541
+ .fillColor(colors.text.secondary)
542
+ .text(chain.mitigationStrategy, spacing.page.margin, this.y, {
543
+ width: layout.content.width,
544
+ });
545
+ this.y = this.doc.y + 8;
546
+ }
547
+ this.y += spacing.section;
548
+ }
549
+ renderAttackSteps(steps) {
550
+ steps.forEach((step, idx) => {
551
+ // Keep step together (action + outcome)
552
+ const stepHeight = 35;
553
+ this.checkPageBreak(stepHeight);
554
+ // Step with finding ID in accent color
555
+ this.doc
556
+ .font(fonts.weights.bold)
557
+ .fontSize(fonts.sizes.body)
558
+ .fillColor(colors.text.primary)
559
+ .text(`${step.order}. `, spacing.page.margin, this.y, { continued: true })
560
+ .fillColor(colors.accent)
561
+ .text(`[${step.findingId}]`, { continued: true })
562
+ .fillColor(colors.text.primary)
563
+ .text(` ${step.action}`);
564
+ this.y += 14;
565
+ this.doc
566
+ .font(fonts.weights.normal)
567
+ .fontSize(fonts.sizes.small)
568
+ .fillColor(colors.text.secondary)
569
+ .text(` Outcome: ${step.outcome}`, spacing.page.margin, this.y);
570
+ this.y += 16;
571
+ });
572
+ }
573
+ // ─────────────────────────────────────────────────────────────────
574
+ // Risk Matrix
575
+ // ─────────────────────────────────────────────────────────────────
576
+ renderRiskMatrix(report) {
577
+ this.checkPageBreak(200);
578
+ this.subTitle('Risk by Category');
579
+ const matrix = report.riskMatrix || [];
580
+ this.renderSimpleTable(['Category', 'Current Risk', 'After Fixes', 'Trend'], matrix.map(r => [
581
+ r.category,
582
+ r.currentRisk.toUpperCase(),
583
+ r.residualRisk.toUpperCase(),
584
+ this.getTrendIndicator(r.trend),
585
+ ]), [150, 100, 100, 145]);
586
+ this.y += spacing.paragraph;
587
+ }
588
+ getTrendIndicator(trend) {
589
+ switch (trend) {
590
+ case 'improving': return '↑ Improving';
591
+ case 'worsening': return '↓ Worsening';
592
+ default: return '→ Stable';
593
+ }
594
+ }
595
+ // ─────────────────────────────────────────────────────────────────
596
+ // Findings
597
+ // ─────────────────────────────────────────────────────────────────
598
+ renderFindings(report) {
599
+ const severities = ['critical', 'high', 'medium', 'low'];
600
+ severities.forEach(severity => {
601
+ const findings = report.findings.filter(f => f.severity === severity);
602
+ if (!findings.length)
603
+ return;
604
+ this.newPage();
605
+ this.y = spacing.page.top;
606
+ this.sectionTitle(`${severity.toUpperCase()} Findings`);
607
+ findings.forEach(finding => {
608
+ this.renderFinding(finding);
609
+ });
610
+ });
611
+ }
612
+ renderFinding(finding) {
613
+ this.checkPageBreak(150);
614
+ const boxPadding = 12;
615
+ const boxInnerWidth = layout.content.width - (boxPadding * 2);
616
+ const style = colors.severity[finding.severity];
617
+ // ID in severity color, title in black
618
+ this.doc
619
+ .font(fonts.weights.bold)
620
+ .fontSize(fonts.sizes.body)
621
+ .fillColor(style.text)
622
+ .text(`[${finding.id}]`, spacing.page.margin, this.y, { continued: true })
623
+ .fillColor(colors.text.primary)
624
+ .text(` ${finding.title}`);
625
+ this.y += 18;
626
+ // Cross-references and DREAD score line
627
+ const hasRefs = finding.relatedFindings?.length || finding.dreadScore || finding.cwe;
628
+ if (hasRefs) {
629
+ const parts = [];
630
+ if (finding.dreadScore)
631
+ parts.push(`DREAD: ${finding.dreadScore.toFixed(1)}`);
632
+ if (finding.cwe)
633
+ parts.push(finding.cwe);
634
+ if (finding.relatedFindings?.length) {
635
+ parts.push(`Related: ${finding.relatedFindings.join(', ')}`);
636
+ }
637
+ this.doc
638
+ .font(fonts.weights.normal)
639
+ .fontSize(fonts.sizes.small)
640
+ .fillColor(colors.text.muted)
641
+ .text(parts.join(' | '), spacing.page.margin, this.y);
642
+ this.y += 14;
643
+ }
644
+ // File reference (if present)
645
+ if (finding.file) {
646
+ this.doc
647
+ .font(fonts.mono)
648
+ .fontSize(fonts.sizes.small)
649
+ .fillColor(colors.accent)
650
+ .text(`${finding.file}${finding.line ? `:${finding.line}` : ''}`, spacing.page.margin, this.y);
651
+ this.y += 18;
652
+ }
653
+ // ─────────────────────────────────────────────────────────────
654
+ // Check for structured format (issue/why/fix) vs legacy format
655
+ // ─────────────────────────────────────────────────────────────
656
+ const hasStructuredFormat = finding.issue || finding.why || finding.fix;
657
+ if (hasStructuredFormat) {
658
+ // Render structured code-box style
659
+ this.renderStructuredFinding(finding, boxPadding, boxInnerWidth);
660
+ }
661
+ else {
662
+ // Render legacy format
663
+ this.renderLegacyFinding(finding);
664
+ }
665
+ this.y += spacing.section;
666
+ }
667
+ // Clean structured finding
668
+ renderStructuredFinding(finding, padding, innerWidth) {
669
+ // Calculate heights to keep sections together
670
+ const issueHeight = finding.issue ? this.estimateTextHeight(finding.issue, layout.content.width) + 22 : 0;
671
+ const whyHeight = finding.why ? this.estimateTextHeight(finding.why, layout.content.width) + 22 : 0;
672
+ const fixHeight = finding.fix ? this.estimateTextHeight(finding.fix, layout.content.width) + 22 : 0;
673
+ // Issue
674
+ if (finding.issue) {
675
+ this.checkPageBreak(issueHeight);
676
+ this.doc
677
+ .font(fonts.weights.bold)
678
+ .fontSize(fonts.sizes.small)
679
+ .fillColor(colors.text.primary)
680
+ .text('Issue:', spacing.page.margin, this.y);
681
+ this.y += 12;
682
+ this.doc
683
+ .font(fonts.weights.normal)
684
+ .fontSize(fonts.sizes.body)
685
+ .fillColor(colors.text.primary)
686
+ .text(finding.issue, spacing.page.margin, this.y, {
687
+ width: layout.content.width,
688
+ lineGap: 3,
689
+ });
690
+ this.y = this.doc.y + 10;
691
+ }
692
+ // Why
693
+ if (finding.why) {
694
+ this.checkPageBreak(whyHeight);
695
+ this.doc
696
+ .font(fonts.weights.bold)
697
+ .fontSize(fonts.sizes.small)
698
+ .fillColor(colors.text.primary)
699
+ .text('Why:', spacing.page.margin, this.y);
700
+ this.y += 12;
701
+ this.doc
702
+ .font(fonts.weights.normal)
703
+ .fontSize(fonts.sizes.body)
704
+ .fillColor(colors.text.secondary)
705
+ .text(finding.why, spacing.page.margin, this.y, {
706
+ width: layout.content.width,
707
+ lineGap: 3,
708
+ });
709
+ this.y = this.doc.y + 10;
710
+ }
711
+ // Fix
712
+ if (finding.fix) {
713
+ this.checkPageBreak(fixHeight);
714
+ this.doc
715
+ .font(fonts.weights.bold)
716
+ .fontSize(fonts.sizes.small)
717
+ .fillColor(colors.text.primary)
718
+ .text('Fix:', spacing.page.margin, this.y);
719
+ this.y += 12;
720
+ this.doc
721
+ .font(fonts.weights.normal)
722
+ .fontSize(fonts.sizes.body)
723
+ .fillColor(colors.text.secondary)
724
+ .text(finding.fix, spacing.page.margin, this.y, {
725
+ width: layout.content.width,
726
+ lineGap: 3,
727
+ });
728
+ this.y = this.doc.y + 10;
729
+ }
730
+ // Code evidence (if present)
731
+ if (finding.codeEvidence?.length) {
732
+ this.y += 4;
733
+ finding.codeEvidence.forEach(evidence => {
734
+ this.renderCodeEvidence(evidence);
735
+ });
736
+ }
737
+ // Proof of concept (if present)
738
+ if (finding.proofOfConcept) {
739
+ this.y += 4;
740
+ this.renderProofOfConcept(finding.proofOfConcept);
741
+ }
742
+ }
743
+ // Estimate text height for page break calculations
744
+ estimateTextHeight(text, width) {
745
+ const avgCharsPerLine = width / 5; // rough estimate for 10pt font
746
+ const lines = Math.ceil(text.length / avgCharsPerLine);
747
+ return lines * 14 + 10; // 14px per line + padding
748
+ }
749
+ renderCodeEvidence(evidence) {
750
+ // Calculate total height needed for code block + annotation
751
+ const codeLines = evidence.code.split('\n');
752
+ const lineHeight = 12;
753
+ const codeHeight = Math.min(codeLines.length * lineHeight + 12, 120);
754
+ const annotationHeight = evidence.annotation ? 20 : 0;
755
+ const totalHeight = 14 + codeHeight + annotationHeight + 10; // file path + code + annotation + padding
756
+ this.checkPageBreak(totalHeight);
757
+ // File path
758
+ this.doc
759
+ .font(fonts.mono)
760
+ .fontSize(fonts.sizes.small)
761
+ .fillColor(colors.text.muted)
762
+ .text(`${evidence.file}:${evidence.startLine}${evidence.endLine ? `-${evidence.endLine}` : ''}`, spacing.page.margin, this.y);
763
+ this.y += 14;
764
+ // Code block - simple gray background
765
+ this.doc
766
+ .rect(spacing.page.margin, this.y, layout.content.width, codeHeight)
767
+ .fill(colors.accentMuted);
768
+ let codeY = this.y + 6;
769
+ codeLines.forEach((line, idx) => {
770
+ if (codeY > this.y + codeHeight - lineHeight)
771
+ return;
772
+ const lineNum = evidence.startLine + idx;
773
+ // Line number
774
+ this.doc
775
+ .font(fonts.mono)
776
+ .fontSize(8)
777
+ .fillColor(colors.text.muted)
778
+ .text(lineNum.toString().padStart(4, ' '), spacing.page.margin + 6, codeY);
779
+ // Code
780
+ this.doc
781
+ .font(fonts.mono)
782
+ .fontSize(8)
783
+ .fillColor(colors.text.primary)
784
+ .text(line.slice(0, 80), spacing.page.margin + 40, codeY);
785
+ codeY += lineHeight;
786
+ });
787
+ this.y += codeHeight + 4;
788
+ // Annotation (if present)
789
+ if (evidence.annotation) {
790
+ this.doc
791
+ .font(fonts.weights.normal)
792
+ .fontSize(fonts.sizes.small)
793
+ .fillColor(colors.text.secondary)
794
+ .text('Note: ' + evidence.annotation, spacing.page.margin, this.y);
795
+ this.y += 14;
796
+ }
797
+ }
798
+ renderProofOfConcept(poc) {
799
+ this.checkPageBreak(30);
800
+ this.doc
801
+ .font(fonts.weights.bold)
802
+ .fontSize(fonts.sizes.small)
803
+ .fillColor(colors.text.primary)
804
+ .text('Proof of Concept:', spacing.page.margin, this.y);
805
+ this.y += 12;
806
+ this.doc
807
+ .font(fonts.mono)
808
+ .fontSize(8)
809
+ .fillColor(colors.text.secondary)
810
+ .text(poc, spacing.page.margin, this.y, {
811
+ width: layout.content.width,
812
+ });
813
+ this.y = this.doc.y + 8;
814
+ }
815
+ // Legacy format rendering (description + impact + recommendation)
816
+ renderLegacyFinding(finding) {
817
+ // Description
818
+ if (finding.description) {
819
+ this.doc
820
+ .font(fonts.weights.normal)
821
+ .fontSize(fonts.sizes.body)
822
+ .fillColor(colors.text.primary)
823
+ .text(finding.description, spacing.page.margin, this.y, {
824
+ width: layout.content.width,
825
+ lineGap: 3,
826
+ });
827
+ this.y = this.doc.y + spacing.paragraph;
828
+ }
829
+ // Impact
830
+ if (finding.impact) {
831
+ this.doc
832
+ .font(fonts.weights.bold)
833
+ .fontSize(fonts.sizes.small)
834
+ .fillColor('#D97706')
835
+ .text('Impact', spacing.page.margin, this.y);
836
+ this.y += 14;
837
+ this.doc
838
+ .font(fonts.weights.normal)
839
+ .fontSize(fonts.sizes.body)
840
+ .fillColor(colors.text.primary)
841
+ .text(finding.impact, spacing.page.margin, this.y, {
842
+ width: layout.content.width,
843
+ lineGap: 3,
844
+ });
845
+ this.y = this.doc.y + spacing.paragraph;
846
+ }
847
+ // Recommendation
848
+ if (finding.recommendation) {
849
+ this.doc
850
+ .font(fonts.weights.bold)
851
+ .fontSize(fonts.sizes.small)
852
+ .fillColor('#059669')
853
+ .text('Recommendation', spacing.page.margin, this.y);
854
+ this.y += 14;
855
+ this.doc
856
+ .font(fonts.weights.normal)
857
+ .fontSize(fonts.sizes.body)
858
+ .fillColor(colors.text.primary)
859
+ .text(finding.recommendation, spacing.page.margin, this.y, {
860
+ width: layout.content.width,
861
+ lineGap: 3,
862
+ });
863
+ this.y = this.doc.y + spacing.paragraph;
864
+ }
865
+ }
866
+ // ─────────────────────────────────────────────────────────────────
867
+ // Threat Model
868
+ // ─────────────────────────────────────────────────────────────────
869
+ renderThreatModel(report) {
870
+ this.newPage();
871
+ this.y = spacing.page.top;
872
+ this.sectionTitle('Threat Model (STRIDE + DREAD)');
873
+ if (report.threatModel?.length) {
874
+ this.renderSimpleTable(['ID', 'Severity', 'DREAD', 'Status', 'Finding'], report.threatModel.map(t => [
875
+ t.id,
876
+ t.severity.toUpperCase(),
877
+ t.dread?.toFixed(1) || '-',
878
+ t.status,
879
+ t.finding,
880
+ ]), [60, 60, 50, 70, 255]);
881
+ }
882
+ this.checkPageBreak();
883
+ }
884
+ // ─────────────────────────────────────────────────────────────────
885
+ // Positive Observations
886
+ // ─────────────────────────────────────────────────────────────────
887
+ renderPositiveObservations(report) {
888
+ this.newPage();
889
+ this.y = spacing.page.top;
890
+ this.sectionTitle('Positive Security Observations');
891
+ report.positiveObservations?.forEach(obs => {
892
+ this.checkPageBreak(60);
893
+ this.doc
894
+ .font(fonts.weights.bold)
895
+ .fontSize(fonts.sizes.body)
896
+ .fillColor(colors.text.primary)
897
+ .text(obs.title, spacing.page.margin, this.y);
898
+ this.y += 14;
899
+ this.doc
900
+ .font(fonts.weights.normal)
901
+ .fontSize(fonts.sizes.body)
902
+ .fillColor(colors.text.secondary)
903
+ .text(obs.description, spacing.page.margin, this.y, {
904
+ width: layout.content.width,
905
+ lineGap: 3,
906
+ });
907
+ this.y = this.doc.y + spacing.paragraph;
908
+ });
909
+ }
910
+ // ─────────────────────────────────────────────────────────────────
911
+ // Remediation
912
+ // ─────────────────────────────────────────────────────────────────
913
+ renderRemediation(report) {
914
+ this.newPage();
915
+ this.y = spacing.page.top;
916
+ this.sectionTitle('Remediation Roadmap');
917
+ const priorities = [
918
+ { key: 'p0', label: 'P0 - Within 24-48 Hours' },
919
+ { key: 'p1', label: 'P1 - Within 1 Week' },
920
+ { key: 'p2', label: 'P2 - Within 1 Month' },
921
+ { key: 'p3', label: 'P3 - Within 1 Quarter' },
922
+ ];
923
+ priorities.forEach(({ key, label }) => {
924
+ const items = report.remediation?.[key];
925
+ if (!items?.length)
926
+ return;
927
+ this.checkPageBreak(100);
928
+ this.subTitle(label);
929
+ this.renderSimpleTable(['Action', 'Finding', 'Owner'], items.map(item => [item.action, item.finding, item.owner]), [280, 80, 135]);
930
+ this.y += spacing.paragraph;
931
+ });
932
+ }
933
+ // ─────────────────────────────────────────────────────────────────
934
+ // Compliance Mapping
935
+ // ─────────────────────────────────────────────────────────────────
936
+ renderComplianceMapping(report) {
937
+ this.newPage();
938
+ this.y = spacing.page.top;
939
+ this.sectionTitle('Compliance Mapping');
940
+ report.complianceMapping?.forEach(framework => {
941
+ this.checkPageBreak(100);
942
+ this.subTitle(framework.framework);
943
+ framework.controls.forEach(control => {
944
+ this.checkPageBreak(24);
945
+ // Control ID and name
946
+ this.doc
947
+ .font(fonts.weights.bold)
948
+ .fontSize(fonts.sizes.small)
949
+ .fillColor(colors.text.primary)
950
+ .text(control.controlId, spacing.page.margin, this.y, { continued: true })
951
+ .font(fonts.weights.normal)
952
+ .fillColor(colors.text.secondary)
953
+ .text(` ${control.name}`, { continued: true })
954
+ .text(` [${control.status.toUpperCase()}]`);
955
+ // Related findings
956
+ if (control.relatedFindings?.length) {
957
+ this.doc
958
+ .font(fonts.mono)
959
+ .fontSize(7)
960
+ .fillColor(colors.text.muted)
961
+ .text(` Related: ${control.relatedFindings.join(', ')}`, spacing.page.margin, this.y + 12);
962
+ this.y += 24;
963
+ }
964
+ else {
965
+ this.y += 16;
966
+ }
967
+ });
968
+ this.y += spacing.paragraph;
969
+ });
970
+ }
971
+ // ─────────────────────────────────────────────────────────────────
972
+ // Quality Review
973
+ // ─────────────────────────────────────────────────────────────────
974
+ renderQualityReview(report) {
975
+ this.newPage();
976
+ this.y = spacing.page.top;
977
+ this.sectionTitle('Quality Review');
978
+ const q = report.qualityReview;
979
+ if (!q)
980
+ return;
981
+ // Dead Code
982
+ if (q.deadCode?.length) {
983
+ this.subTitle('Dead Code');
984
+ this.renderQualityTable(q.deadCode);
985
+ }
986
+ // DRY Violations
987
+ if (q.dryViolations?.length) {
988
+ this.checkPageBreak(150);
989
+ this.subTitle('DRY Violations');
990
+ this.renderQualityTable(q.dryViolations);
991
+ }
992
+ // Deprecated
993
+ if (q.deprecated?.length) {
994
+ this.checkPageBreak(150);
995
+ this.subTitle('Deprecated Code');
996
+ this.renderQualityTable(q.deprecated);
997
+ }
998
+ }
999
+ renderQualityTable(items) {
1000
+ this.renderSimpleTable(['Action', 'File', 'Description'], items.map(item => [
1001
+ item.action,
1002
+ `${item.file}${item.line ? `:${item.line}` : ''}`,
1003
+ item.description,
1004
+ ]), [60, 180, 255]);
1005
+ }
1006
+ // ─────────────────────────────────────────────────────────────────
1007
+ // Previously Resolved Issues
1008
+ // ─────────────────────────────────────────────────────────────────
1009
+ renderResolvedIssues(report) {
1010
+ this.newPage();
1011
+ this.y = spacing.page.top;
1012
+ this.sectionTitle('Previously Resolved Issues');
1013
+ report.resolvedIssues?.forEach(issue => {
1014
+ this.checkPageBreak(50);
1015
+ const style = colors.severity[issue.severity];
1016
+ // Checkmark + ID + Title
1017
+ this.doc
1018
+ .font(fonts.weights.bold)
1019
+ .fontSize(fonts.sizes.body)
1020
+ .fillColor(colors.severity.low.text)
1021
+ .text('OK', spacing.page.margin, this.y, { continued: true })
1022
+ .fillColor(style.text)
1023
+ .text(` [${issue.id}]`, { continued: true })
1024
+ .fillColor(colors.text.primary)
1025
+ .text(` ${issue.title}`);
1026
+ this.y += 16;
1027
+ this.doc
1028
+ .font(fonts.weights.normal)
1029
+ .fontSize(fonts.sizes.small)
1030
+ .fillColor(colors.text.secondary)
1031
+ .text(`Resolution: ${issue.resolution}`, spacing.page.margin + 30, this.y);
1032
+ this.y += 20;
1033
+ });
1034
+ }
1035
+ // ─────────────────────────────────────────────────────────────────
1036
+ // Privacy Analysis (LINDDUN)
1037
+ // ─────────────────────────────────────────────────────────────────
1038
+ renderPrivacyAnalysis(report) {
1039
+ this.newPage();
1040
+ this.y = spacing.page.top;
1041
+ this.sectionTitle('Privacy Analysis (LINDDUN)');
1042
+ this.renderSimpleTable(['Category', 'Risk', 'Description', 'Mitigation'], (report.privacyAnalysis || []).map(p => [
1043
+ p.category,
1044
+ p.risk.toUpperCase(),
1045
+ p.description,
1046
+ p.mitigation || '-',
1047
+ ]), [90, 50, 200, 155]);
1048
+ }
1049
+ // ─────────────────────────────────────────────────────────────────
1050
+ // Summary Page
1051
+ // ─────────────────────────────────────────────────────────────────
1052
+ renderSummaryPage(report) {
1053
+ this.newPage();
1054
+ this.y = spacing.page.top;
1055
+ this.sectionTitle('Summary');
1056
+ // Findings by severity table
1057
+ this.subTitle('Findings by Severity');
1058
+ this.renderSimpleTable(['Severity', 'Count', 'Open', 'Partial', 'Resolved'], this.getSeveritySummary(report), [100, 80, 80, 80, 155]);
1059
+ this.y += spacing.section;
1060
+ // Risk by category table
1061
+ if (report.riskMatrix?.length) {
1062
+ this.subTitle('Risk by Category');
1063
+ this.renderSimpleTable(['Category', 'Current', 'After Fixes', 'Trend'], report.riskMatrix.map(r => [
1064
+ r.category,
1065
+ r.currentRisk.toUpperCase(),
1066
+ r.residualRisk.toUpperCase(),
1067
+ this.getTrendIndicator(r.trend),
1068
+ ]), [150, 100, 100, 145]);
1069
+ }
1070
+ this.y += spacing.section;
1071
+ // Key metrics
1072
+ this.subTitle('Key Metrics');
1073
+ const metrics = [
1074
+ ['Total Findings', report.summary.total.toString()],
1075
+ ['Critical + High', (report.summary.critical + report.summary.high).toString()],
1076
+ ['Attack Chains', (report.attackChains?.length || 0).toString()],
1077
+ ['Compliance Frameworks', (report.complianceMapping?.length || 0).toString()],
1078
+ ];
1079
+ const startX = spacing.page.margin;
1080
+ const boxWidth = 120;
1081
+ const boxHeight = 50;
1082
+ const gap = 10;
1083
+ metrics.forEach((metric, i) => {
1084
+ const x = startX + (i * (boxWidth + gap));
1085
+ this.doc
1086
+ .rect(x, this.y, boxWidth, boxHeight)
1087
+ .stroke(colors.border);
1088
+ this.doc
1089
+ .font(fonts.weights.bold)
1090
+ .fontSize(18)
1091
+ .fillColor(colors.accent)
1092
+ .text(metric[1], x, this.y + 10, { width: boxWidth, align: 'center' });
1093
+ this.doc
1094
+ .font(fonts.weights.normal)
1095
+ .fontSize(fonts.sizes.small)
1096
+ .fillColor(colors.text.secondary)
1097
+ .text(metric[0], x, this.y + 32, { width: boxWidth, align: 'center' });
1098
+ });
1099
+ }
1100
+ getSeveritySummary(report) {
1101
+ const severities = ['critical', 'high', 'medium', 'low'];
1102
+ return severities.map(sev => {
1103
+ const findings = report.findings.filter(f => f.severity === sev);
1104
+ const open = findings.filter(f => f.status === 'open' || !f.status).length;
1105
+ const partial = findings.filter(f => f.status === 'partial').length;
1106
+ const resolved = findings.filter(f => f.status === 'resolved').length;
1107
+ return [
1108
+ sev.toUpperCase(),
1109
+ findings.length.toString(),
1110
+ open.toString(),
1111
+ partial.toString(),
1112
+ resolved.toString(),
1113
+ ];
1114
+ });
1115
+ }
1116
+ // ─────────────────────────────────────────────────────────────────
1117
+ // Helpers
1118
+ // ─────────────────────────────────────────────────────────────────
1119
+ sectionTitle(title) {
1120
+ this.doc
1121
+ .font(fonts.weights.bold)
1122
+ .fontSize(fonts.sizes.h1)
1123
+ .fillColor(colors.text.primary)
1124
+ .text(title, spacing.page.margin, this.y);
1125
+ // Subtle underline
1126
+ this.y += 28;
1127
+ this.doc
1128
+ .moveTo(spacing.page.margin, this.y)
1129
+ .lineTo(spacing.page.margin + layout.content.width, this.y)
1130
+ .strokeColor(colors.borderLight)
1131
+ .lineWidth(1)
1132
+ .stroke();
1133
+ this.y += spacing.paragraph;
1134
+ }
1135
+ subTitle(title) {
1136
+ this.doc
1137
+ .font(fonts.weights.bold)
1138
+ .fontSize(fonts.sizes.h3)
1139
+ .fillColor(colors.text.secondary)
1140
+ .text(title, spacing.page.margin, this.y);
1141
+ this.y += 20;
1142
+ }
1143
+ renderSimpleTable(headers, rows, colWidths) {
1144
+ const rowHeight = 24;
1145
+ const x = spacing.page.margin;
1146
+ // Header
1147
+ this.doc
1148
+ .rect(x, this.y, layout.content.width, rowHeight)
1149
+ .fill(colors.table.header);
1150
+ let colX = x;
1151
+ headers.forEach((header, i) => {
1152
+ this.doc
1153
+ .font(fonts.weights.bold)
1154
+ .fontSize(fonts.sizes.small)
1155
+ .fillColor(colors.table.headerText)
1156
+ .text(header, colX + 8, this.y + 7, {
1157
+ width: colWidths[i] - 16,
1158
+ });
1159
+ colX += colWidths[i];
1160
+ });
1161
+ this.y += rowHeight;
1162
+ // Rows
1163
+ rows.forEach((row, rowIndex) => {
1164
+ this.checkPageBreak(rowHeight + 20);
1165
+ // Alternating background
1166
+ if (rowIndex % 2 === 1) {
1167
+ this.doc
1168
+ .rect(x, this.y, layout.content.width, rowHeight)
1169
+ .fill(colors.table.altRow);
1170
+ }
1171
+ // Border
1172
+ this.doc
1173
+ .moveTo(x, this.y + rowHeight)
1174
+ .lineTo(x + layout.content.width, this.y + rowHeight)
1175
+ .strokeColor(colors.table.border)
1176
+ .lineWidth(0.5)
1177
+ .stroke();
1178
+ colX = x;
1179
+ row.forEach((cell, i) => {
1180
+ this.doc
1181
+ .font(fonts.weights.normal)
1182
+ .fontSize(fonts.sizes.small)
1183
+ .fillColor(colors.text.primary)
1184
+ .text(cell, colX + 8, this.y + 7, {
1185
+ width: colWidths[i] - 16,
1186
+ ellipsis: true,
1187
+ });
1188
+ colX += colWidths[i];
1189
+ });
1190
+ this.y += rowHeight;
1191
+ });
1192
+ this.y += spacing.paragraph;
1193
+ }
1194
+ checkPageBreak(requiredSpace = 100) {
1195
+ if (this.y > layout.page.height - spacing.page.bottom - requiredSpace) {
1196
+ this.newPage();
1197
+ }
1198
+ }
1199
+ addPageNumbersAndFooters() {
1200
+ // Get the actual page range from PDFKit's buffer BEFORE adding footers
1201
+ const range = this.doc.bufferedPageRange();
1202
+ const totalPages = range.count;
1203
+ for (let i = 0; i < totalPages; i++) {
1204
+ this.doc.switchToPage(i);
1205
+ // Save the current position and set a safe position for footer
1206
+ // This prevents PDFKit from creating new pages when adding text
1207
+ this.doc.save();
1208
+ // Move to footer position without triggering page creation
1209
+ this.doc.x = spacing.page.margin;
1210
+ this.doc.y = layout.page.height - 30;
1211
+ // Add CONFIDENTIAL footer on left using simple text without width constraints
1212
+ this.doc
1213
+ .font(fonts.weights.normal)
1214
+ .fontSize(fonts.sizes.caption)
1215
+ .fillColor(colors.text.muted);
1216
+ // Use _fragment to add text without triggering page breaks
1217
+ this.doc.text('CONFIDENTIAL', spacing.page.margin, layout.page.height - 30, {
1218
+ lineBreak: false
1219
+ });
1220
+ // Add page number on right - position it manually
1221
+ const pageNumText = `${i + 1}`;
1222
+ const pageNumWidth = this.doc.widthOfString(pageNumText);
1223
+ this.doc.text(pageNumText, layout.page.width - spacing.page.margin - pageNumWidth, layout.page.height - 30, { lineBreak: false });
1224
+ this.doc.restore();
1225
+ }
1226
+ // Switch back to the last real page
1227
+ if (totalPages > 0) {
1228
+ this.doc.switchToPage(totalPages - 1);
1229
+ }
1230
+ }
1231
+ }
1232
+ export async function generatePDF(report, outputPath) {
1233
+ const generator = new PDFGenerator();
1234
+ await generator.generate(report, outputPath);
1235
+ }