Skip to content

Commit dad7967

Browse files
fix(reports): release hardening for rc.13 (claim, audit, channel validation) (#660)
Pre-release quality + security pass on the Reports surface ahead of v0.2.0-rc.13. Release: - Bump version.env to 0.2.0-rc.13 (Eyrie). - Add the 0.2.0-rc.13 CHANGELOG entry (four report kinds, OSCAL SAR, scheduled email delivery, async rendering + report.ready bell, Ed25519 signing, migrations 0043-0046). Hardening: - Dispatcher now claims due schedules atomically with FOR UPDATE SKIP LOCKED (ClaimDue advances next_run_at in the locked tx; MarkResult records the outcome), so two concurrent dispatchers never double-send a scheduled report. - Emit audit events on report generate and on schedule create / toggle / delete (report.generated, report.schedule.created/.toggled/.deleted). - Reject a non-email (or unknown) delivery channel at schedule create (400) instead of failing silently at dispatch. - CRLF-sanitize report-email subject + attachment filename (header-injection defense, CWE-93). Tests: - freshAPIServer truncates report_schedules / report_snapshots / notification_channels between API tests (isolation gap). - New AC-05 no-double-claim test for ClaimDue; schedule API test asserts the create audit event and the non-email-channel 400. Specs: - system-report-schedule -> 1.1.0 (C-03 atomic claim, C-04 CRLF, C-05 channel validation + audit, AC-04 extended, AC-05 added). - api-reports -> 1.14.0 (C-06 report.generated audit).
1 parent e6242c4 commit dad7967

16 files changed

Lines changed: 402 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,50 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1212

1313
---
1414

15+
## [0.2.0-rc.13] Eyrie — 2026-06-22
16+
17+
The Reports surface is now a complete compliance-artifact platform, spanning
18+
the executive summary, an auditor/GRC bulk path, two new GRC read-model
19+
kinds, and recurring email delivery.
20+
21+
### Added
22+
- **Framework Attestation report kind** (auditor/GRC): a point-in-time,
23+
signed attestation that freezes the latest completed scan per in-scope
24+
host. Faces: a fleet **OSCAL 1.0.6 assessment-results (SAR)** with
25+
evidence referenced by content hash, a per-(host, rule) **CSV** evidence
26+
extract, a bounded one-page **PDF cover**, and the canonical **JSON**.
27+
A frozen, signed compliance rollup (compliance %, pass/fail, top-failing)
28+
is shown in-app and on the PDF cover.
29+
- **Exception Register report kind** (Compliance/GRC): a point-in-time
30+
read-model of compliance waivers — counts by state (active, pending,
31+
expiring-soon) plus the register rows — with CSV + PDF + JSON faces.
32+
- **Remediation Activity report kind** (Operations): a read-model of
33+
remediation requests over a look-back window (last 7/30/90 days), with
34+
an outcome summary and CSV + PDF + JSON faces.
35+
- **Scheduled reports + email delivery**: a daily/weekly/monthly schedule
36+
generates a report and emails its rendered PDF (MIME attachment) through
37+
an email notification channel. Managed from a live **Scheduled** tab
38+
(create / pause-resume / delete). Endpoints under
39+
`/api/v1/reports/schedules`.
40+
- **Asynchronous report rendering** + a new `report.ready` event on the
41+
event bus — the first producer of the in-app **notification bell**.
42+
- **Ed25519 report signing** with an in-browser **offline Verify**, a
43+
fleet **framework catalog** endpoint, and a kind selector + scope/period
44+
pickers on the Library tab.
45+
- Report kinds are admitted by `report_snapshots.kind` (migrations
46+
0043–0045); `report_schedules` lands in migration 0046.
47+
48+
### Audit
49+
- Report generation and report-schedule create/delete/enable-disable now
50+
emit audit events (`report.generated`, `report.schedule.*`).
51+
52+
### Security / hardening
53+
- The scheduled-report dispatcher claims due schedules with
54+
`FOR UPDATE SKIP LOCKED`, so concurrent dispatchers never double-send.
55+
- Report-email subjects are CRLF-sanitized (header-injection defense).
56+
57+
---
58+
1559
## [0.2.0-rc.12] Eyrie — 2026-06-20
1660

1761
The fleet activity stream and audit trail are now readable end to end: every

audit/events.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,44 @@ events:
758758
- code: remediation.rolled_back
759759
severity: warning
760760

761+
# =========================================================================
762+
# reports — generation + scheduled delivery
763+
# =========================================================================
764+
- code: report.generated
765+
severity: info
766+
detail_schema:
767+
type: object
768+
properties:
769+
kind: {type: string}
770+
scope_label: {type: string}
771+
report_id: {type: string}
772+
773+
- code: report.schedule.created
774+
severity: info
775+
detail_schema:
776+
type: object
777+
properties:
778+
schedule_id: {type: string}
779+
name: {type: string}
780+
kind: {type: string}
781+
frequency: {type: string}
782+
channel_id: {type: string}
783+
784+
- code: report.schedule.deleted
785+
severity: warning
786+
detail_schema:
787+
type: object
788+
properties:
789+
schedule_id: {type: string}
790+
791+
- code: report.schedule.toggled
792+
severity: info
793+
detail_schema:
794+
type: object
795+
properties:
796+
schedule_id: {type: string}
797+
enabled: {type: boolean}
798+
761799
# =========================================================================
762800
# integration — external systems
763801
# =========================================================================

frontend/src/pages/reports/ReportsPage.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1905,9 +1905,10 @@ function ComingSoon({ what }: { what: string }) {
19051905
lineHeight: 1.5,
19061906
}}
19071907
>
1908-
{what === 'Templates'
1909-
? 'The report kinds (Fleet Compliance Executive Summary and Framework Attestation) are live in the Library tab, each with signed PDF, CSV, OSCAL, and JSON faces. A gallery for building and saving custom report templates is not built yet.'
1910-
: 'Scheduled report delivery requires a dispatcher, which is not built yet. For now, generate reports on demand from the Library tab.'}
1908+
The report kinds (executive, attestation, exception, remediation) are live in the Library
1909+
tab, each with signed PDF, CSV, OSCAL, and JSON faces, and reports can be delivered on a
1910+
schedule from the Scheduled tab. A gallery for building and saving custom report templates
1911+
is not built yet.
19111912
</div>
19121913
</div>
19131914
</Panel>

internal/audit/events.gen.go

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/notification/report_email.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ func (s *Service) SendReportEmail(ctx context.Context, channelID uuid.UUID, subj
5656
// buildReportEmail assembles a multipart/mixed RFC 5322 message: a
5757
// text/plain body part and a base64-encoded application/pdf attachment.
5858
func buildReportEmail(from string, to []string, subject, body, filename string, attachment []byte) []byte {
59+
// Defense-in-depth: strip CR/LF from the subject so a value flowing into
60+
// the Subject header can never inject additional headers (CWE-93). Report
61+
// titles are fixed today, but this future-proofs the header.
62+
subject = stripCRLF(subject)
63+
filename = stripCRLF(filename)
5964
var parts bytes.Buffer
6065
w := multipart.NewWriter(&parts)
6166

@@ -85,6 +90,12 @@ func buildReportEmail(from string, to []string, subject, body, filename string,
8590
return msg.Bytes()
8691
}
8792

93+
// stripCRLF removes carriage returns and newlines so a value cannot inject
94+
// extra MIME/RFC-5322 headers.
95+
func stripCRLF(s string) string {
96+
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
97+
}
98+
8899
// wrap76 breaks a base64 string into 76-character CRLF-terminated lines
89100
// (RFC 2045).
90101
func wrap76(s string) string {

internal/report/types.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
// Package report implements the Reports library: point-in-time,
2-
// immutable compliance artifacts. The MVP generates exactly one kind,
3-
// the Fleet Compliance Executive Summary. Generating it computes a
4-
// posture snapshot from data that already exists (host_rule_state
5-
// pass/fail counts + critical, host count, top failing rules) and
6-
// stores it as a JSON document; the row is then never recomputed.
2+
// immutable, Ed25519-signed compliance artifacts. It generates four kinds
3+
// (executive summary, framework attestation, exception register,
4+
// remediation activity), each computed once from data that already exists
5+
// and stored as a frozen JSON document; the row is never recomputed. Each
6+
// kind renders to downloadable faces (PDF / CSV / OSCAL SAR / JSON).
77
//
8-
// DEFERRED (not built here, see the migration + spec excludes): Ed25519
9-
// signing, PDF/OSCAL rendering, the Scheduled dispatcher, the Templates
10-
// gallery, retention sweeps.
8+
// DEFERRED: the Templates gallery and retention sweeps (see the spec
9+
// excludes). Recurring generation + email delivery lives in
10+
// internal/reportschedule.
1111
//
12-
// Spec: api-reports v1.0.0.
12+
// Spec: api-reports.
1313
package report
1414

1515
import (

internal/reportschedule/dispatcher.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,26 +38,26 @@ func NewDispatcher(svc *Service, gen Generator, deliver Deliverer) *Dispatcher {
3838
return &Dispatcher{svc: svc, gen: gen, deliver: deliver}
3939
}
4040

41-
// Tick processes every due schedule once. It is the cron TickFunc; an error
42-
// from one schedule is recorded on that schedule (last_status) and does not
43-
// abort the others. The return error is non-nil only on a query failure.
41+
// Tick claims + processes every due schedule once. It is the cron TickFunc.
42+
// ClaimDue atomically reserves the due schedules (FOR UPDATE SKIP LOCKED +
43+
// advance), so concurrent dispatchers never double-send. An error from one
44+
// schedule is recorded on that schedule (last_status) and does not abort the
45+
// others. The return error is non-nil only on a claim failure.
4446
func (d *Dispatcher) Tick(ctx context.Context) error {
4547
now := time.Now().UTC()
46-
due, err := d.svc.Due(ctx, now)
48+
claimed, err := d.svc.ClaimDue(ctx, now)
4749
if err != nil {
4850
return err
4951
}
50-
for _, sch := range due {
52+
for _, sch := range claimed {
5153
status := "ok"
5254
if rerr := d.run(ctx, sch); rerr != nil {
5355
status = "failed: " + rerr.Error()
5456
slog.WarnContext(ctx, "report schedule run failed",
5557
slog.String("schedule_id", sch.ID.String()), slog.String("error", rerr.Error()))
5658
}
57-
// Advance from now so a slow/failed run does not immediately re-fire.
58-
next := ComputeNextRun(sch.Frequency, sch.Hour, sch.Weekday, sch.DayOfMonth, time.Now().UTC())
59-
if merr := d.svc.MarkRun(ctx, sch.ID, next, status); merr != nil {
60-
slog.WarnContext(ctx, "report schedule mark-run failed",
59+
if merr := d.svc.MarkResult(ctx, sch.ID, status); merr != nil {
60+
slog.WarnContext(ctx, "report schedule mark-result failed",
6161
slog.String("schedule_id", sch.ID.String()), slog.String("error", merr.Error()))
6262
}
6363
}

internal/reportschedule/schedule_db_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,50 @@ func seedChannel(t *testing.T, pool *pgxpool.Pool) uuid.UUID {
114114
return id
115115
}
116116

117+
// @ac AC-05
118+
// ClaimDue atomically advances next_run_at under FOR UPDATE SKIP LOCKED, so
119+
// a second claim at the same instant returns nothing - the property that
120+
// stops two concurrent dispatchers from double-sending a report.
121+
func TestClaimDue_NoDoubleClaim(t *testing.T) {
122+
t.Run("system-report-schedule/AC-05", func(t *testing.T) {
123+
pool := freshPool(t)
124+
ctx := context.Background()
125+
svc := NewService(pool)
126+
ch := seedChannel(t, pool)
127+
sch, err := svc.Create(ctx, CreateParams{
128+
Name: "daily", Kind: "executive", Frequency: Daily, Hour: 6, ChannelID: ch,
129+
})
130+
if err != nil {
131+
t.Fatalf("Create: %v", err)
132+
}
133+
// Make it due.
134+
if _, err := pool.Exec(ctx,
135+
`UPDATE report_schedules SET next_run_at = now() - interval '1 minute' WHERE id = $1`, sch.ID); err != nil {
136+
t.Fatalf("backdate: %v", err)
137+
}
138+
now := time.Now().UTC()
139+
140+
first, err := svc.ClaimDue(ctx, now)
141+
if err != nil {
142+
t.Fatalf("first ClaimDue: %v", err)
143+
}
144+
if len(first) != 1 {
145+
t.Fatalf("first claim = %d schedules, want 1", len(first))
146+
}
147+
if !first[0].NextRunAt.After(now) {
148+
t.Errorf("claim did not advance next_run_at: %v", first[0].NextRunAt)
149+
}
150+
// A second claim at the same instant must see nothing (already advanced).
151+
second, err := svc.ClaimDue(ctx, now)
152+
if err != nil {
153+
t.Fatalf("second ClaimDue: %v", err)
154+
}
155+
if len(second) != 0 {
156+
t.Errorf("second claim = %d schedules, want 0 (no double-claim)", len(second))
157+
}
158+
})
159+
}
160+
117161
// @ac AC-02
118162
func TestDispatcher_RunsDueSchedule(t *testing.T) {
119163
t.Run("system-report-schedule/AC-02", func(t *testing.T) {

internal/reportschedule/service.go

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,69 @@ func (s *Service) Due(ctx context.Context, now time.Time) ([]Schedule, error) {
133133
return out, rows.Err()
134134
}
135135

136-
// MarkRun records a run outcome and advances next_run_at.
137-
func (s *Service) MarkRun(ctx context.Context, id uuid.UUID, next time.Time, status string) error {
136+
// ClaimDue atomically claims the due schedules: in ONE transaction it locks
137+
// them with FOR UPDATE SKIP LOCKED and advances next_run_at to the next
138+
// occurrence. A concurrent dispatcher (a second serve process) therefore
139+
// sees a DISJOINT set and never re-claims the same schedule, so a report is
140+
// never double-generated or double-emailed. The claimed schedules are
141+
// returned for processing; record the per-run outcome with MarkResult.
142+
// next_run advances at claim time, so a crash mid-run simply skips that run
143+
// rather than re-firing it every tick.
144+
func (s *Service) ClaimDue(ctx context.Context, now time.Time) ([]Schedule, error) {
145+
tx, err := s.pool.Begin(ctx)
146+
if err != nil {
147+
return nil, fmt.Errorf("reportschedule: claim begin: %w", err)
148+
}
149+
defer func() { _ = tx.Rollback(ctx) }()
150+
151+
rows, err := tx.Query(ctx,
152+
`SELECT `+scheduleCols+` FROM report_schedules
153+
WHERE enabled AND next_run_at <= $1
154+
ORDER BY next_run_at
155+
FOR UPDATE SKIP LOCKED`, now)
156+
if err != nil {
157+
return nil, fmt.Errorf("reportschedule: claim select: %w", err)
158+
}
159+
var claimed []Schedule
160+
for rows.Next() {
161+
sch, serr := scanSchedule(rows)
162+
if serr != nil {
163+
rows.Close()
164+
return nil, serr
165+
}
166+
claimed = append(claimed, sch)
167+
}
168+
rows.Close()
169+
if err := rows.Err(); err != nil {
170+
return nil, err
171+
}
172+
173+
// Advance next_run_at within the same locked transaction so the claim is
174+
// effective the moment we commit.
175+
for i := range claimed {
176+
next := ComputeNextRun(claimed[i].Frequency, claimed[i].Hour,
177+
claimed[i].Weekday, claimed[i].DayOfMonth, now)
178+
if _, err := tx.Exec(ctx,
179+
`UPDATE report_schedules SET next_run_at = $2, updated_at = now() WHERE id = $1`,
180+
claimed[i].ID, next); err != nil {
181+
return nil, fmt.Errorf("reportschedule: claim advance: %w", err)
182+
}
183+
claimed[i].NextRunAt = next
184+
}
185+
if err := tx.Commit(ctx); err != nil {
186+
return nil, fmt.Errorf("reportschedule: claim commit: %w", err)
187+
}
188+
return claimed, nil
189+
}
190+
191+
// MarkResult records the outcome of a claimed run (last_run_at + last_status).
192+
// next_run_at was already advanced by ClaimDue.
193+
func (s *Service) MarkResult(ctx context.Context, id uuid.UUID, status string) error {
138194
_, err := s.pool.Exec(ctx,
139-
`UPDATE report_schedules SET last_run_at = now(), last_status = $2, next_run_at = $3, updated_at = now() WHERE id = $1`,
140-
id, status, next)
195+
`UPDATE report_schedules SET last_run_at = now(), last_status = $2, updated_at = now() WHERE id = $1`,
196+
id, status)
141197
if err != nil {
142-
return fmt.Errorf("reportschedule: mark run: %w", err)
198+
return fmt.Errorf("reportschedule: mark result: %w", err)
143199
}
144200
return nil
145201
}

internal/server/api_helpers_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ func freshAPIServer(t *testing.T) (string, *pgxpool.Pool) {
177177
_, _ = pool.Exec(ctx, "TRUNCATE TABLE posture_snapshots")
178178
_, _ = pool.Exec(ctx, "TRUNCATE TABLE compliance_exceptions")
179179
_, _ = pool.Exec(ctx, "TRUNCATE TABLE job_queue")
180+
// Reports: schedules FK notification_channels; report_faces FK
181+
// report_snapshots. CASCADE so a leftover schedule/snapshot/channel from a
182+
// prior test cannot leak (a stale channel encrypted with a prior ephemeral
183+
// key would break notification decrypt in a later test).
184+
_, _ = pool.Exec(ctx, "TRUNCATE TABLE report_schedules CASCADE")
185+
_, _ = pool.Exec(ctx, "TRUNCATE TABLE report_snapshots CASCADE")
186+
_, _ = pool.Exec(ctx, "TRUNCATE TABLE notification_channels CASCADE")
180187
// SSO config + federation links (cascades to identities + auth states).
181188
_, _ = pool.Exec(ctx, "TRUNCATE TABLE sso_providers CASCADE")
182189
// TRUNCATE…CASCADE delegates child cleanup to the schema — the

0 commit comments

Comments
 (0)