Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2609,12 +2609,15 @@ paths:
summary: Download a rendered face of a report (PDF, JSON, CSV, or OSCAL SAR)
description: |
Streams a rendered face of the report as a downloadable
attachment. format=pdf (default, executive only) renders the
bounded one-page executive PDF (a fixed posture block, the coverage
caveat, and the top-failing list - never the full per-host/per-rule
evidence); format=json returns the canonical frozen posture
content (any kind). For an attestation report, format=csv returns
the per-(host, rule) evidence extract and format=oscal_sar returns a
attachment. format=pdf (default) renders the bounded one-page PDF,
dispatched by kind: the executive summary (a fixed posture block,
the coverage caveat, and the top-failing list) or the framework
attestation cover (a methodology note, the aggregate coverage +
framework rollup, a sampled top-failing list, and a content-hash
pointer to the bulk faces) - never the full per-host/per-rule
evidence. format=json returns the canonical frozen posture content
(any kind). For an attestation report, format=csv returns the
per-(host, rule) evidence extract and format=oscal_sar returns a
single OSCAL 1.0.6 assessment-results document (one observation +
finding per (host, rule), evidence referenced by sha256 in
back-matter, not inlined). A face that does not apply to the
Expand Down
36 changes: 27 additions & 9 deletions docs/engineering/reports_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,18 +540,36 @@ v1.8.0 (C-14 / AC-20). True streaming to a separate blob store is deferred
(the in-memory + row-cap + `report_faces.content` pattern matches the CSV
face).

**B3 — Async generation + report.ready.** Fleet attestation generation
(the bulk query + SAR/CSV render) moves to the job queue: a
**B3b — Bounded attestation PDF face.** *(SHIPPED 2026-06-21, PR #644.)*
The `pdf` face is now KIND-DISPATCHED (`internal/report/export.go`): an
executive report renders the executive summary PDF, an attestation report
renders a bounded one-page cover (`renderAttestationPDF` in `pdf.go`) —
methodology note, aggregate attestation coverage + framework rollup
(compliance %, checks evaluated, pass/fail/skipped/error), a SAMPLED
top-failing list, and a footer carrying the snapshot content hash + signing
status as the pointer to the bulk faces. The rollup is O(1) in fleet size
(aggregate `count(*) FILTER` over the frozen scans + a top-N grouped query,
framework-lensed), so the PDF stays bounded. Cached in `report_faces` (face
`pdf`) like the others. Spec: `api-reports` v1.9.0 (C-15 / AC-21; C-10
updated: pdf kind-dispatched, not executive-only).

**B3a — Async generation + report.ready.** *(REMAINING.)* Fleet attestation
generation (the bulk query + SAR/CSV/PDF render) moves to the job queue: a
`FleetReportJobType` + payload + a worker processor that flips
`report_faces` status `pending → ready` and publishes
`EventKindReportReady` on the event bus — **the in-app notification bell's
first producer** (closes that coupling). The bounded **PDF attestation**
face (cover + methodology + framework rollup + SAMPLED findings + a hash
pointer to the SAR/CSV bundle) lands here. Spec: `system-report-faces`
first producer** (closes that coupling). Spec: `system-report-faces`
(async + status), eventbus types.

**B3c — Notification bell (frontend).** *(REMAINING; needs product input.)*
Turn the stubbed TopBar bell into a real consumer of `report.ready` (and
later other events) over SSE — unread state, what the feed shows, whether
notifications persist. This is the product-design surface of B3 and is
held for a direction decision rather than guessed.

### Recommended order
B0 → B1 → B2 → B3. B0 is a quick win that unblocks both attestation
scoping and the deferred A1 framework picker; B1/B2 build the bulk faces;
B3 makes generation async and wires the "ready" signal (the notification
bell's first producer).
B0 → B1 → B2 → B3b → B3a → B3c. B0 unblocks attestation scoping + the
deferred A1 framework picker; B1/B2/B3b build the three bulk/cover faces
(CSV, OSCAL SAR, PDF); B3a makes generation async and emits the "ready"
signal; B3c surfaces it in the notification bell (the product-sensitive
slice, sequenced last).
133 changes: 131 additions & 2 deletions internal/report/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ func (s *Service) Export(ctx context.Context, id uuid.UUID, face string) ([]byte
case FaceJSON:
return s.canonicalJSON(rep)
case FacePDF:
if rep.Kind != KindExecutive {
// PDF is the bounded human narrative for BOTH kinds, dispatched by
// kind: the executive summary or the framework attestation cover.
switch rep.Kind {
case KindExecutive:
return s.exportPDF(ctx, rep)
case KindAttestation:
return s.exportAttestationPDF(ctx, rep)
default:
return nil, "", ErrInvalidFace
}
return s.exportPDF(ctx, rep)
case FaceCSV:
if rep.Kind != KindAttestation {
return nil, "", ErrInvalidFace
Expand Down Expand Up @@ -306,6 +312,129 @@ func (s *Service) exportAttestationCSV(ctx context.Context, rep Report) ([]byte,
return csvBytes, mediaType, nil
}

// attestationRollup is the bounded aggregate the attestation PDF renders:
// pass/fail/total counts (and a sampled top-failing list) computed from the
// frozen scans' scan_results, never the per-(host, rule) rows themselves.
type attestationRollup struct {
TotalChecks int
Pass int
Fail int
Skipped int
Errored int
CompliancePct *int
TopFailing []TopFailingRule
}

// exportAttestationPDF returns the cached attestation PDF face if present,
// else computes the bounded rollup from the frozen scans (aggregate
// queries scoped by the snapshot's framework lens), renders the one-page
// cover via renderAttestationPDF, caches it in report_faces, and returns
// it. The rollup is O(1) in fleet size (aggregates + a small top-N), so
// the PDF stays bounded regardless of host/rule count.
func (s *Service) exportAttestationPDF(ctx context.Context, rep Report) ([]byte, string, error) {
const mediaType = "application/pdf"

var cached []byte
err := s.pool.QueryRow(ctx,
`SELECT content FROM report_faces WHERE snapshot_id = $1 AND face = $2 AND status = 'ready'`,
rep.ID, FacePDF).Scan(&cached)
if err == nil && len(cached) > 0 {
return cached, mediaType, nil
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, "", fmt.Errorf("report: attestation pdf face lookup: %w", err)
}

var c AttestationContent
if err := json.Unmarshal(rep.Content, &c); err != nil {
return nil, "", fmt.Errorf("report: decode attestation content: %w", err)
}
rollup, err := s.computeAttestationRollup(ctx, c)
if err != nil {
return nil, "", err
}
pdfBytes, err := renderAttestationPDF(rep, c, rollup)
if err != nil {
return nil, "", err
}
sum := sha256.Sum256(pdfBytes)
blobSHA := hex.EncodeToString(sum[:])
_, err = s.pool.Exec(ctx, `
INSERT INTO report_faces (snapshot_id, face, media_type, content, size_bytes, blob_sha256, status)
VALUES ($1, $2, $3, $4, $5, $6, 'ready')
ON CONFLICT (snapshot_id, face)
DO UPDATE SET content = EXCLUDED.content, media_type = EXCLUDED.media_type,
size_bytes = EXCLUDED.size_bytes, blob_sha256 = EXCLUDED.blob_sha256,
status = 'ready'`,
rep.ID, FacePDF, mediaType, pdfBytes, len(pdfBytes), blobSHA)
if err != nil {
// A cache write failure should not fail the download.
return pdfBytes, mediaType, nil
}
return pdfBytes, mediaType, nil
}

// computeAttestationRollup runs two aggregate queries over the frozen
// scans (counts by status, and the top failing rules by distinct failing
// host), applying the snapshot's framework lens. Compliance is passing /
// (passing + failing), rounded half up, nil when nothing was evaluated.
func (s *Service) computeAttestationRollup(ctx context.Context, c AttestationContent) (attestationRollup, error) {
scanIDs := make([]uuid.UUID, len(c.Attested))
for i, a := range c.Attested {
scanIDs[i] = a.ScanID
}

var r attestationRollup
countQ := `
SELECT count(*),
count(*) FILTER (WHERE status = 'pass'),
count(*) FILTER (WHERE status = 'fail'),
count(*) FILTER (WHERE status = 'skipped'),
count(*) FILTER (WHERE status = 'error')
FROM scan_results sr
WHERE sr.scan_id = ANY($1)`
countArgs := []any{scanIDs}
if c.Framework != "" {
countQ += " AND sr.framework_refs ? $2"
countArgs = append(countArgs, c.Framework)
}
if err := s.pool.QueryRow(ctx, countQ, countArgs...).
Scan(&r.TotalChecks, &r.Pass, &r.Fail, &r.Skipped, &r.Errored); err != nil {
return attestationRollup{}, fmt.Errorf("report: attestation rollup counts: %w", err)
}
if evaluated := r.Pass + r.Fail; evaluated > 0 {
pct := int((float64(r.Pass)/float64(evaluated))*100 + 0.5)
r.CompliancePct = &pct
}

topQ := `
SELECT sr.rule_id, count(DISTINCT sr.host_id)
FROM scan_results sr
WHERE sr.scan_id = ANY($1) AND sr.status = 'fail'`
topArgs := []any{scanIDs}
if c.Framework != "" {
topQ += " AND sr.framework_refs ? $2"
topArgs = append(topArgs, c.Framework)
}
topQ += " GROUP BY sr.rule_id ORDER BY count(DISTINCT sr.host_id) DESC, sr.rule_id LIMIT 10"
rows, err := s.pool.Query(ctx, topQ, topArgs...)
if err != nil {
return attestationRollup{}, fmt.Errorf("report: attestation rollup top-failing: %w", err)
}
defer rows.Close()
for rows.Next() {
var t TopFailingRule
if err := rows.Scan(&t.RuleID, &t.FailingHostCount); err != nil {
return attestationRollup{}, fmt.Errorf("report: attestation rollup scan: %w", err)
}
r.TopFailing = append(r.TopFailing, t)
}
if err := rows.Err(); err != nil {
return attestationRollup{}, fmt.Errorf("report: attestation rollup iterate: %w", err)
}
return r, nil
}

// csvSafe neutralizes spreadsheet formula injection (CWE-1236): a cell
// whose first byte is = + - @ tab or CR is prefixed with a single quote so
// it renders as literal text. (Mirrors the audit export's guard; a shared
Expand Down
123 changes: 123 additions & 0 deletions internal/report/pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,129 @@ func renderExecutivePDF(rep Report, c ExecutiveContent) ([]byte, error) {
return buf.Bytes(), nil
}

// renderAttestationPDF renders the bounded human narrative for a Framework
// Attestation: a one-page A4 cover document. Like the executive PDF its
// page count is O(1) - a methodology note, an aggregate framework rollup
// (pass/fail/total counts computed from the frozen scans, never the
// per-host/rule rows), a small sampled top-failing list, and a footer that
// POINTS to the full machine-readable bundle by content hash. The bulk
// evidence lives in the CSV and OSCAL SAR faces; the PDF is the signed,
// auditor-facing summary that references them.
func renderAttestationPDF(rep Report, c AttestationContent, r attestationRollup) ([]byte, error) {
pdf := fpdf.New("P", "mm", "A4", "")
pdf.SetTitle(rep.Title, true)
pdf.SetMargins(18, 18, 18)
pdf.SetAutoPageBreak(true, 18)
pdf.AddPage()
tr := pdf.UnicodeTranslatorFromDescriptor("")

const (
ink = 0x1a
mut = 0x6a
crit = 0xc0
line = 0xcc
)

// Header.
pdf.SetTextColor(ink, ink, ink)
pdf.SetFont("Helvetica", "B", 16)
pdf.CellFormat(0, 9, rep.Title, "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(mut, mut, mut)
lens := c.Framework
if lens == "" {
lens = "All frameworks"
}
meta := fmt.Sprintf("OpenWatch · Data as of %s · Scope: %s · Framework: %s · Generated by %s",
rep.DataAsOf.Format("2006-01-02 15:04 MST"), rep.ScopeLabel, lens, rep.GeneratedBy)
pdf.MultiCell(0, 6, tr(meta), "", "L", false)
pdf.Ln(2)
pdf.SetDrawColor(line, line, line)
y := pdf.GetY()
pdf.Line(18, y, 192, y)
pdf.Ln(4)

// Methodology - what this attestation is and is not.
sectionHead(pdf, "Methodology")
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(mut, mut, mut)
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)
pdf.Ln(4)

// Attestation coverage + framework rollup (aggregates only).
sectionHead(pdf, "Attestation coverage")
stat(pdf, "Hosts attested", fmt.Sprintf("%d of %d in scope", c.HostsAttested, c.HostsTotal))
pct := "n/a"
if r.CompliancePct != nil {
pct = fmt.Sprintf("%d%%", *r.CompliancePct)
}
stat(pdf, "Compliance", pct)
stat(pdf, "Checks evaluated", fmt.Sprintf("%d", r.TotalChecks))
stat(pdf, "Passing", fmt.Sprintf("%d", r.Pass))
stat(pdf, "Failing", fmt.Sprintf("%d", r.Fail))
stat(pdf, "Skipped / error", fmt.Sprintf("%d / %d", r.Skipped, r.Errored))
pdf.Ln(2)

// Honesty caveat: not every in-scope host could be attested.
if c.HostsAttested < c.HostsTotal {
pdf.SetTextColor(crit, 0x80, 0x10)
pdf.SetFont("Helvetica", "B", 9)
pdf.MultiCell(0, 5, fmt.Sprintf("Coverage: %d of %d in-scope hosts have no completed scan and are not attested here.",
c.HostsTotal-c.HostsAttested, c.HostsTotal), "", "L", false)
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(mut, mut, mut)
pdf.Ln(2)
}

// Sampled top failing rules (bounded list, not the full set).
sectionHead(pdf, "Top failing rules (sampled)")
if len(r.TopFailing) == 0 {
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(mut, mut, mut)
pdf.CellFormat(0, 6, "No failing rules in scope.", "", 1, "L", false, 0, "")
} else {
pdf.SetFont("Helvetica", "B", 8)
pdf.SetTextColor(mut, mut, mut)
pdf.CellFormat(120, 6, "RULE", "", 0, "L", false, 0, "")
pdf.CellFormat(0, 6, "FAILING HOSTS", "", 1, "R", false, 0, "")
pdf.SetFont("Helvetica", "", 10)
pdf.SetTextColor(ink, ink, ink)
for _, t := range r.TopFailing {
pdf.CellFormat(120, 6, t.RuleID, "", 0, "L", false, 0, "")
pdf.CellFormat(0, 6, fmt.Sprintf("%d", t.FailingHostCount), "", 1, "R", false, 0, "")
}
}

// Footer: the content address + signing status. The attestation's
// content hash is the pointer auditors use to tie this summary to the
// signed snapshot and its bulk faces.
pdf.SetAutoPageBreak(false, 0)
pdf.SetY(-20)
pdf.SetDrawColor(line, line, line)
yf := pdf.GetY()
pdf.Line(18, yf, 192, yf)
pdf.Ln(2)
pdf.SetFont("Helvetica", "", 7)
pdf.SetTextColor(mut, mut, mut)
short := rep.ContentSHA256
if len(short) > 16 {
short = short[:16]
}
signed := "not signed"
if len(rep.Signature) > 0 {
signed = "signed " + rep.SigningKeyID
}
foot := fmt.Sprintf("OpenWatch framework attestation · content %s… · %s · %s",
short, signed, time.Now().UTC().Format("2006-01-02"))
pdf.CellFormat(0, 5, tr(foot), "", 0, "L", false, 0, "")

var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("report: render attestation pdf: %w", err)
}
return buf.Bytes(), nil
}

func sectionHead(pdf *fpdf.Fpdf, s string) {
pdf.SetFont("Helvetica", "B", 8)
pdf.SetTextColor(0x6a, 0x6a, 0x6a)
Expand Down
Loading
Loading