Skip to content

Commit b6b880d

Browse files
feat(reports): bounded attestation PDF cover face (B3b) (#644)
Make the pdf face KIND-DISPATCHED: an executive report still renders the executive summary PDF; an attestation report now renders a bounded one-page cover (renderAttestationPDF). The attestation cover is O(1) pages regardless of fleet size: - A methodology note (point-in-time, full evidence in the CSV/OSCAL faces). - Aggregate attestation coverage (hosts attested of in-scope) + a framework rollup (compliance %, checks evaluated, pass/fail/skipped/ error), computed by aggregate count(*) FILTER queries over the frozen scans, framework-lensed. - A SAMPLED top-failing list (rules failing on the most hosts, capped at 10) from a grouped distinct-host query. - A footer carrying the snapshot content hash + signing status as the pointer to the bulk faces. Never the per-(host, rule) rows. A host with no completed scan is disclosed as not attested. Rendered via the pure-Go go-pdf/fpdf core fonts (airgap-safe) and cached in report_faces (face pdf) like the other faces. Spec api-reports v1.9.0: C-15 + AC-21 (rollup correctness over a 2-host mix + the %PDF face + caching); C-10 updated (pdf is kind-dispatched, not executive-only); AC-19 wording updated (pdf valid for both kinds).
1 parent 6745761 commit b6b880d

7 files changed

Lines changed: 533 additions & 133 deletions

File tree

api/openapi.yaml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2609,12 +2609,15 @@ paths:
26092609
summary: Download a rendered face of a report (PDF, JSON, CSV, or OSCAL SAR)
26102610
description: |
26112611
Streams a rendered face of the report as a downloadable
2612-
attachment. format=pdf (default, executive only) renders the
2613-
bounded one-page executive PDF (a fixed posture block, the coverage
2614-
caveat, and the top-failing list - never the full per-host/per-rule
2615-
evidence); format=json returns the canonical frozen posture
2616-
content (any kind). For an attestation report, format=csv returns
2617-
the per-(host, rule) evidence extract and format=oscal_sar returns a
2612+
attachment. format=pdf (default) renders the bounded one-page PDF,
2613+
dispatched by kind: the executive summary (a fixed posture block,
2614+
the coverage caveat, and the top-failing list) or the framework
2615+
attestation cover (a methodology note, the aggregate coverage +
2616+
framework rollup, a sampled top-failing list, and a content-hash
2617+
pointer to the bulk faces) - never the full per-host/per-rule
2618+
evidence. format=json returns the canonical frozen posture content
2619+
(any kind). For an attestation report, format=csv returns the
2620+
per-(host, rule) evidence extract and format=oscal_sar returns a
26182621
single OSCAL 1.0.6 assessment-results document (one observation +
26192622
finding per (host, rule), evidence referenced by sha256 in
26202623
back-matter, not inlined). A face that does not apply to the

docs/engineering/reports_design.md

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -540,18 +540,36 @@ v1.8.0 (C-14 / AC-20). True streaming to a separate blob store is deferred
540540
(the in-memory + row-cap + `report_faces.content` pattern matches the CSV
541541
face).
542542

543-
**B3 — Async generation + report.ready.** Fleet attestation generation
544-
(the bulk query + SAR/CSV render) moves to the job queue: a
543+
**B3b — Bounded attestation PDF face.** *(SHIPPED 2026-06-21, PR #644.)*
544+
The `pdf` face is now KIND-DISPATCHED (`internal/report/export.go`): an
545+
executive report renders the executive summary PDF, an attestation report
546+
renders a bounded one-page cover (`renderAttestationPDF` in `pdf.go`) —
547+
methodology note, aggregate attestation coverage + framework rollup
548+
(compliance %, checks evaluated, pass/fail/skipped/error), a SAMPLED
549+
top-failing list, and a footer carrying the snapshot content hash + signing
550+
status as the pointer to the bulk faces. The rollup is O(1) in fleet size
551+
(aggregate `count(*) FILTER` over the frozen scans + a top-N grouped query,
552+
framework-lensed), so the PDF stays bounded. Cached in `report_faces` (face
553+
`pdf`) like the others. Spec: `api-reports` v1.9.0 (C-15 / AC-21; C-10
554+
updated: pdf kind-dispatched, not executive-only).
555+
556+
**B3a — Async generation + report.ready.** *(REMAINING.)* Fleet attestation
557+
generation (the bulk query + SAR/CSV/PDF render) moves to the job queue: a
545558
`FleetReportJobType` + payload + a worker processor that flips
546559
`report_faces` status `pending → ready` and publishes
547560
`EventKindReportReady` on the event bus — **the in-app notification bell's
548-
first producer** (closes that coupling). The bounded **PDF attestation**
549-
face (cover + methodology + framework rollup + SAMPLED findings + a hash
550-
pointer to the SAR/CSV bundle) lands here. Spec: `system-report-faces`
561+
first producer** (closes that coupling). Spec: `system-report-faces`
551562
(async + status), eventbus types.
552563

564+
**B3c — Notification bell (frontend).** *(REMAINING; needs product input.)*
565+
Turn the stubbed TopBar bell into a real consumer of `report.ready` (and
566+
later other events) over SSE — unread state, what the feed shows, whether
567+
notifications persist. This is the product-design surface of B3 and is
568+
held for a direction decision rather than guessed.
569+
553570
### Recommended order
554-
B0 → B1 → B2 → B3. B0 is a quick win that unblocks both attestation
555-
scoping and the deferred A1 framework picker; B1/B2 build the bulk faces;
556-
B3 makes generation async and wires the "ready" signal (the notification
557-
bell's first producer).
571+
B0 → B1 → B2 → B3b → B3a → B3c. B0 unblocks attestation scoping + the
572+
deferred A1 framework picker; B1/B2/B3b build the three bulk/cover faces
573+
(CSV, OSCAL SAR, PDF); B3a makes generation async and emits the "ready"
574+
signal; B3c surfaces it in the notification bell (the product-sensitive
575+
slice, sequenced last).

internal/report/export.go

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,16 @@ func (s *Service) Export(ctx context.Context, id uuid.UUID, face string) ([]byte
5252
case FaceJSON:
5353
return s.canonicalJSON(rep)
5454
case FacePDF:
55-
if rep.Kind != KindExecutive {
55+
// PDF is the bounded human narrative for BOTH kinds, dispatched by
56+
// kind: the executive summary or the framework attestation cover.
57+
switch rep.Kind {
58+
case KindExecutive:
59+
return s.exportPDF(ctx, rep)
60+
case KindAttestation:
61+
return s.exportAttestationPDF(ctx, rep)
62+
default:
5663
return nil, "", ErrInvalidFace
5764
}
58-
return s.exportPDF(ctx, rep)
5965
case FaceCSV:
6066
if rep.Kind != KindAttestation {
6167
return nil, "", ErrInvalidFace
@@ -306,6 +312,129 @@ func (s *Service) exportAttestationCSV(ctx context.Context, rep Report) ([]byte,
306312
return csvBytes, mediaType, nil
307313
}
308314

315+
// attestationRollup is the bounded aggregate the attestation PDF renders:
316+
// pass/fail/total counts (and a sampled top-failing list) computed from the
317+
// frozen scans' scan_results, never the per-(host, rule) rows themselves.
318+
type attestationRollup struct {
319+
TotalChecks int
320+
Pass int
321+
Fail int
322+
Skipped int
323+
Errored int
324+
CompliancePct *int
325+
TopFailing []TopFailingRule
326+
}
327+
328+
// exportAttestationPDF returns the cached attestation PDF face if present,
329+
// else computes the bounded rollup from the frozen scans (aggregate
330+
// queries scoped by the snapshot's framework lens), renders the one-page
331+
// cover via renderAttestationPDF, caches it in report_faces, and returns
332+
// it. The rollup is O(1) in fleet size (aggregates + a small top-N), so
333+
// the PDF stays bounded regardless of host/rule count.
334+
func (s *Service) exportAttestationPDF(ctx context.Context, rep Report) ([]byte, string, error) {
335+
const mediaType = "application/pdf"
336+
337+
var cached []byte
338+
err := s.pool.QueryRow(ctx,
339+
`SELECT content FROM report_faces WHERE snapshot_id = $1 AND face = $2 AND status = 'ready'`,
340+
rep.ID, FacePDF).Scan(&cached)
341+
if err == nil && len(cached) > 0 {
342+
return cached, mediaType, nil
343+
}
344+
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
345+
return nil, "", fmt.Errorf("report: attestation pdf face lookup: %w", err)
346+
}
347+
348+
var c AttestationContent
349+
if err := json.Unmarshal(rep.Content, &c); err != nil {
350+
return nil, "", fmt.Errorf("report: decode attestation content: %w", err)
351+
}
352+
rollup, err := s.computeAttestationRollup(ctx, c)
353+
if err != nil {
354+
return nil, "", err
355+
}
356+
pdfBytes, err := renderAttestationPDF(rep, c, rollup)
357+
if err != nil {
358+
return nil, "", err
359+
}
360+
sum := sha256.Sum256(pdfBytes)
361+
blobSHA := hex.EncodeToString(sum[:])
362+
_, err = s.pool.Exec(ctx, `
363+
INSERT INTO report_faces (snapshot_id, face, media_type, content, size_bytes, blob_sha256, status)
364+
VALUES ($1, $2, $3, $4, $5, $6, 'ready')
365+
ON CONFLICT (snapshot_id, face)
366+
DO UPDATE SET content = EXCLUDED.content, media_type = EXCLUDED.media_type,
367+
size_bytes = EXCLUDED.size_bytes, blob_sha256 = EXCLUDED.blob_sha256,
368+
status = 'ready'`,
369+
rep.ID, FacePDF, mediaType, pdfBytes, len(pdfBytes), blobSHA)
370+
if err != nil {
371+
// A cache write failure should not fail the download.
372+
return pdfBytes, mediaType, nil
373+
}
374+
return pdfBytes, mediaType, nil
375+
}
376+
377+
// computeAttestationRollup runs two aggregate queries over the frozen
378+
// scans (counts by status, and the top failing rules by distinct failing
379+
// host), applying the snapshot's framework lens. Compliance is passing /
380+
// (passing + failing), rounded half up, nil when nothing was evaluated.
381+
func (s *Service) computeAttestationRollup(ctx context.Context, c AttestationContent) (attestationRollup, error) {
382+
scanIDs := make([]uuid.UUID, len(c.Attested))
383+
for i, a := range c.Attested {
384+
scanIDs[i] = a.ScanID
385+
}
386+
387+
var r attestationRollup
388+
countQ := `
389+
SELECT count(*),
390+
count(*) FILTER (WHERE status = 'pass'),
391+
count(*) FILTER (WHERE status = 'fail'),
392+
count(*) FILTER (WHERE status = 'skipped'),
393+
count(*) FILTER (WHERE status = 'error')
394+
FROM scan_results sr
395+
WHERE sr.scan_id = ANY($1)`
396+
countArgs := []any{scanIDs}
397+
if c.Framework != "" {
398+
countQ += " AND sr.framework_refs ? $2"
399+
countArgs = append(countArgs, c.Framework)
400+
}
401+
if err := s.pool.QueryRow(ctx, countQ, countArgs...).
402+
Scan(&r.TotalChecks, &r.Pass, &r.Fail, &r.Skipped, &r.Errored); err != nil {
403+
return attestationRollup{}, fmt.Errorf("report: attestation rollup counts: %w", err)
404+
}
405+
if evaluated := r.Pass + r.Fail; evaluated > 0 {
406+
pct := int((float64(r.Pass)/float64(evaluated))*100 + 0.5)
407+
r.CompliancePct = &pct
408+
}
409+
410+
topQ := `
411+
SELECT sr.rule_id, count(DISTINCT sr.host_id)
412+
FROM scan_results sr
413+
WHERE sr.scan_id = ANY($1) AND sr.status = 'fail'`
414+
topArgs := []any{scanIDs}
415+
if c.Framework != "" {
416+
topQ += " AND sr.framework_refs ? $2"
417+
topArgs = append(topArgs, c.Framework)
418+
}
419+
topQ += " GROUP BY sr.rule_id ORDER BY count(DISTINCT sr.host_id) DESC, sr.rule_id LIMIT 10"
420+
rows, err := s.pool.Query(ctx, topQ, topArgs...)
421+
if err != nil {
422+
return attestationRollup{}, fmt.Errorf("report: attestation rollup top-failing: %w", err)
423+
}
424+
defer rows.Close()
425+
for rows.Next() {
426+
var t TopFailingRule
427+
if err := rows.Scan(&t.RuleID, &t.FailingHostCount); err != nil {
428+
return attestationRollup{}, fmt.Errorf("report: attestation rollup scan: %w", err)
429+
}
430+
r.TopFailing = append(r.TopFailing, t)
431+
}
432+
if err := rows.Err(); err != nil {
433+
return attestationRollup{}, fmt.Errorf("report: attestation rollup iterate: %w", err)
434+
}
435+
return r, nil
436+
}
437+
309438
// csvSafe neutralizes spreadsheet formula injection (CWE-1236): a cell
310439
// whose first byte is = + - @ tab or CR is prefixed with a single quote so
311440
// it renders as literal text. (Mirrors the audit export's guard; a shared

internal/report/pdf.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,129 @@ func renderExecutivePDF(rep Report, c ExecutiveContent) ([]byte, error) {
136136
return buf.Bytes(), nil
137137
}
138138

139+
// renderAttestationPDF renders the bounded human narrative for a Framework
140+
// Attestation: a one-page A4 cover document. Like the executive PDF its
141+
// page count is O(1) - a methodology note, an aggregate framework rollup
142+
// (pass/fail/total counts computed from the frozen scans, never the
143+
// per-host/rule rows), a small sampled top-failing list, and a footer that
144+
// POINTS to the full machine-readable bundle by content hash. The bulk
145+
// evidence lives in the CSV and OSCAL SAR faces; the PDF is the signed,
146+
// auditor-facing summary that references them.
147+
func renderAttestationPDF(rep Report, c AttestationContent, r attestationRollup) ([]byte, error) {
148+
pdf := fpdf.New("P", "mm", "A4", "")
149+
pdf.SetTitle(rep.Title, true)
150+
pdf.SetMargins(18, 18, 18)
151+
pdf.SetAutoPageBreak(true, 18)
152+
pdf.AddPage()
153+
tr := pdf.UnicodeTranslatorFromDescriptor("")
154+
155+
const (
156+
ink = 0x1a
157+
mut = 0x6a
158+
crit = 0xc0
159+
line = 0xcc
160+
)
161+
162+
// Header.
163+
pdf.SetTextColor(ink, ink, ink)
164+
pdf.SetFont("Helvetica", "B", 16)
165+
pdf.CellFormat(0, 9, rep.Title, "", 1, "L", false, 0, "")
166+
pdf.SetFont("Helvetica", "", 9)
167+
pdf.SetTextColor(mut, mut, mut)
168+
lens := c.Framework
169+
if lens == "" {
170+
lens = "All frameworks"
171+
}
172+
meta := fmt.Sprintf("OpenWatch · Data as of %s · Scope: %s · Framework: %s · Generated by %s",
173+
rep.DataAsOf.Format("2006-01-02 15:04 MST"), rep.ScopeLabel, lens, rep.GeneratedBy)
174+
pdf.MultiCell(0, 6, tr(meta), "", "L", false)
175+
pdf.Ln(2)
176+
pdf.SetDrawColor(line, line, line)
177+
y := pdf.GetY()
178+
pdf.Line(18, y, 192, y)
179+
pdf.Ln(4)
180+
181+
// Methodology - what this attestation is and is not.
182+
sectionHead(pdf, "Methodology")
183+
pdf.SetFont("Helvetica", "", 9)
184+
pdf.SetTextColor(mut, mut, mut)
185+
pdf.MultiCell(0, 5, tr("This attestation reflects the latest completed compliance scan for each in-scope host as of the data-as-of time above. Figures are point-in-time, not live. Full per-host, per-rule evidence is provided in the accompanying CSV and OSCAL assessment-results faces, referenced by content hash in the footer."), "", "L", false)
186+
pdf.Ln(4)
187+
188+
// Attestation coverage + framework rollup (aggregates only).
189+
sectionHead(pdf, "Attestation coverage")
190+
stat(pdf, "Hosts attested", fmt.Sprintf("%d of %d in scope", c.HostsAttested, c.HostsTotal))
191+
pct := "n/a"
192+
if r.CompliancePct != nil {
193+
pct = fmt.Sprintf("%d%%", *r.CompliancePct)
194+
}
195+
stat(pdf, "Compliance", pct)
196+
stat(pdf, "Checks evaluated", fmt.Sprintf("%d", r.TotalChecks))
197+
stat(pdf, "Passing", fmt.Sprintf("%d", r.Pass))
198+
stat(pdf, "Failing", fmt.Sprintf("%d", r.Fail))
199+
stat(pdf, "Skipped / error", fmt.Sprintf("%d / %d", r.Skipped, r.Errored))
200+
pdf.Ln(2)
201+
202+
// Honesty caveat: not every in-scope host could be attested.
203+
if c.HostsAttested < c.HostsTotal {
204+
pdf.SetTextColor(crit, 0x80, 0x10)
205+
pdf.SetFont("Helvetica", "B", 9)
206+
pdf.MultiCell(0, 5, fmt.Sprintf("Coverage: %d of %d in-scope hosts have no completed scan and are not attested here.",
207+
c.HostsTotal-c.HostsAttested, c.HostsTotal), "", "L", false)
208+
pdf.SetFont("Helvetica", "", 9)
209+
pdf.SetTextColor(mut, mut, mut)
210+
pdf.Ln(2)
211+
}
212+
213+
// Sampled top failing rules (bounded list, not the full set).
214+
sectionHead(pdf, "Top failing rules (sampled)")
215+
if len(r.TopFailing) == 0 {
216+
pdf.SetFont("Helvetica", "", 9)
217+
pdf.SetTextColor(mut, mut, mut)
218+
pdf.CellFormat(0, 6, "No failing rules in scope.", "", 1, "L", false, 0, "")
219+
} else {
220+
pdf.SetFont("Helvetica", "B", 8)
221+
pdf.SetTextColor(mut, mut, mut)
222+
pdf.CellFormat(120, 6, "RULE", "", 0, "L", false, 0, "")
223+
pdf.CellFormat(0, 6, "FAILING HOSTS", "", 1, "R", false, 0, "")
224+
pdf.SetFont("Helvetica", "", 10)
225+
pdf.SetTextColor(ink, ink, ink)
226+
for _, t := range r.TopFailing {
227+
pdf.CellFormat(120, 6, t.RuleID, "", 0, "L", false, 0, "")
228+
pdf.CellFormat(0, 6, fmt.Sprintf("%d", t.FailingHostCount), "", 1, "R", false, 0, "")
229+
}
230+
}
231+
232+
// Footer: the content address + signing status. The attestation's
233+
// content hash is the pointer auditors use to tie this summary to the
234+
// signed snapshot and its bulk faces.
235+
pdf.SetAutoPageBreak(false, 0)
236+
pdf.SetY(-20)
237+
pdf.SetDrawColor(line, line, line)
238+
yf := pdf.GetY()
239+
pdf.Line(18, yf, 192, yf)
240+
pdf.Ln(2)
241+
pdf.SetFont("Helvetica", "", 7)
242+
pdf.SetTextColor(mut, mut, mut)
243+
short := rep.ContentSHA256
244+
if len(short) > 16 {
245+
short = short[:16]
246+
}
247+
signed := "not signed"
248+
if len(rep.Signature) > 0 {
249+
signed = "signed " + rep.SigningKeyID
250+
}
251+
foot := fmt.Sprintf("OpenWatch framework attestation · content %s… · %s · %s",
252+
short, signed, time.Now().UTC().Format("2006-01-02"))
253+
pdf.CellFormat(0, 5, tr(foot), "", 0, "L", false, 0, "")
254+
255+
var buf bytes.Buffer
256+
if err := pdf.Output(&buf); err != nil {
257+
return nil, fmt.Errorf("report: render attestation pdf: %w", err)
258+
}
259+
return buf.Bytes(), nil
260+
}
261+
139262
func sectionHead(pdf *fpdf.Fpdf, s string) {
140263
pdf.SetFont("Helvetica", "B", 8)
141264
pdf.SetTextColor(0x6a, 0x6a, 0x6a)

0 commit comments

Comments
 (0)