diff --git a/CHANGELOG.md b/CHANGELOG.md index 703fc2e..2f6380b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/evidence/oscal.go b/internal/evidence/oscal.go index b3e1120..2151575 100644 --- a/internal/evidence/oscal.go +++ b/internal/evidence/oscal.go @@ -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 { @@ -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, @@ -295,7 +315,7 @@ func ExportOSCAL(envelope *api.EvidenceEnvelope) ([]byte, error) { End: endStr, ReviewedControls: oscalReviewedControls{ ControlSelections: []oscalControlSelection{ - {IncludeControls: controlRefs}, + controlSelection(controlRefs), }, }, Findings: []oscalFinding{finding}, diff --git a/internal/evidence/oscal_scan.go b/internal/evidence/oscal_scan.go index dee1fce..a3039ad 100644 --- a/internal/evidence/oscal_scan.go +++ b/internal/evidence/oscal_scan.go @@ -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} @@ -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, diff --git a/internal/evidence/oscal_scan_test.go b/internal/evidence/oscal_scan_test.go index e9ff263..94b191a 100644 --- a/internal/evidence/oscal_scan_test.go +++ b/internal/evidence/oscal_scan_test.go @@ -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) { diff --git a/pkg/kensa/oscal.go b/pkg/kensa/oscal.go index 858a78d..5207c5e 100644 --- a/pkg/kensa/oscal.go +++ b/pkg/kensa/oscal.go @@ -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 diff --git a/pkg/kensa/oscal_test.go b/pkg/kensa/oscal_test.go index be0c8e0..cd8fe3f 100644 --- a/pkg/kensa/oscal_test.go +++ b/pkg/kensa/oscal_test.go @@ -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) { diff --git a/specs/evidence/oscal-public-export.spec.yaml b/specs/evidence/oscal-public-export.spec.yaml index 6c34a9e..4a0278b 100644 --- a/specs/evidence/oscal-public-export.spec.yaml +++ b/specs/evidence/oscal-public-export.spec.yaml @@ -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] diff --git a/specs/evidence/oscal-scan.spec.yaml b/specs/evidence/oscal-scan.spec.yaml index c500eed..fe44d6c 100644 --- a/specs/evidence/oscal-scan.spec.yaml +++ b/specs/evidence/oscal-scan.spec.yaml @@ -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. @@ -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]