Skip to content

fix(evidence): per-rule OSCAL export + unmapped-rule conformance fix#83

Merged
remyluslosius merged 1 commit into
mainfrom
fix/oscal-per-rule-export
Jun 14, 2026
Merged

fix(evidence): per-rule OSCAL export + unmapped-rule conformance fix#83
remyluslosius merged 1 commit into
mainfrom
fix/oscal-per-rule-export

Conversation

@remyluslosius

Copy link
Copy Markdown
Contributor

Context

The OpenWatch team asked: OSCAL is a whole-scan document (one AR per host), but Evidence is per-rule — how should the OSCAL option work in the per-rule UI expansion?

Investigating it surfaced a real conformance bug at exactly that granularity, plus the answer.

The design answer

  • CheckEvidence is the per-rule primitive — that's what a per-rule expansion should render.
  • OSCAL AR is a whole-document artifact, but the exporter is granularity-agnostic: a one-outcome ScanResult yields a valid one-finding AR. So "per-rule OSCAL" is the same exporter fed a single-rule slice — no separate serializer.

This PR makes that first-class and fixes what was blocking it.

The bug (found via the question)

reviewed-controls is required on every OSCAL result, and a control-selection must select include-all or a non-empty include-controls (minItems 1). The exporter emitted an empty include-controls when a result had no framework-mapped control. A whole-host scan never hits this (some corpus rule is always mapped) — but a single-rule document for an unmapped rule produces invalid OSCAL, which is precisely the per-rule-expansion case.

Fix: a shared controlSelection helper falls back to OSCAL include-all when there are no control refs — applied to both ExportOSCALScan (scan) and ExportOSCAL (remediation) paths.

Per-rule entry point

pkg/kensa.ExportOSCALOutcome(scan, outcome, hostname) / WriteOSCALOutcome render a single api.RuleOutcome as its own valid one-finding AR, preserving the parent scan's host context (HostID/Capabilities/Platform) so the caller doesn't hand-roll the slice and drop it. Thin wrapper over the granularity-agnostic exporter, not a second implementation.

// per-rule expansion → OSCAL for just that rule
doc, _ := kensa.ExportOSCALOutcome(scan, scan.Outcomes[i], scan.HostID)
// whole host → unchanged
doc, _ := kensa.ExportOSCALScan(scan, scan.HostID)

Tests / spec

  • Regression: unmapped single-rule validates against OSCAL 1.0.6 (both the scan path and the public helper)
  • evidence-oscal-scan C-08/AC-09 (include-all fallback); oscal-public-export AC-06/AC-07 (per-rule helper)

Gate

go test ./... green · golangci-lint 0 · specter 118/118 (evidence-oscal-scan 9/9, oscal-public-export 7/7). No engine/capture/rollback touched.

Recommendation to OpenWatch

Show CheckEvidence in the per-rule expansion (it's the right primitive); offer OSCAL as an export action that calls ExportOSCALOutcome for one rule or ExportOSCALScan for the whole host. Both yield valid OSCAL 1.0.6 now, including unmapped rules.

🤖 Generated with Claude Code

The OpenWatch team asked how the OSCAL option should work in a per-rule
UI expansion, given OSCAL is a whole-scan document but evidence is
per-rule. Investigating surfaced a real conformance bug at exactly that
granularity.

Bug: a result with no framework-mapped control emitted an empty OSCAL
`include-controls`, which the 1.0.6 schema rejects — `reviewed-controls`
is required and a control-selection must select `include-all` or a
non-empty `include-controls` (minItems 1). A whole-host scan never hit
this (some corpus rule is always mapped), but a single-rule document for
an unmapped rule — the per-rule expansion case — produced invalid OSCAL.
Fix: a shared `controlSelection` helper falls back to OSCAL `include-all`
when there are no control refs, applied to both `ExportOSCALScan` (scan)
and `ExportOSCAL` (remediation) paths.

Per-rule entry point: `pkg/kensa.ExportOSCALOutcome` / `WriteOSCALOutcome`
render a single `api.RuleOutcome` as its own valid one-finding OSCAL AR,
preserving the parent scan's host context. The exporter is
granularity-agnostic — a one-outcome document is a valid AR — so this is
a thin wrapper that builds the single-outcome ScanResult correctly
(without dropping Capabilities/Platform) rather than a second serializer.

Regression tests: unmapped single-rule validates against 1.0.6 (scan +
public-helper paths). Specs evidence-oscal-scan C-08/AC-09 and
oscal-public-export AC-06/AC-07. No engine/capture/rollback code touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@remyluslosius remyluslosius merged commit 79767d3 into main Jun 14, 2026
14 checks passed
@remyluslosius remyluslosius deleted the fix/oscal-per-rule-export branch June 14, 2026 05:05
remyluslosius added a commit that referenced this pull request Jun 14, 2026
…ix (#84)

Stamp v0.4.2. VERSION -> 0.4.2; CHANGELOG's Unreleased entry (per-rule
OSCAL export + the unmapped-rule include-all fix, PR #83) moves under the
v0.4.2 heading and Unreleased resets.

PATCH bump: the addition lives on pkg/kensa (ExportOSCALOutcome /
WriteOSCALOutcome) and the conformance fix is in internal/evidence; the
frozen api/ surface is untouched.

Verification: go test ./... green; goreleaser check + snapshot build;
Makefile build stamps `kensa 0.4.2`. The signed release pipeline
triggers on the v0.4.2 tag.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant