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
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,26 @@ the canonical names; short forms are listed in `cmd/kensa/flags.go`.

## Unreleased

(no changes yet)
### Added

- **Per-rule OSCAL export on `pkg/kensa`** — `ExportOSCALOutcome` /
`WriteOSCALOutcome` render a single `api.RuleOutcome` as its own valid
one-finding OSCAL 1.0.6 AR document, preserving the parent scan's host
context (HostID/Capabilities/Platform). The per-rule counterpart of
`ExportOSCALScan`, for a UI that exports OSCAL from one expanded rule
rather than the whole scan.

### Fixed

- **Unmapped rule produced invalid OSCAL.** A result with no
framework-mapped control emitted an empty `include-controls`, which the
OSCAL 1.0.6 schema rejects (`reviewed-controls` is required and a
control-selection must select `include-all` or a non-empty
`include-controls`). A whole-host scan never hit this (some rule is
always mapped), but a single-rule document for an unmapped rule — the
per-rule UI expansion — did. The exporter now falls back to OSCAL
`include-all` when there are no control refs, on both the scan
(`ExportOSCALScan`) and remediation (`ExportOSCAL`) paths.

## v0.4.1 — 2026-06-14

Expand Down
30 changes: 25 additions & 5 deletions internal/evidence/oscal.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,30 @@ type oscalReviewedControls struct {
}

type oscalControlSelection struct {
IncludeControls []oscalControlRef `json:"include-controls"`
IncludeAll *oscalIncludeAll `json:"include-all,omitempty"`
IncludeControls []oscalControlRef `json:"include-controls,omitempty"`
}

// oscalIncludeAll is OSCAL's "all controls" selector — serialized as an empty
// object. See [controlSelection] for when it is emitted.
type oscalIncludeAll struct{}

// controlSelection builds the one reviewed-controls control-selection for a
// result. With mapped controls it emits include-controls; with none it emits
// include-all. The fallback is mandatory, not cosmetic: `reviewed-controls` is
// required on every OSCAL result and a control-selection MUST select either
// include-all or a non-empty include-controls (minItems 1) — an empty
// include-controls is schema-invalid. A whole-host scan always has some mapped
// rule so it never hits this, but a single-rule OSCAL document (the per-rule UI
// expansion) of a rule with no FrameworkRefs would otherwise emit invalid
// OSCAL. include-all is the conventional "no specific control subset enumerated"
// signal; a consumer reads it as "this result reviewed no framework-mapped
// control", with the rule's own objective carried on the finding target-id.
func controlSelection(refs []oscalControlRef) oscalControlSelection {
if len(refs) == 0 {
return oscalControlSelection{IncludeAll: &oscalIncludeAll{}}
}
return oscalControlSelection{IncludeControls: refs}
}

type oscalControlRef struct {
Expand Down Expand Up @@ -246,9 +269,6 @@ func ExportOSCAL(envelope *api.EvidenceEnvelope) ([]byte, error) {
for _, ref := range envelope.FrameworkRefs {
controlRefs = append(controlRefs, oscalControlRef{ControlID: oscalControlID(ref)})
}
if len(controlRefs) == 0 {
controlRefs = []oscalControlRef{}
}

observation := oscalObservation{
UUID: observationUUID,
Expand Down Expand Up @@ -295,7 +315,7 @@ func ExportOSCAL(envelope *api.EvidenceEnvelope) ([]byte, error) {
End: endStr,
ReviewedControls: oscalReviewedControls{
ControlSelections: []oscalControlSelection{
{IncludeControls: controlRefs},
controlSelection(controlRefs),
},
},
Findings: []oscalFinding{finding},
Expand Down
6 changes: 1 addition & 5 deletions internal/evidence/oscal_scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,6 @@ func ExportOSCALScan(result *api.ScanResult, hostname string) ([]byte, error) {
}
}
}
if controlRefs == nil {
controlRefs = []oscalControlRef{}
}

var backMatter *oscalBackMatter
if len(resources) > 0 {
backMatter = &oscalBackMatter{Resources: resources}
Expand All @@ -134,7 +130,7 @@ func ExportOSCALScan(result *api.ScanResult, hostname string) ([]byte, error) {
Start: now,
End: now,
ReviewedControls: oscalReviewedControls{
ControlSelections: []oscalControlSelection{{IncludeControls: controlRefs}},
ControlSelections: []oscalControlSelection{controlSelection(controlRefs)},
},
Findings: findings,
Observations: observations,
Expand Down
44 changes: 44 additions & 0 deletions internal/evidence/oscal_scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,50 @@ func TestExportOSCALScan_ParenControlIDCoerced(t *testing.T) {
}
}

// @spec evidence-oscal-scan
// @ac AC-09
func TestExportOSCALScan_NoFrameworkRefs_IncludeAll(t *testing.T) {
t.Log("// @spec evidence-oscal-scan")
t.Log("// @ac AC-09")
// A single rule with NO FrameworkRefs — what a per-rule OSCAL export of an
// unmapped rule looks like. reviewed-controls is required and an empty
// include-controls is schema-invalid, so this must fall back to include-all.
schema := loadOSCALSchema(t)
res := &api.ScanResult{
HostID: "host-a.example.com",
Outcomes: []api.RuleOutcome{{
RuleID: "rule_unmapped",
Status: api.CompliancePass,
Detail: "ok",
Evidence: []api.CheckEvidence{{
Method: "command_exec", Command: "true", Stdout: "ok\n", ExitCode: 0,
}},
}},
}
b, err := ExportOSCALScan(res, "host-a.example.com")
if err != nil {
t.Fatalf("ExportOSCALScan: %v", err)
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b))
if err != nil {
t.Fatalf("decode: %v", err)
}
if err := schema.Validate(doc); err != nil {
t.Fatalf("unmapped single-rule OSCAL is not 1.0.6-valid:\n%v", err)
}
var typed OSCALAssessmentResults
if err := json.Unmarshal(b, &typed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
sel := typed.AssessmentResults.Results[0].ReviewedControls.ControlSelections[0]
if sel.IncludeAll == nil {
t.Error("expected include-all for a rule with no framework refs")
}
if len(sel.IncludeControls) != 0 {
t.Errorf("expected no include-controls, got %v", sel.IncludeControls)
}
}

// @spec evidence-oscal-scan
// @ac AC-01
func TestWriteOSCALScan(t *testing.T) {
Expand Down
36 changes: 36 additions & 0 deletions pkg/kensa/oscal.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,42 @@ func WriteOSCALScan(w io.Writer, result *api.ScanResult, hostname string) error
return evidence.WriteOSCALScan(w, result, hostname)
}

// ExportOSCALOutcome renders a SINGLE rule outcome as its own OSCAL 1.0.6
// Assessment Results document — the per-rule counterpart of [ExportOSCALScan],
// for a UI that exports OSCAL from one expanded rule rather than the whole scan.
//
// OSCAL AR is a whole-document artifact, but the exporter is granularity-
// agnostic: a one-outcome document is a valid one-finding/one-observation AR.
// This helper exists so a caller does not hand-roll the single-outcome
// [api.ScanResult] and accidentally drop the host context — it copies HostID,
// Capabilities, and Platform from scan so the per-rule document is as
// self-describing as the whole-scan one. A nil scan is allowed (only the
// outcome and hostname are then used). The outcome is emitted even when it
// carries no FrameworkRefs: reviewed-controls falls back to OSCAL include-all,
// so an unmapped rule still produces a schema-valid document.
//
// For the whole scan, call [ExportOSCALScan] with the full result instead.
func ExportOSCALOutcome(scan *api.ScanResult, outcome api.RuleOutcome, hostname string) ([]byte, error) {
return evidence.ExportOSCALScan(singleOutcomeResult(scan, outcome), hostname)
}

// WriteOSCALOutcome is [ExportOSCALOutcome] streamed to w.
func WriteOSCALOutcome(w io.Writer, scan *api.ScanResult, outcome api.RuleOutcome, hostname string) error {
return evidence.WriteOSCALScan(w, singleOutcomeResult(scan, outcome), hostname)
}

// singleOutcomeResult wraps one outcome in a ScanResult that preserves the
// parent scan's host context (HostID/Capabilities/Platform) when available.
func singleOutcomeResult(scan *api.ScanResult, outcome api.RuleOutcome) *api.ScanResult {
one := &api.ScanResult{Outcomes: []api.RuleOutcome{outcome}}
if scan != nil {
one.HostID = scan.HostID
one.Capabilities = scan.Capabilities
one.Platform = scan.Platform
}
return one
}

// ExportOSCAL renders a signed [api.EvidenceEnvelope] (the audit-truth-of-record
// a remediation transaction produces) as an OSCAL 1.0.6 Assessment Results
// document. This is the remediation counterpart to [ExportOSCALScan]: where the
Expand Down
64 changes: 64 additions & 0 deletions pkg/kensa/oscal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,70 @@ func TestExportOSCAL_EnvelopePublic(t *testing.T) {
hasAssessmentResults(t, buf.Bytes())
}

// @spec oscal-public-export
// @ac AC-06
func TestExportOSCALOutcome_PerRule(t *testing.T) {
t.Log("// @spec oscal-public-export")
t.Log("// @ac AC-06")
scan := sampleScanResult() // one mapped outcome; carries HostID
outcome := scan.Outcomes[0]
b, err := ExportOSCALOutcome(scan, outcome, scan.HostID)
if err != nil {
t.Fatalf("ExportOSCALOutcome: %v", err)
}
hasAssessmentResults(t, b)
// Minimal local shape — package kensa cannot import the internal OSCAL
// structs, so decode just the fields under assertion.
var doc struct {
AR struct {
Results []struct {
Findings []struct {
Title string `json:"title"`
} `json:"findings"`
Observations []json.RawMessage `json:"observations"`
} `json:"results"`
} `json:"assessment-results"`
}
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
res := doc.AR.Results[0]
if len(res.Findings) != 1 || len(res.Observations) != 1 {
t.Errorf("per-rule doc should have 1 finding + 1 observation, got %d/%d",
len(res.Findings), len(res.Observations))
}
if res.Findings[0].Title != "rule_sysctl_aslr" {
t.Errorf("finding title = %q, want the expanded rule", res.Findings[0].Title)
}
var buf bytes.Buffer
if err := WriteOSCALOutcome(&buf, scan, outcome, scan.HostID); err != nil {
t.Fatalf("WriteOSCALOutcome: %v", err)
}
hasAssessmentResults(t, buf.Bytes())
}

// @spec oscal-public-export
// @ac AC-07
func TestExportOSCALOutcome_UnmappedRuleValid(t *testing.T) {
t.Log("// @spec oscal-public-export")
t.Log("// @ac AC-07")
// An expanded rule with no framework refs must still produce a valid
// single-rule document (include-all fallback in the exporter).
outcome := api.RuleOutcome{
RuleID: "rule_unmapped",
Status: api.CompliancePass,
Detail: "ok",
Evidence: []api.CheckEvidence{{
Method: "command_exec", Command: "true", Stdout: "ok\n",
}},
}
b, err := ExportOSCALOutcome(&api.ScanResult{HostID: "h1"}, outcome, "h1")
if err != nil {
t.Fatalf("ExportOSCALOutcome: %v", err)
}
hasAssessmentResults(t, b)
}

// @spec oscal-public-export
// @ac AC-05
func TestExportOSCAL_NilInputsError(t *testing.T) {
Expand Down
16 changes: 16 additions & 0 deletions specs/evidence/oscal-public-export.spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,21 @@ spec:
an error rather than panicking.
references_constraints: [C-02]
priority: medium
- id: AC-06
description: |
kensa.ExportOSCALOutcome(scan, outcome, hostname) /
WriteOSCALOutcome render a SINGLE rule outcome as its own
valid one-finding/one-observation OSCAL AR document — the
per-rule analogue of ExportOSCALScan — preserving the parent
scan's host context (HostID/Capabilities/Platform).
references_constraints: [C-01]
priority: high
- id: AC-07
description: |
ExportOSCALOutcome of a rule with no FrameworkRefs still
produces a schema-valid document (the exporter's include-all
fallback). The per-rule path must work for unmapped rules.
references_constraints: [C-01]
priority: high

tags: [evidence, oscal, public-api, pkg-kensa, v0.4.x, tier-2]
22 changes: 22 additions & 0 deletions specs/evidence/oscal-scan.spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ spec:
guarantee is exclusive to the remediation path.
type: business
enforcement: error
- id: C-08
description: |
A result with no framework-mapped control (e.g. a
single-rule document for a rule with no FrameworkRefs —
the per-rule UI expansion) MUST still be schema-valid.
`reviewed-controls` is required on every OSCAL result and a
control-selection must select include-all or a non-empty
include-controls (minItems 1); an empty include-controls is
invalid. The exporter MUST fall back to OSCAL include-all
when there are no control refs. (A whole-host scan never
hits this because some rule is always mapped; the per-rule
path does.)
type: technical
enforcement: error
- id: C-06
description: |
A check command is frequently a multi-line shell script.
Expand Down Expand Up @@ -201,5 +215,13 @@ spec:
emitted document validates against 1.0.6.
references_constraints: [C-07]
priority: high
- id: AC-09
description: |
A ScanResult of a single rule with no FrameworkRefs exports
OSCAL that validates against 1.0.6: reviewed-controls falls
back to include-all (include-controls is absent/empty). This
is the per-rule-expansion case for an unmapped rule.
references_constraints: [C-08]
priority: high

tags: [evidence, oscal, scan, native-evidence, v0.4.0, tier-2]