From 2f1006980c1181adf332f1ef621568852e07f8c8 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Sun, 14 Jun 2026 21:24:04 -0400 Subject: [PATCH] feat(pkg/kensa): public rule read model for catalog consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tranche 1 of the OpenWatch read-model ask, scoped by the ownership test in docs/KENSA_OPENWATCH_BOUNDARY.md §3.3: publish the normalization Kensa already owns; carry facts, not policy. A consumer rendering a rule catalog previously had only the raw api.Rule — the heterogeneous `references` map and an unsummarized Remediation — so every consumer re-implemented internal/mappings.RefsFromReferences (the path the scanner already uses for ScanResult.Outcomes) and re-derived the framework-id scheme. This publishes the read model: - RuleFrameworkRefs(*api.Rule) — normalized []api.FrameworkRef, delegating to internal/mappings (no re-implementation; api/ is a leaf and can't import mappings, so this is a pkg/kensa function, not a method on api.Rule). - Framework / FrameworkFromID / Frameworks — a framework registry (cis_rhel9 -> "CIS (RHEL 9)") so consumers stop hardcoding prefixes; unknown frameworks degrade gracefully. - RuleSummary / RuleToSummary / LoadRuleSummaries — a lightweight catalog projection over existing Rule fields, loaded via the existing LoadRules path (539-rule production corpus verified). - RemediationSummary — derivable FACTS only: Available, Mechanisms, RestartsServices, RebootBehavior (boot-param/none). Per the boundary it omits RiskLevel (operator policy) and a blanket RequiresReboot (mechanism dictates reboot only for boot-param rules; deriving it elsewhere is a false-negative — needs an authored schema field, deferred). Types live on pkg/kensa (public-but-not-frozen); the read model is a derivation expected to grow. The frozen api/ surface is untouched. Spec rule-read-model (Tier 2, 7 ACs). No engine/capture/rollback touched. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 28 ++- pkg/kensa/catalog.go | 292 ++++++++++++++++++++++++++++++++ pkg/kensa/catalog_test.go | 191 +++++++++++++++++++++ specs/rule/read-model.spec.yaml | 152 +++++++++++++++++ 4 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 pkg/kensa/catalog.go create mode 100644 pkg/kensa/catalog_test.go create mode 100644 specs/rule/read-model.spec.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8437f..7c84a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,33 @@ the canonical names; short forms are listed in `cmd/kensa/flags.go`. ## Unreleased -(no changes yet) +### Added + +- **Public rule read model on `pkg/kensa`** — the normalized catalog + projection an `api` consumer needs to render a rule browser without + re-parsing the heterogeneous raw `references` map or loading the full + `[]*api.Rule`: + - `RuleFrameworkRefs(*api.Rule) []api.FrameworkRef` — the rule's + framework references in the same normalized form the scanner puts on + `ScanResult.Outcomes`, delegating to the existing + `internal/mappings` normalization (no re-implementation, no drift + from the canonical framework-id scheme). + - `Framework` + `FrameworkFromID(id)` + `Frameworks(rules)` — a + framework registry so consumers render labels/families consistently + (`cis_rhel9` → `{Family:"cis", Version:"rhel9", Label:"CIS (RHEL 9)"}`) + instead of hardcoding prefix strings; unknown frameworks degrade + gracefully. + - `RuleSummary` + `RuleToSummary` + `LoadRuleSummaries(dir, paths, vars)` + — a lightweight catalog row (id/title/description/rationale/severity/ + category/tags/platforms/transactional + normalized framework refs + + remediation summary), loaded via the existing `LoadRules` path. + - `RemediationSummary` carries derivable **facts only**: `Available`, + `Mechanisms`, `RestartsServices`, and `RebootBehavior` + (`boot-param`/`none`). Per the Kensa/OpenWatch boundary it + deliberately omits a remediation risk level (operator policy) and a + blanket requires-reboot boolean (not derivable for change-specific + cases). Spec `rule-read-model` (Tier 2). The frozen `api/` surface is + untouched. ## v0.4.2 — 2026-06-14 diff --git a/pkg/kensa/catalog.go b/pkg/kensa/catalog.go new file mode 100644 index 0000000..80a4e1a --- /dev/null +++ b/pkg/kensa/catalog.go @@ -0,0 +1,292 @@ +package kensa + +import ( + "sort" + "strconv" + "strings" + + "github.com/Hanalyx/kensa/api" + "github.com/Hanalyx/kensa/internal/mappings" +) + +// This file is the public, normalized rule read model — the catalog projection +// an external consumer (e.g. OpenWatch's rule browser) needs without parsing +// the heterogeneous raw `references` map or loading the full []*api.Rule just to +// render a list. It publishes derivations Kensa already owns: +// +// - RuleFrameworkRefs wraps internal/mappings (the SAME normalization the +// scanner uses on ScanResult.Outcomes), so consumers stop re-parsing the +// raw References map and drifting from Kensa's framework-id scheme. +// - RuleSummary / RemediationSummary are a lightweight projection of fields +// api.Rule already carries. +// +// These types live on pkg/kensa (public-but-not-frozen), not api/, deliberately: +// the read model is a derivation that will grow, and api/ is frozen. Per the +// Kensa/OpenWatch boundary (docs/KENSA_OPENWATCH_BOUNDARY.md §3.3) it carries +// only FACTS Kensa can derive — it intentionally does NOT carry a remediation +// risk level (that is operator policy, computed by the consumer) or a blanket +// RequiresReboot boolean (not derivable for the change-specific cases; see +// RebootBehavior). + +// FrameworkRef is re-exported from api for the read model's convenience; it is +// the same type the scanner puts on every outcome. +type FrameworkRef = api.FrameworkRef + +// Framework is a normalized descriptor for one compliance framework (or +// framework-version), so consumers render labels and group by family +// consistently instead of hardcoding prefix strings. +type Framework struct { + // ID is the canonical framework id exactly as it appears on + // [api.FrameworkRef.FrameworkID] — e.g. "cis_rhel9", "nist_800_53". + ID string + // Family is the framework family without the version discriminator — + // e.g. "cis", "stig", "nist_800_53". + Family string + // Version is the version/profile discriminator for versioned frameworks + // (e.g. "rhel9"), or "" for unversioned flat-list frameworks. + Version string + // Label is a human display string, e.g. "CIS (RHEL 9)" or "NIST 800-53". + Label string +} + +// frameworkFamilies maps a framework family key to its human label. Kensa owns +// the rule-schema framework vocabulary, so this is the canonical label source; +// adding a framework to the corpus means adding its label here. Unknown +// families degrade gracefully (see FrameworkFromID). +var frameworkFamilies = map[string]string{ + "cis": "CIS", + "stig": "STIG", + "nist_800_53": "NIST 800-53", + "pci_dss_4": "PCI DSS 4.0", + "srg": "SRG", + "iso27001_2022": "ISO 27001:2022", + "cmmc_l2": "CMMC Level 2", + "hipaa": "HIPAA", +} + +// FrameworkFromID parses a framework id (as found on +// [api.FrameworkRef.FrameworkID]) into its normalized [Framework] descriptor. +// It is a pure function — no corpus needed — so a consumer can render any +// FrameworkRef it holds. Unknown families degrade gracefully: Family is the +// whole id, Version is empty, and Label is the id verbatim, so a framework +// added to rule YAML before this map is updated still renders (just without a +// pretty label) rather than breaking the consumer. +func FrameworkFromID(id string) Framework { + // Match the longest known family first so families that are prefixes of + // others (none today, but future-proof) resolve correctly. + fams := make([]string, 0, len(frameworkFamilies)) + for f := range frameworkFamilies { + fams = append(fams, f) + } + sort.Slice(fams, func(i, j int) bool { return len(fams[i]) > len(fams[j]) }) + + for _, fam := range fams { + switch { + case id == fam: + return Framework{ID: id, Family: fam, Version: "", Label: frameworkFamilies[fam]} + case strings.HasPrefix(id, fam+"_"): + version := strings.TrimPrefix(id, fam+"_") + return Framework{ + ID: id, + Family: fam, + Version: version, + Label: frameworkFamilies[fam] + " (" + humanizeVersion(version) + ")", + } + } + } + // Unknown framework: degrade to the raw id. + return Framework{ID: id, Family: id, Version: "", Label: id} +} + +// humanizeVersion renders a version discriminator for display. It special-cases +// the "rhelN" OS-version form the corpus uses ("rhel9" -> "RHEL 9"); anything +// else is returned verbatim. +func humanizeVersion(v string) string { + if rest, ok := strings.CutPrefix(v, "rhel"); ok { + if _, err := strconv.Atoi(rest); err == nil { + return "RHEL " + rest + } + } + return v +} + +// RuleFrameworkRefs returns the rule's compliance-framework references in +// normalized [api.FrameworkRef] form. It is the public entry point to the SAME +// normalization the scanner applies to every outcome +// (internal/mappings.RefsFromReferences), so a consumer reads the typed tuple +// instead of re-parsing the heterogeneous raw [api.Rule.References] map and +// re-deriving Kensa's framework-id scheme. Nil rule yields nil. +func RuleFrameworkRefs(r *api.Rule) []api.FrameworkRef { + if r == nil { + return nil + } + return mappings.RefsFromReferences(r.References) +} + +// Frameworks returns the distinct frameworks referenced across a set of rules, +// each as a normalized [Framework], sorted by id. Useful for building a catalog +// filter ("show me the frameworks this corpus covers") without the consumer +// deduping framework ids itself. +func Frameworks(rules []*api.Rule) []Framework { + seen := map[string]bool{} + var out []Framework + for _, r := range rules { + for _, ref := range RuleFrameworkRefs(r) { + if !seen[ref.FrameworkID] { + seen[ref.FrameworkID] = true + out = append(out, FrameworkFromID(ref.FrameworkID)) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// RuleSummary is the lightweight catalog projection of an [api.Rule] — the +// fields a rule browser shows in a list/detail view, with the framework refs +// normalized and the remediation summarized. It deliberately omits the heavy +// Implementations/Check internals; load those via [LoadRules] when a consumer +// actually needs to scan or remediate. +type RuleSummary struct { + ID string + Title string + Description string + Rationale string + Severity string + Category string + Tags []string + FrameworkRefs []api.FrameworkRef + Platforms []api.Platform + // Transactional reports whether the rule's apply path is a capturable, + // atomic transaction (vs. a non-capturable best-effort or staged change). + Transactional bool + Remediation RemediationSummary +} + +// RemediationSummary is the host-independent, FACTUAL summary of a rule's +// remediation — what an operator wants to know before remediating, derived +// only from data the rule already carries. Per the Kensa/OpenWatch boundary it +// carries no risk level (operator policy) and no blanket RequiresReboot (not +// derivable; see RebootBehavior). +type RemediationSummary struct { + // Available reports whether the rule has an automated (non-manual) + // remediation in any implementation. + Available bool + // Mechanisms are the distinct remediation mechanisms across all + // implementations (e.g. "config_set", "service_masked"), sorted. Host- + // independent: a host selects one implementation, but the catalog row has + // no host, so all candidate mechanisms are listed. + Mechanisms []string + // RestartsServices are the distinct services the remediation reloads or + // restarts (from the rule's Reload/Restart hooks), sorted. A signal that + // applying the rule will bounce a service. + RestartsServices []string + // RebootBehavior is the derivable reboot signal: + // - "boot-param": the remediation stages a boot parameter (grub), + // PENDING until the operator reboots — Kensa models this directly. + // - "none": no reboot is inherent to the mechanism. + // This is NOT a complete "requires reboot" answer: a few rules require a + // reboot because of the SPECIFIC change (e.g. the auditd `-e 2` immutable + // flag, enabling SELinux from disabled) using mechanisms that hundreds of + // non-reboot rules also use. Deriving reboot from mechanism there would be + // a dangerous false-negative; a complete signal needs an authored + // `requires_reboot:` rule-schema field (deferred). See + // docs/KENSA_OPENWATCH_BOUNDARY.md §3.3. + RebootBehavior string +} + +// Reboot behavior values for [RemediationSummary.RebootBehavior]. +const ( + RebootNone = "none" + RebootBootParam = "boot-param" +) + +// bootParamMechanisms are the remediation mechanisms that stage a boot +// parameter (PENDING until reboot). +var bootParamMechanisms = map[string]bool{ + "grub_parameter_set": true, + "grub_parameter_remove": true, +} + +// RuleToSummary projects an [api.Rule] into its [RuleSummary]. Nil yields a +// zero RuleSummary. +func RuleToSummary(r *api.Rule) RuleSummary { + if r == nil { + return RuleSummary{} + } + return RuleSummary{ + ID: r.ID, + Title: r.Title, + Description: r.Description, + Rationale: r.Rationale, + Severity: r.Severity, + Category: r.Category, + Tags: r.Tags, + FrameworkRefs: RuleFrameworkRefs(r), + Platforms: r.Platforms, + Transactional: r.Transactional, + Remediation: remediationSummary(r), + } +} + +// remediationSummary derives the factual remediation summary from a rule's +// implementations. +func remediationSummary(r *api.Rule) RemediationSummary { + mechSet := map[string]bool{} + svcSet := map[string]bool{} + available := false + reboot := RebootNone + + for _, impl := range r.Implementations { + m := impl.Remediation.Mechanism + if m != "" { + mechSet[m] = true + if m != "manual" { + available = true + } + if bootParamMechanisms[m] { + reboot = RebootBootParam + } + } + for _, svc := range []string{impl.Remediation.Restart, impl.Remediation.Reload} { + if svc != "" { + svcSet[svc] = true + } + } + } + + return RemediationSummary{ + Available: available, + Mechanisms: sortedKeys(mechSet), + RestartsServices: sortedKeys(svcSet), + RebootBehavior: reboot, + } +} + +func sortedKeys(m map[string]bool) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// LoadRuleSummaries loads the rule corpus and projects each rule to a +// [RuleSummary] — the catalog read path. It reuses [LoadRules] for +// path-resolution, variable substitution, and strict parsing, so the same +// corpus a scan would run is what the catalog shows. Arguments match LoadRules. +func LoadRuleSummaries(dir string, paths []string, vars map[string]string) ([]RuleSummary, error) { + rules, err := LoadRules(dir, paths, vars) + if err != nil { + return nil, err + } + out := make([]RuleSummary, len(rules)) + for i, r := range rules { + out[i] = RuleToSummary(r) + } + return out, nil +} diff --git a/pkg/kensa/catalog_test.go b/pkg/kensa/catalog_test.go new file mode 100644 index 0000000..b205dc7 --- /dev/null +++ b/pkg/kensa/catalog_test.go @@ -0,0 +1,191 @@ +package kensa + +import ( + "reflect" + "testing" + + "github.com/Hanalyx/kensa/api" +) + +// sampleRule builds an api.Rule exercising the read-model projection: CIS +// versioned + NIST flat refs, two implementations (an automated config_set and +// a service restart), platforms, transactional. +func sampleRule() *api.Rule { + return &api.Rule{ + ID: "sshd_permit_root_login", + Title: "Disable SSH root login", + Description: "PermitRootLogin must be no", + Rationale: "Direct root login bypasses accountability", + Severity: "high", + Category: "access-control", + Tags: []string{"ssh", "cis"}, + Transactional: true, + Platforms: []api.Platform{{Family: "rhel", MinVersion: 9}}, + References: map[string]interface{}{ + "cis": map[string]interface{}{"rhel9": map[string]interface{}{"section": "5.2.8"}}, + "nist_800_53": []interface{}{"AC-6", "AC-17"}, + }, + Implementations: []api.Implementation{ + { + Remediation: api.Remediation{Mechanism: "config_set", Restart: "sshd"}, + }, + { + Remediation: api.Remediation{Mechanism: "config_set_dropin", Reload: "sshd"}, + }, + }, + } +} + +// @spec rule-read-model +// @ac AC-01 +func TestRuleFrameworkRefs_NormalizesViaMappings(t *testing.T) { + t.Log("// @spec rule-read-model") + t.Log("// @ac AC-01") + refs := RuleFrameworkRefs(sampleRule()) + // Expect the CIS versioned ref + both NIST flat refs, normalized to the + // same FrameworkRef tuples the scanner produces. + want := map[string]string{ + "cis_rhel9": "5.2.8", + "nist_800_53": "", // two entries; checked below + } + got := map[string][]string{} + for _, r := range refs { + got[r.FrameworkID] = append(got[r.FrameworkID], r.ControlID) + } + if len(got["cis_rhel9"]) != 1 || got["cis_rhel9"][0] != want["cis_rhel9"] { + t.Errorf("cis_rhel9 refs = %v, want [5.2.8]", got["cis_rhel9"]) + } + if len(got["nist_800_53"]) != 2 { + t.Errorf("nist_800_53 refs = %v, want 2 entries (AC-6, AC-17)", got["nist_800_53"]) + } + if RuleFrameworkRefs(nil) != nil { + t.Error("nil rule should yield nil refs") + } +} + +// @spec rule-read-model +// @ac AC-02 +func TestFrameworkFromID(t *testing.T) { + t.Log("// @spec rule-read-model") + t.Log("// @ac AC-02") + cases := []struct { + id, family, version, label string + }{ + {"cis_rhel9", "cis", "rhel9", "CIS (RHEL 9)"}, + {"stig_rhel10", "stig", "rhel10", "STIG (RHEL 10)"}, + {"nist_800_53", "nist_800_53", "", "NIST 800-53"}, + {"pci_dss_4", "pci_dss_4", "", "PCI DSS 4.0"}, + // Unknown framework degrades gracefully to the raw id. + {"acme_framework_v9", "acme_framework_v9", "", "acme_framework_v9"}, + } + for _, c := range cases { + got := FrameworkFromID(c.id) + if got.Family != c.family || got.Version != c.version || got.Label != c.label { + t.Errorf("FrameworkFromID(%q) = %+v, want family=%q version=%q label=%q", + c.id, got, c.family, c.version, c.label) + } + } +} + +// @spec rule-read-model +// @ac AC-03 +func TestRuleToSummary_ProjectsFields(t *testing.T) { + t.Log("// @spec rule-read-model") + t.Log("// @ac AC-03") + s := RuleToSummary(sampleRule()) + if s.ID != "sshd_permit_root_login" || s.Title != "Disable SSH root login" || + s.Severity != "high" || s.Category != "access-control" || !s.Transactional { + t.Errorf("scalar projection wrong: %+v", s) + } + if !reflect.DeepEqual(s.Tags, []string{"ssh", "cis"}) { + t.Errorf("tags = %v", s.Tags) + } + if len(s.FrameworkRefs) != 3 { + t.Errorf("expected 3 framework refs, got %d", len(s.FrameworkRefs)) + } + if len(s.Platforms) != 1 || s.Platforms[0].Family != "rhel" { + t.Errorf("platforms = %v", s.Platforms) + } + if RuleToSummary(nil).ID != "" { + t.Error("nil rule should yield zero summary") + } +} + +// @spec rule-read-model +// @ac AC-04 +func TestRemediationSummary_DerivesFacts(t *testing.T) { + t.Log("// @spec rule-read-model") + t.Log("// @ac AC-04") + s := RuleToSummary(sampleRule()).Remediation + if !s.Available { + t.Error("expected Available=true for a config_set rule") + } + if !reflect.DeepEqual(s.Mechanisms, []string{"config_set", "config_set_dropin"}) { + t.Errorf("mechanisms = %v (want sorted distinct)", s.Mechanisms) + } + if !reflect.DeepEqual(s.RestartsServices, []string{"sshd"}) { + t.Errorf("restarts = %v (want distinct [sshd])", s.RestartsServices) + } + // A manual-only rule is not "available". + manual := &api.Rule{ID: "m", Implementations: []api.Implementation{{Remediation: api.Remediation{Mechanism: "manual"}}}} + if RuleToSummary(manual).Remediation.Available { + t.Error("manual-only rule must have Available=false") + } +} + +// @spec rule-read-model +// @ac AC-05 +func TestRemediationSummary_RebootBehavior(t *testing.T) { + t.Log("// @spec rule-read-model") + t.Log("// @ac AC-05") + // grub mechanism -> boot-param. + grub := &api.Rule{ID: "g", Implementations: []api.Implementation{{Remediation: api.Remediation{Mechanism: "grub_parameter_set"}}}} + if got := RuleToSummary(grub).Remediation.RebootBehavior; got != RebootBootParam { + t.Errorf("grub rule RebootBehavior = %q, want %q", got, RebootBootParam) + } + // non-boot mechanism -> none (even for audit_rule_set, whose change-specific + // reboot cases are deliberately NOT derived here — see the boundary). + audit := &api.Rule{ID: "a", Implementations: []api.Implementation{{Remediation: api.Remediation{Mechanism: "audit_rule_set"}}}} + if got := RuleToSummary(audit).Remediation.RebootBehavior; got != RebootNone { + t.Errorf("audit rule RebootBehavior = %q, want %q", got, RebootNone) + } +} + +// @spec rule-read-model +// @ac AC-06 +func TestLoadRuleSummaries_ProductionCorpus(t *testing.T) { + t.Log("// @spec rule-read-model") + t.Log("// @ac AC-06") + sums, err := LoadRuleSummaries("../../rules", nil, nil) + if err != nil { + t.Fatalf("LoadRuleSummaries on production corpus: %v", err) + } + if len(sums) != 539 { + t.Errorf("expected 539 rule summaries, got %d", len(sums)) + } + // Every summary has an ID and at least the NIST ref (corpus-wide). + for _, s := range sums { + if s.ID == "" { + t.Fatal("summary with empty ID") + } + if len(s.FrameworkRefs) == 0 { + t.Errorf("rule %s projected zero framework refs", s.ID) + } + } +} + +// @spec rule-read-model +// @ac AC-07 +func TestFrameworks_DistinctSorted(t *testing.T) { + t.Log("// @spec rule-read-model") + t.Log("// @ac AC-07") + rules := []*api.Rule{sampleRule(), sampleRule()} // duplicate refs + fws := Frameworks(rules) + // Distinct: cis_rhel9 + nist_800_53, sorted by id. + if len(fws) != 2 { + t.Fatalf("expected 2 distinct frameworks, got %d: %+v", len(fws), fws) + } + if fws[0].ID != "cis_rhel9" || fws[1].ID != "nist_800_53" { + t.Errorf("frameworks not sorted/distinct: %+v", fws) + } +} diff --git a/specs/rule/read-model.spec.yaml b/specs/rule/read-model.spec.yaml new file mode 100644 index 0000000..0b5c45e --- /dev/null +++ b/specs/rule/read-model.spec.yaml @@ -0,0 +1,152 @@ +spec: + id: rule-read-model + version: 0.1.0 + status: draft + tier: 2 + + context: + system: kensa + feature: rule-read-model + description: | + A program importing the api package can load rules (LoadRules, + rule-public-loader) and read scan verdicts (ScanResult.Outcomes), + but to render a rule CATALOG it had only the raw api.Rule: the + heterogeneous `references` map (CIS/STIG versioned-object vs + nist_800_53 flat-list), and a Remediation struct with no summarized + mechanisms or side-effects. So every consumer re-implemented the + same normalization Kensa already runs internally + (internal/mappings.RefsFromReferences, the path the scanner uses for + ScanResult.Outcomes) and re-derived the framework-id scheme. + + This spec publishes the normalized rule read model — the derivation + Kensa already owns — so consumers stop re-parsing References and + drifting from the canonical scheme. It lives on pkg/kensa + (public-but-not-frozen): a derivation that will grow, kept off the + frozen api/. + + Per the Kensa/OpenWatch boundary (docs/KENSA_OPENWATCH_BOUNDARY.md + §3.3) it carries only FACTS Kensa can derive. It deliberately does + NOT carry a remediation risk level (operator policy, computed by the + consumer) or a blanket RequiresReboot boolean (not derivable for the + change-specific cases; RebootBehavior is the honest mechanism-level + signal instead). + + objective: + summary: | + Add to pkg/kensa: RuleFrameworkRefs (Rule -> normalized + []api.FrameworkRef via internal/mappings), a Framework registry + (FrameworkFromID / Frameworks), and a RuleSummary / RemediationSummary + catalog projection with LoadRuleSummaries. The RemediationSummary + carries derivable facts only (Available, Mechanisms, + RestartsServices, RebootBehavior) — no risk level, no blanket + requires-reboot. + scope: + includes: + - RuleFrameworkRefs (delegates to internal/mappings — no + re-implemented normalization) + - Framework + FrameworkFromID (pure parse, graceful on unknown) + + Frameworks (distinct across a rule set) + - RuleSummary + RuleToSummary projection + - RemediationSummary factual derivation + RebootBehavior + - LoadRuleSummaries (reuses LoadRules) + excludes: + - RemediationSummary.RiskLevel — operator policy, declined per + boundary §3.3 (consumer computes from the facts) + - A blanket RequiresReboot bool — not derivable for change- + specific cases; needs an authored rule-schema field (deferred) + - Any change to the frozen api/ surface + - Rendering/labels-as-UI (the consumer renders; this provides + the normalized data) + + constraints: + - id: C-01 + description: | + RuleFrameworkRefs MUST delegate to + internal/mappings.RefsFromReferences — the same normalization the + scanner applies to ScanResult.Outcomes — and MUST NOT + re-implement the References parse. A second implementation would + drift from the canonical framework-id scheme. (api/ is a leaf and + cannot import internal/mappings, so this is a pkg/kensa function, + not a method on api.Rule.) + type: technical + enforcement: error + - id: C-02 + description: | + The read-model types live on pkg/kensa (public-but-not-frozen), + not api/. The frozen api/ surface MUST NOT change. The read model + is a derivation that is expected to grow (e.g. a future authored + reboot field), so freezing it would be wrong. + type: technical + enforcement: error + - id: C-03 + description: | + RemediationSummary MUST carry only facts Kensa can derive from the + rule. It MUST NOT carry a risk level (operator policy — depends on + the host's environment) nor a blanket RequiresReboot boolean + (mechanism dictates reboot only for boot-param rules; deriving it + for change-specific cases is a false-negative). RebootBehavior is + the honest mechanism-level signal: "boot-param" or "none". See + docs/KENSA_OPENWATCH_BOUNDARY.md §3.3. + type: business + enforcement: error + + acceptance_criteria: + - id: AC-01 + description: | + RuleFrameworkRefs(rule) returns the rule's references normalized + to []api.FrameworkRef — CIS/STIG versioned refs as + "_" and nist_800_53 flat refs — matching the + scanner's normalization. Nil rule yields nil. + references_constraints: [C-01] + priority: critical + - id: AC-02 + description: | + FrameworkFromID parses a framework id into {ID, Family, Version, + Label}: versioned ("cis_rhel9" -> family cis, version rhel9, label + "CIS (RHEL 9)") and flat ("nist_800_53" -> "NIST 800-53"). An + unknown framework degrades gracefully (Family=id, Label=id) rather + than erroring. + references_constraints: [C-01] + priority: high + - id: AC-03 + description: | + RuleToSummary projects an api.Rule's id/title/description/ + rationale/severity/category/tags/transactional, its normalized + FrameworkRefs, and its platforms into a RuleSummary. Nil yields a + zero summary. + references_constraints: [C-02] + priority: high + - id: AC-04 + description: | + RemediationSummary derives Available (any non-manual mechanism), + Mechanisms (distinct, sorted across implementations), and + RestartsServices (distinct Restart/Reload services). A manual-only + rule has Available=false. + references_constraints: [C-03] + priority: high + - id: AC-05 + description: | + RebootBehavior is "boot-param" when any implementation uses a grub + boot-parameter mechanism, "none" otherwise — and is NOT treated as + a complete requires-reboot answer (an audit_rule_set rule that + needs reboot for the immutable flag still reports "none", by + design; the boundary defers the complete signal to an authored + schema field). + references_constraints: [C-03] + priority: high + - id: AC-06 + description: | + LoadRuleSummaries(dir, paths, vars) loads the production corpus + via LoadRules and projects every rule: all 539 rules load and each + summary has a non-empty ID and at least one normalized framework + ref. + references_constraints: [C-01, C-02] + priority: critical + - id: AC-07 + description: | + Frameworks(rules) returns the distinct frameworks referenced + across a rule set, deduplicated and sorted by id. + references_constraints: [C-01] + priority: medium + + tags: [rule, read-model, catalog, public-api, pkg-kensa, boundary, tier-2]