Skip to content

Commit 8630aba

Browse files
committed
fix(reports): kind-aware detail body with a frozen compliance rollup
The report detail body always rendered ExecutiveBody(asExecutiveContent), regardless of kind. An attestation report's content has a different shape with NONE of the executive keys, so every field defaulted to 0/null and the in-app detail showed zero for everything. Two parts: 1. Freeze the headline compliance rollup into the attestation content. computeAttestation now also computes {compliance_pct, total_checks, passing, failing, skipped, errored, top_failing} once over the frozen scans (framework-lensed) and stores it on AttestationContent.Rollup. Because it is part of the content it is signed + tamper-evident, and the in-app view, the PDF cover, and the signature all read the SAME numbers (P1: one snapshot, identical across every face). The PDF face reads the frozen rollup instead of recomputing (a pre-rollup snapshot is recomputed on the fly for back-compat). 2. Render it. The detail body branches on resolved.kind: an attestation renders a new AttestationBody from asAttestationContent, showing the compliance percent, checks evaluated, passing/failing/skipped/errored, and a top-failing-rules table, plus the framework lens and the attested-of-in-scope coverage. An executive still renders ExecutiveBody. The downloadable PDF/CSV/OSCAL faces were always correct; only the in-app view was affected (and now shows the real numbers). Specs: api-reports v1.11.0 (C-13: rollup frozen in the content, PDF reads it); frontend-reports v1.8.0 (C-11/AC-12: kind-aware body renders the frozen rollup).
1 parent 2ba06d4 commit 8630aba

9 files changed

Lines changed: 346 additions & 63 deletions

File tree

frontend/src/pages/reports/ReportsPage.tsx

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,57 @@ function asExecutiveContent(content: Report['content']): ExecutiveContent {
6565
};
6666
}
6767

68+
// The attestation-summary content shape (see api-reports spec). Unlike the
69+
// executive shape it carries no pass/fail rollup — those numbers live in the
70+
// downloadable faces (PDF/CSV/OSCAL). The in-app body shows coverage (hosts
71+
// attested of in-scope) and the framework lens, then points at the faces.
72+
interface AttestedHostRef {
73+
host_id: string;
74+
scan_id: string;
75+
scanned_at: string;
76+
}
77+
78+
interface AttestationRollup {
79+
compliance_pct: number | null;
80+
total_checks: number;
81+
passing: number;
82+
failing: number;
83+
skipped: number;
84+
errored: number;
85+
top_failing: { rule_id: string; failing_host_count: number }[];
86+
}
87+
88+
interface AttestationContent {
89+
framework: string;
90+
hosts_total: number;
91+
hosts_attested: number;
92+
attested: AttestedHostRef[];
93+
rollup: AttestationRollup;
94+
}
95+
96+
function asAttestationRollup(r: Partial<AttestationRollup> | undefined): AttestationRollup {
97+
return {
98+
compliance_pct: typeof r?.compliance_pct === 'number' ? r.compliance_pct : null,
99+
total_checks: typeof r?.total_checks === 'number' ? r.total_checks : 0,
100+
passing: typeof r?.passing === 'number' ? r.passing : 0,
101+
failing: typeof r?.failing === 'number' ? r.failing : 0,
102+
skipped: typeof r?.skipped === 'number' ? r.skipped : 0,
103+
errored: typeof r?.errored === 'number' ? r.errored : 0,
104+
top_failing: Array.isArray(r?.top_failing) ? r.top_failing : [],
105+
};
106+
}
107+
108+
function asAttestationContent(content: Report['content']): AttestationContent {
109+
const c = content as Partial<AttestationContent>;
110+
return {
111+
framework: typeof c.framework === 'string' ? c.framework : '',
112+
hosts_total: typeof c.hosts_total === 'number' ? c.hosts_total : 0,
113+
hosts_attested: typeof c.hosts_attested === 'number' ? c.hosts_attested : 0,
114+
attested: Array.isArray(c.attested) ? c.attested : [],
115+
rollup: asAttestationRollup(c.rollup as Partial<AttestationRollup> | undefined),
116+
};
117+
}
118+
68119
function kindLabel(kind: Report['kind']): string {
69120
if (kind === 'executive') return 'Executive';
70121
if (kind === 'attestation') return 'Attestation';
@@ -896,7 +947,12 @@ function ReportDetail({
896947
{verifyResult.detail}
897948
</div>
898949
)}
899-
{resolved && <ExecutiveBody content={asExecutiveContent(resolved.content)} />}
950+
{resolved &&
951+
(resolved.kind === 'attestation' ? (
952+
<AttestationBody content={asAttestationContent(resolved.content)} />
953+
) : (
954+
<ExecutiveBody content={asExecutiveContent(resolved.content)} />
955+
))}
900956
</div>
901957
</div>
902958
</div>
@@ -1042,6 +1098,127 @@ function ExecutiveBody({ content }: { content: ExecutiveContent }) {
10421098
);
10431099
}
10441100

1101+
function AttestationBody({ content }: { content: AttestationContent }) {
1102+
const lens = content.framework || 'All frameworks';
1103+
const notAttested = Math.max(0, content.hosts_total - content.hosts_attested);
1104+
const r = content.rollup;
1105+
const pct = r.compliance_pct;
1106+
const pctTone =
1107+
pct === null
1108+
? 'var(--ow-fg-2)'
1109+
: pct >= 80
1110+
? 'var(--ow-ok)'
1111+
: pct >= 50
1112+
? 'var(--ow-warn)'
1113+
: 'var(--ow-crit)';
1114+
return (
1115+
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
1116+
<section>
1117+
<SectionHead>Compliance</SectionHead>
1118+
<div
1119+
style={{
1120+
display: 'grid',
1121+
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
1122+
gap: 12,
1123+
}}
1124+
>
1125+
<Stat
1126+
label="Compliance"
1127+
value={pct === null ? 'n/a' : `${Math.round(pct)}%`}
1128+
tone={pctTone}
1129+
/>
1130+
<Stat label="Framework" value={lens} />
1131+
<Stat
1132+
label="Hosts attested"
1133+
value={`${content.hosts_attested} of ${content.hosts_total}`}
1134+
tone={notAttested > 0 ? 'var(--ow-warn)' : 'var(--ow-ok)'}
1135+
/>
1136+
<Stat label="Checks evaluated" value={`${r.total_checks}`} />
1137+
<Stat label="Passing" value={`${r.passing}`} tone="var(--ow-ok)" />
1138+
<Stat label="Failing" value={`${r.failing}`} tone="var(--ow-warn)" />
1139+
<Stat label="Skipped / error" value={`${r.skipped} / ${r.errored}`} />
1140+
</div>
1141+
</section>
1142+
1143+
{notAttested > 0 && (
1144+
<div
1145+
role="note"
1146+
style={{
1147+
display: 'flex',
1148+
gap: 12,
1149+
alignItems: 'flex-start',
1150+
padding: '12px 14px',
1151+
borderRadius: 'var(--ow-radius)',
1152+
border: '1px solid var(--ow-warn)',
1153+
borderLeft: '3px solid var(--ow-warn)',
1154+
background: 'var(--ow-warn-bg, rgba(200,160,40,0.12))',
1155+
fontSize: 12.5,
1156+
lineHeight: 1.5,
1157+
color: 'var(--ow-fg-1)',
1158+
}}
1159+
>
1160+
<span aria-hidden="true" style={{ color: 'var(--ow-warn)', flexShrink: 0 }}>
1161+
!
1162+
</span>
1163+
<div>
1164+
{notAttested} of {content.hosts_total} in-scope{' '}
1165+
{notAttested === 1 ? 'host has' : 'hosts have'} no completed scan and{' '}
1166+
{notAttested === 1 ? 'is' : 'are'} not attested here.
1167+
</div>
1168+
</div>
1169+
)}
1170+
1171+
<section>
1172+
<SectionHead>Top failing rules</SectionHead>
1173+
{r.top_failing.length === 0 ? (
1174+
<div style={{ fontSize: 13, color: 'var(--ow-fg-3)', padding: '8px 0' }}>
1175+
No failing rules recorded.
1176+
</div>
1177+
) : (
1178+
<Panel>
1179+
<Row head cols="1fr 140px">
1180+
<span>Rule</span>
1181+
<span>Failing hosts</span>
1182+
</Row>
1183+
{r.top_failing.map((rule, i) => (
1184+
<Row key={rule.rule_id} cols="1fr 140px" first={i === 0}>
1185+
<span
1186+
style={{
1187+
fontSize: 12,
1188+
fontFamily: 'var(--ow-font-mono, monospace)',
1189+
color: 'var(--ow-fg-1)',
1190+
overflow: 'hidden',
1191+
textOverflow: 'ellipsis',
1192+
whiteSpace: 'nowrap',
1193+
}}
1194+
>
1195+
{rule.rule_id}
1196+
</span>
1197+
<span style={{ fontSize: 13, color: 'var(--ow-fg-1)' }}>
1198+
{rule.failing_host_count}
1199+
</span>
1200+
</Row>
1201+
))}
1202+
</Panel>
1203+
)}
1204+
</section>
1205+
1206+
<div
1207+
style={{
1208+
fontSize: 12,
1209+
color: 'var(--ow-fg-3)',
1210+
lineHeight: 1.5,
1211+
paddingTop: 4,
1212+
borderTop: '1px solid var(--ow-line)',
1213+
}}
1214+
>
1215+
Figures are frozen from the latest completed scan per host, not live. The full per-host,
1216+
per-rule breakdown and evidence are in the downloadable PDF, CSV, and OSCAL faces above.
1217+
</div>
1218+
</div>
1219+
);
1220+
}
1221+
10451222
function ComingSoon({ what }: { what: string }) {
10461223
return (
10471224
<Panel>

frontend/tests/pages/reports.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
// otherwise); kindLabel maps attestation -> "Attestation"
1616
// AC-11 secondaryFaces surfaces the attestation PDF + OSCAL SAR downloads
1717
// (executive none); downloadReportFace accepts 'oscal_sar'
18+
// AC-12 detail body is kind-aware: AttestationBody(asAttestationContent)
19+
// for attestation, ExecutiveBody(asExecutiveContent) otherwise
1820

1921
import { describe, expect, test } from 'vitest';
2022
import { readFileSync } from 'node:fs';
@@ -200,6 +202,30 @@ describe('frontend-reports — reports library page', () => {
200202
expect(PAGE_SRC).toMatch(/format: 'pdf' \| 'json' \| 'csv' \| 'oscal_sar'/);
201203
});
202204

205+
// @ac AC-12
206+
test('frontend-reports/AC-12 — kind-aware body renders the frozen attestation rollup', () => {
207+
// The detail body branches on the resolved kind.
208+
expect(PAGE_SRC).toMatch(/resolved\.kind === 'attestation' \?/);
209+
expect(PAGE_SRC).toMatch(
210+
/<AttestationBody content=\{asAttestationContent\(resolved\.content\)\}/,
211+
);
212+
expect(PAGE_SRC).toMatch(/<ExecutiveBody content=\{asExecutiveContent\(resolved\.content\)\}/);
213+
// asAttestationContent narrows the attestation keys incl. the frozen rollup.
214+
expect(PAGE_SRC).toContain('function asAttestationContent');
215+
expect(PAGE_SRC).toMatch(/hosts_attested:/);
216+
expect(PAGE_SRC).toContain('function asAttestationRollup');
217+
// AttestationBody reads the rollup and renders compliance + pass/fail +
218+
// the top-failing table (not the executive content keys).
219+
expect(PAGE_SRC).toContain('function AttestationBody');
220+
expect(PAGE_SRC).toMatch(/const r = content\.rollup/);
221+
expect(PAGE_SRC).toContain('Hosts attested');
222+
expect(PAGE_SRC).toContain('Framework');
223+
expect(PAGE_SRC).toMatch(/r\.compliance_pct/);
224+
expect(PAGE_SRC).toMatch(/r\.passing/);
225+
expect(PAGE_SRC).toMatch(/r\.failing/);
226+
expect(PAGE_SRC).toMatch(/r\.top_failing\.map/);
227+
});
228+
203229
// @ac AC-04
204230
test('frontend-reports/AC-04 — generate is the only mutation, tokens, no em-dash', () => {
205231
// The only mutating call is the generate POST; no PUT/DELETE.

internal/report/export.go

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -312,25 +312,12 @@ func (s *Service) exportAttestationCSV(ctx context.Context, rep Report) ([]byte,
312312
return csvBytes, mediaType, nil
313313
}
314314

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-
328315
// 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.
316+
// else renders the one-page cover via renderAttestationPDF and caches it in
317+
// report_faces. The rollup is read from the FROZEN content (computed once
318+
// at generation time and signed), so the PDF, the in-app view, and the
319+
// signature all show the same numbers. A pre-rollup snapshot (generated
320+
// before the rollup was frozen) is handled by recomputing on the fly.
334321
func (s *Service) exportAttestationPDF(ctx context.Context, rep Report) ([]byte, string, error) {
335322
const mediaType = "application/pdf"
336323

@@ -349,11 +336,16 @@ func (s *Service) exportAttestationPDF(ctx context.Context, rep Report) ([]byte,
349336
if err := json.Unmarshal(rep.Content, &c); err != nil {
350337
return nil, "", fmt.Errorf("report: decode attestation content: %w", err)
351338
}
352-
rollup, err := s.computeAttestationRollup(ctx, c)
353-
if err != nil {
354-
return nil, "", err
339+
// Back-compat: a snapshot frozen before the rollup was part of the
340+
// content has an empty rollup but attested hosts; recompute it live.
341+
if c.Rollup.TotalChecks == 0 && c.HostsAttested > 0 {
342+
rollup, err := s.computeAttestationRollup(ctx, scanIDsOf(c), c.Framework)
343+
if err != nil {
344+
return nil, "", err
345+
}
346+
c.Rollup = rollup
355347
}
356-
pdfBytes, err := renderAttestationPDF(rep, c, rollup)
348+
pdfBytes, err := renderAttestationPDF(rep, c)
357349
if err != nil {
358350
return nil, "", err
359351
}
@@ -374,17 +366,25 @@ func (s *Service) exportAttestationPDF(ctx context.Context, rep Report) ([]byte,
374366
return pdfBytes, mediaType, nil
375367
}
376368

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))
369+
// scanIDsOf extracts the frozen scan ids from an attestation's attested list.
370+
func scanIDsOf(c AttestationContent) []uuid.UUID {
371+
ids := make([]uuid.UUID, len(c.Attested))
383372
for i, a := range c.Attested {
384-
scanIDs[i] = a.ScanID
373+
ids[i] = a.ScanID
385374
}
375+
return ids
376+
}
377+
378+
// computeAttestationRollup runs two aggregate queries over the given frozen
379+
// scans (counts by status, and the top failing rules by distinct failing
380+
// host), applying the framework lens. Compliance is passing / (passing +
381+
// failing), rounded half up, nil when nothing was evaluated. Called at
382+
// generation time to FREEZE the rollup into the signed content (and as a
383+
// back-compat fallback when rendering a pre-rollup snapshot).
384+
func (s *Service) computeAttestationRollup(ctx context.Context, scanIDs []uuid.UUID, framework string) (AttestationRollup, error) {
385+
var r AttestationRollup
386+
r.TopFailing = []TopFailingRule{}
386387

387-
var r attestationRollup
388388
countQ := `
389389
SELECT count(*),
390390
count(*) FILTER (WHERE status = 'pass'),
@@ -394,16 +394,16 @@ func (s *Service) computeAttestationRollup(ctx context.Context, c AttestationCon
394394
FROM scan_results sr
395395
WHERE sr.scan_id = ANY($1)`
396396
countArgs := []any{scanIDs}
397-
if c.Framework != "" {
397+
if framework != "" {
398398
countQ += " AND sr.framework_refs ? $2"
399-
countArgs = append(countArgs, c.Framework)
399+
countArgs = append(countArgs, framework)
400400
}
401401
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)
402+
Scan(&r.TotalChecks, &r.Passing, &r.Failing, &r.Skipped, &r.Errored); err != nil {
403+
return AttestationRollup{}, fmt.Errorf("report: attestation rollup counts: %w", err)
404404
}
405-
if evaluated := r.Pass + r.Fail; evaluated > 0 {
406-
pct := int((float64(r.Pass)/float64(evaluated))*100 + 0.5)
405+
if evaluated := r.Passing + r.Failing; evaluated > 0 {
406+
pct := int((float64(r.Passing)/float64(evaluated))*100 + 0.5)
407407
r.CompliancePct = &pct
408408
}
409409

@@ -412,25 +412,25 @@ func (s *Service) computeAttestationRollup(ctx context.Context, c AttestationCon
412412
FROM scan_results sr
413413
WHERE sr.scan_id = ANY($1) AND sr.status = 'fail'`
414414
topArgs := []any{scanIDs}
415-
if c.Framework != "" {
415+
if framework != "" {
416416
topQ += " AND sr.framework_refs ? $2"
417-
topArgs = append(topArgs, c.Framework)
417+
topArgs = append(topArgs, framework)
418418
}
419419
topQ += " GROUP BY sr.rule_id ORDER BY count(DISTINCT sr.host_id) DESC, sr.rule_id LIMIT 10"
420420
rows, err := s.pool.Query(ctx, topQ, topArgs...)
421421
if err != nil {
422-
return attestationRollup{}, fmt.Errorf("report: attestation rollup top-failing: %w", err)
422+
return AttestationRollup{}, fmt.Errorf("report: attestation rollup top-failing: %w", err)
423423
}
424424
defer rows.Close()
425425
for rows.Next() {
426426
var t TopFailingRule
427427
if err := rows.Scan(&t.RuleID, &t.FailingHostCount); err != nil {
428-
return attestationRollup{}, fmt.Errorf("report: attestation rollup scan: %w", err)
428+
return AttestationRollup{}, fmt.Errorf("report: attestation rollup scan: %w", err)
429429
}
430430
r.TopFailing = append(r.TopFailing, t)
431431
}
432432
if err := rows.Err(); err != nil {
433-
return attestationRollup{}, fmt.Errorf("report: attestation rollup iterate: %w", err)
433+
return AttestationRollup{}, fmt.Errorf("report: attestation rollup iterate: %w", err)
434434
}
435435
return r, nil
436436
}

0 commit comments

Comments
 (0)