Skip to content

Commit 6745761

Browse files
feat(reports): fleet OSCAL SAR face for attestation reports (B2) (#643)
Add a fleet OSCAL 1.0.6 assessment-results face (format=oscal_sar, attestation-only) to the Reports export surface. Kensa's per-scan exporter (ExportOSCALScan) inlines each scan's evidence as base64 and is per-scan, so it cannot produce the hash-referencing single-document fleet SAR the design requires. internal/report/oscal.go is a light, custom assembler with its own minimal OSCAL structs mirroring the per-scan shape: - One assessment-results document, one result, one observation + finding per (host, rule) reconstructed from the attestation snapshot's frozen, immutable scan_results. - Finding target state "satisfied" only on a pass (else "not-satisfied"); host as a deterministic-v5 inventory-item subject. - Reviewed-controls aggregated as framework-prefixed control-id tokens (digit-leading native ids like CIS "1.1" stay valid OSCAL tokens), narrowed by the snapshot's framework lens. - Evidence REFERENCED by sha256 in back-matter (an rlink SHA-256 hash), never inlined as base64; the bytes stay in scan_evidence. This is what keeps a 100-host x 500-rule attestation from becoming the 1000-page problem in OSCAL form. - Every uuid is a deterministic v5 from the snapshot id, so the document is byte-deterministic and cached in report_faces (face oscal_sar) like the PDF/CSV faces; assembly is bounded by the same row cap as the CSV, with a metadata prop disclosing truncation. OSCAL version stays 1.0.6 to match the Kensa-controlled per-scan emitter. Spec api-reports v1.8.0: C-14 + AC-20 (DB test over the assembled document: shape, states, control tokens, hash-referenced evidence, determinism, framework-lens scoping, executive ErrInvalidFace).
1 parent 3c55fcf commit 6745761

8 files changed

Lines changed: 879 additions & 126 deletions

File tree

api/openapi.yaml

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2606,16 +2606,21 @@ paths:
26062606
/api/v1/reports/{id}/export:
26072607
get:
26082608
operationId: getReportExport
2609-
summary: Download a rendered face of a report (PDF or JSON)
2609+
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) renders the bounded one-page
2613-
executive PDF (a fixed posture block, the coverage caveat, and the
2614-
top-failing list - never the full per-host/per-rule evidence);
2615-
format=json returns the canonical frozen posture content. The PDF
2616-
is rendered on first request and cached in report_faces, so a
2617-
repeat download re-streams the stored bytes. RBAC: host:read. Spec
2618-
api-reports.
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
2618+
single OSCAL 1.0.6 assessment-results document (one observation +
2619+
finding per (host, rule), evidence referenced by sha256 in
2620+
back-matter, not inlined). A face that does not apply to the
2621+
report's kind is a 400. Each face is rendered on first request and
2622+
cached in report_faces, so a repeat download re-streams the stored
2623+
bytes. RBAC: host:read. Spec api-reports.
26192624
parameters:
26202625
- name: id
26212626
in: path
@@ -2625,7 +2630,7 @@ paths:
26252630
in: query
26262631
required: false
26272632
description: The face to render. Defaults to pdf.
2628-
schema: {type: string, enum: [pdf, json, csv], default: pdf}
2633+
schema: {type: string, enum: [pdf, json, csv, oscal_sar], default: pdf}
26292634
responses:
26302635
'200':
26312636
description: The rendered face, as a downloadable attachment

docs/engineering/reports_design.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -519,11 +519,26 @@ severity, framework_refs, evidence_sha256, scan_at, exception_id. The
519519
snapshot content is blob-stored (compressed). Spec: `api-reports`
520520
(kind=attestation), new `system-report-attestation`.
521521

522-
**B2 — Fleet OSCAL SAR face.** Assemble a single OSCAL 1.0.6
523-
`assessment-results` from the attestation snapshot: reviewed-controls from
524-
the framework mapping, one observation + finding per `(host, rule)`,
525-
evidence referenced by `sha256` (back-matter), streamed to the `oscal_sar`
526-
face in `report_faces`. Spec: extend the attestation spec.
522+
**B2 — Fleet OSCAL SAR face.** *(SHIPPED 2026-06-21, PR #643.)* Assemble a
523+
single OSCAL 1.0.6 `assessment-results` from the attestation snapshot
524+
(`internal/report/oscal.go`): one result whose findings + observations
525+
carry one entry per `(host, rule)`, reviewed-controls aggregated as
526+
framework-prefixed control-id tokens (digit-leading native ids stay valid
527+
OSCAL tokens), the finding state "satisfied" only on a pass, the host as a
528+
deterministic-v5 inventory-item subject, narrowed by the snapshot's
529+
framework lens. Evidence is REFERENCED by `sha256` in back-matter (an rlink
530+
SHA-256 hash), never inlined as base64 — the bytes stay in `scan_evidence`.
531+
Since Kensa's `ExportOSCALScan` is per-scan and *inlines* evidence, the
532+
fleet assembler is a light hash-referencing custom builder (not Kensa's
533+
exporter), with its own minimal OSCAL structs mirroring the per-scan shape.
534+
Every uuid is a deterministic v5 from the snapshot id, so the document is
535+
byte-deterministic and cached in `report_faces` (face `oscal_sar`, status
536+
`ready`) like the other faces; the assembly is bounded by the same row cap
537+
as the CSV (a metadata prop discloses truncation). `format=oscal_sar` is
538+
attestation-only (executive is `ErrInvalidFace`). Spec: `api-reports`
539+
v1.8.0 (C-14 / AC-20). True streaming to a separate blob store is deferred
540+
(the in-memory + row-cap + `report_faces.content` pattern matches the CSV
541+
face).
527542

528543
**B3 — Async generation + report.ready.** Fleet attestation generation
529544
(the bulk query + SAR/CSV render) moves to the job queue: a

internal/report/export.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ func (s *Service) Export(ctx context.Context, id uuid.UUID, face string) ([]byte
6161
return nil, "", ErrInvalidFace
6262
}
6363
return s.exportAttestationCSV(ctx, rep)
64+
case FaceOSCALSAR:
65+
if rep.Kind != KindAttestation {
66+
return nil, "", ErrInvalidFace
67+
}
68+
return s.exportFleetOSCALSAR(ctx, rep)
6469
default:
6570
return nil, "", ErrInvalidFace
6671
}
@@ -150,7 +155,13 @@ func ExportFilename(rep Report, face string) string {
150155
if kind == "" {
151156
kind = "report"
152157
}
153-
return fmt.Sprintf("openwatch-%s-%s-%s.%s", kind, slugify(rep.ScopeLabel), rep.DataAsOf.Format("2006-01-02"), face)
158+
// The OSCAL SAR is a JSON document; give it a readable .oscal.json
159+
// extension rather than the raw face token.
160+
ext := face
161+
if face == FaceOSCALSAR {
162+
ext = "oscal.json"
163+
}
164+
return fmt.Sprintf("openwatch-%s-%s-%s.%s", kind, slugify(rep.ScopeLabel), rep.DataAsOf.Format("2006-01-02"), ext)
154165
}
155166

156167
// slugify lowercases and replaces non-alphanumeric runs with single

0 commit comments

Comments
 (0)