From 9cfa51b358ef7ff3968b4cae4b29c91b2063bb3a Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Fri, 25 Sep 2026 21:42:20 -0400 Subject: [PATCH 01/10] feat(kensa): integrate Kensa v0.10.0 Pins github.com/Hanalyx/kensa v0.10.0 (only kensa moved in go.sum). The engine constant, system-kensa-executor 2.10.0 (context, C-13) and the third-party notices follow. Corpus: 769 to 779 rules, two new framework keys, nist_800_171 and cmmc_l2, 324 rules each, measured through pkg/kensa.RuleFrameworkRefs. The backend family labels and the host-detail lens chips name them "NIST 800-171" and "CMMC Level 2"; the chip transform would have rendered "CMMC L 2" (frontend-host-compliance-tab 1.7.0, AC-12). The README, the scanning guide framework table and the distribution matrix are re-derived from v0.10.0. Variables: v0.10.0 ships eight corpus-used variables with an empty default. The catalog test bounded the list at a constant 29; it now derives the bound from BuiltInVars and checks membership. api-system-scan-config 1.5.0 adds AC-11: a list override reaches the rule's set_compare parameters verbatim on reload, and removing it restores the default. configure_me stays limited to the three placeholders (C-07); extending it is a decision, not part of this change. The scanning guide gains a Scan variables section naming the eight, the list syntax and the fact that values are not type checked. Mutation checks, each red then restored by inverse edit with the hash verified: reload without overrides (AC-11), a wrong CMMC backend label (system-compliance-lens AC-02), a missing CMMC chip label (AC-12). Findings filed, not fixed here: CP bugs/OW-080 (override values are stored and applied without a type check; the Kensa checker is internal, features/KN-OW-022) and bugs/OW-081 (the rc.5 engine cannot load the 0.10.0 corpus and the packages do not forbid that pairing; a decision is needed before this merges). --- CHANGELOG.md | 30 ++++++++ README.md | 9 +-- THIRD-PARTY-NOTICES.md | 2 +- docs/guides/LINUX_DISTRIBUTION_SUPPORT.md | 18 ++--- docs/guides/SCANNING_AND_COMPLIANCE.md | 53 ++++++++++++-- .../src/pages/host-detail/ComplianceTab.tsx | 13 +++- .../pages/host-detail-compliance-tab.test.tsx | 20 +++++- go.mod | 2 +- go.sum | 4 +- internal/framework/framework.go | 3 + internal/framework/framework_test.go | 5 ++ internal/kensa/doc.go | 2 +- internal/kensa/types.go | 2 +- internal/kensa/variables_test.go | 69 ++++++++++++++++++- specs/api/system-scan-config.spec.yaml | 6 +- specs/frontend/host-compliance-tab.spec.yaml | 14 +++- specs/system/kensa-executor.spec.yaml | 6 +- 17 files changed, 224 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 839d65bab..cd946415c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,36 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- **Kensa 0.10.0.** The rule corpus grows from 769 to 779 rules and gains two + framework keys, `nist_800_171` (NIST SP 800-171 Rev 2, cited at objective + level such as `3.1.11[b]`) and `cmmc_l2`. Each is referenced by 324 rules, + and the lens picker labels them "NIST 800-171" and "CMMC Level 2". + + **Install `openwatch` and `kensa-rules` in one transaction.** An earlier + `openwatch` cannot load the 0.10.0 corpus: the service starts and every scan + fails. The packages do not yet forbid that pairing (CP `bugs/OW-081`). + + **Verdicts change on existing hosts, so scores can move after the first scan + on this release.** The change comes from the rules, not the hosts: + + - `no-unauthorized-accounts` passed every host without comparing anything. + It now reports skipped until `authorized_local_accounts` is declared. + - `shell-timeout` fails RHEL hosts set between 601 and 900 seconds and + requires `TMOUT` to be readonly on RHEL. It absorbs `shell-timeout-600` + and `shell-idle-timeout-tmout`, whose old verdicts leave the current score + after each host's next completed scan. + - Rules that passed without checking now report a real verdict: + `security-updates-installed`, `nftables-default-deny`, + `journald-to-rsyslog`, `selinux-user-mapping` and + `firewalld-loopback-source`. + - Eight audit and session rules, and `no-unauthorized-accounts`, now run on + RHEL 8 instead of reporting not applicable. + + Eight new scan variables ship with no default, and seven rules report + skipped until theirs is declared. The scanning guide lists them under + "Scan variables". Values are not type checked when saved (CP + `bugs/OW-080`). + - **When an outcome cannot be confirmed, OpenWatch says so.** A sign-in, refresh, logout or administrative change whose commit result is unknown answers 503 `server.error`, not retryable, and claims neither success nor diff --git a/README.md b/README.md index 2931d4e86..043161c6f 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ OpenWatch, it is a query: answered in seconds, backed by machine-verifiable evidence, exportable as CSV, JSON, PDF or OSCAL. OpenWatch is a continuous compliance platform for Linux fleets under CIS, -STIG, NIST 800-53 and PCI DSS. It connects to your servers over SSH, runs the -769-rule [Kensa](https://github.com/Hanalyx/kensa) corpus, and keeps posture +STIG, NIST 800-53, NIST 800-171, CMMC Level 2 and PCI DSS. It connects to your +servers over SSH, runs the 779-rule [Kensa](https://github.com/Hanalyx/kensa) +corpus, and keeps posture as a timeline: what is passing now, what was passing last Tuesday, what drifted since your last assessment, and what needs attention before the next one. **[Read the introduction](docs/guides/INTRODUCTION.md)** for what it does @@ -31,7 +32,7 @@ and how it is built. > React 19 + TanStack frontend (`frontend/`), PostgreSQL-only. The current > version is `0.8.0-rc.5`, on the general-availability line that opened with `0.2.0`. -![OpenWatch Host Management: a fleet of RHEL and Ubuntu hosts with per-host compliance scores against the 769-rule Kensa corpus](docs/images/host-management.png) +![OpenWatch Host Management: a fleet of RHEL and Ubuntu hosts with per-host compliance scores against the Kensa corpus](docs/images/host-management.png) ## Deploy in 10 minutes @@ -86,7 +87,7 @@ model. Then three starting points: an **operator** reads ## Part of the Hanalyx Compliance Platform -OpenWatch is the compliance operating system: the dashboard, the scheduler, the governance layer. **[Kensa](https://github.com/Hanalyx/kensa)** is the compliance engine underneath: 769 rules, 29 remediation mechanisms, automatic rollback, all over SSH. +OpenWatch is the compliance operating system: the dashboard, the scheduler, the governance layer. **[Kensa](https://github.com/Hanalyx/kensa)** is the compliance engine underneath: 779 rules, 29 remediation mechanisms, automatic rollback, all over SSH. If you want a CLI that integrates into scripts and pipelines, start with Kensa. If you want a platform for your team with a dashboard, scheduling, and audit workflows, start here. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 4977e77fa..a1414b47a 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -50,7 +50,7 @@ OpenWatch and their licenses. It is generated; see "Regeneration" below. | `modernc.org/mathutil` | v1.7.1 | BSD | | `modernc.org/memory` | v1.11.0 | BSD | | `modernc.org/sqlite` | v1.53.0 | BSD | -| `github.com/Hanalyx/kensa` | v0.9.0 | BSL-1.1 | +| `github.com/Hanalyx/kensa` | v0.10.0 | BSL-1.1 | | `github.com/apapsch/go-jsonmerge/v2` | v2.0.0 | MIT | | `github.com/boombuler/barcode` | v1.1.0 | MIT | | `github.com/BurntSushi/toml` | v1.6.0 | MIT | diff --git a/docs/guides/LINUX_DISTRIBUTION_SUPPORT.md b/docs/guides/LINUX_DISTRIBUTION_SUPPORT.md index 54d26c63f..3b8337808 100644 --- a/docs/guides/LINUX_DISTRIBUTION_SUPPORT.md +++ b/docs/guides/LINUX_DISTRIBUTION_SUPPORT.md @@ -14,7 +14,7 @@ They describe one corpus version and change whenever the bundled Kensa dependency moves, so they are dated and reproducible rather than stated as permanent facts. -**Derived 2026-09-11** from the corpus this repository ships, by reading each +**Derived 2026-09-25** from the corpus this repository ships, by reading each rule's `platforms:` declaration. Reproduce it yourself: ```sh @@ -123,15 +123,15 @@ sensitivity: ### Per-OS rule applicability Read from each rule's `platforms:` block in the bundled corpus. **Derived -2026-09-11 from Kensa v0.9.0**, the version `go.mod` pins: +2026-09-25 from Kensa v0.10.0**, the version `go.mod` pins: | OS family | Rules applicable | |-----------|-------------------| -| RHEL family (RHEL, Rocky, AlmaLinux, CentOS Stream, Oracle Linux) | 677 | -| Ubuntu (22.04, 24.04) | 272 | +| RHEL family (RHEL, Rocky, AlmaLinux, CentOS Stream, Oracle Linux) | 689 | +| Ubuntu (22.04, 24.04) | 284 | -A rule can apply to several platforms, so these counts overlap: 497 rules -declare RHEL only, 92 declare Ubuntu only, and 180 declare both, giving 769 +A rule can apply to several platforms, so these counts overlap: 495 rules +declare RHEL only, 90 declare Ubuntu only, and 194 declare both, giving 779 rules in total. **Expect these to move.** They are a property of one corpus version, not of @@ -251,9 +251,9 @@ a compliance score. partial-success semantics. - Kensa filters its corpus by the host's detected platform at scan time. - Rule corpus applicability, read from the corpus platform declarations and - derived 2026-09-11 from Kensa v0.9.0 as pinned in `go.mod`: **769 rules** - spanning RHEL 8/9/10 and Ubuntu 22.04/24.04, of which 677 apply to the RHEL - family and 272 to Ubuntu. Rules can apply to more than one platform, so + derived 2026-09-25 from Kensa v0.10.0 as pinned in `go.mod`: **779 rules** + spanning RHEL 8/9/10 and Ubuntu 22.04/24.04, of which 689 apply to the RHEL + family and 284 to Ubuntu. Rules can apply to more than one platform, so these overlap. - Framework keys and per-key rule counts: see [Available frameworks](SCANNING_AND_COMPLIANCE.md#available-frameworks), diff --git a/docs/guides/SCANNING_AND_COMPLIANCE.md b/docs/guides/SCANNING_AND_COMPLIANCE.md index c1651081f..d5cc28cce 100644 --- a/docs/guides/SCANNING_AND_COMPLIANCE.md +++ b/docs/guides/SCANNING_AND_COMPLIANCE.md @@ -44,8 +44,8 @@ Key points: - **No agent on targets.** Kensa connects over SSH, runs commands, and disconnects. Nothing is installed on the scanned host. - **One scan, many frameworks.** A single scan produces results for every - framework key the corpus maps: CIS and STIG benchmarks, NIST 800-53, PCI - DSS 4 and the SRG. The keys are listed under + framework key the corpus maps: CIS and STIG benchmarks, NIST 800-53, + NIST 800-171, CMMC Level 2, PCI DSS 4 and the SRG. The keys are listed under [Available frameworks](#available-frameworks). - **Evidence captured.** Each check records the command executed, the raw output, the expected value, and the actual value found. @@ -61,7 +61,7 @@ RHEL 9 host is `stig_rhel9`. `GET /api/v1/compliance/frameworks` lists the families present in your scanned fleet and the keys each one spans. The counts below are rules that reference each key in the corpus this release -pins (Kensa v0.9.0, 769 rules). They change with the `kensa-rules` package, +pins (Kensa v0.10.0, 779 rules). They change with the `kensa-rules` package, not with the OpenWatch binary. | Family | Key | Rules | @@ -71,12 +71,14 @@ not with the OpenWatch binary. | CIS | `cis_rhel10` | 321 | | CIS | `cis_ubuntu22` | 131 | | CIS | `cis_ubuntu24` | 132 | -| STIG | `stig_rhel8` | 342 | +| STIG | `stig_rhel8` | 341 | | STIG | `stig_rhel9` | 391 | | STIG | `stig_rhel10` | 388 | | STIG | `stig_ubuntu22` | 159 | | STIG | `stig_ubuntu24` | 167 | -| NIST 800-53 | `nist_800_53` | 750 | +| NIST 800-53 | `nist_800_53` | 760 | +| NIST 800-171 | `nist_800_171` | 324 | +| CMMC Level 2 | `cmmc_l2` | 324 | | PCI DSS 4 | `pci_dss_4` | 2 | | SRG | `srg` | 1 | @@ -164,6 +166,47 @@ service first, then the logs. --- +## Scan variables + +Some rules compare a host against a value your organization chooses, such as a +session timeout or a list of approved ports. Those values are scan variables. +Set them under **Settings -> Compliance policies -> Scan variables**. The card +lists only the variables a rule in the loaded corpus uses, with the number of +rules each one affects. A change applies to the next scan. + +### Variables with no default + +Kensa v0.10.0 added eight variables that ship empty. Each one feeds a single +rule. Until you declare the variable, seven of those rules report **skipped** +and name the variable to set. A skipped rule is left out of the score, so it is +neither a pass nor a fail. The eighth, `suid-sgid-files-reviewed`, still runs +its world-writable check; only its inventory comparison needs the baseline. + +| Variable | Rule | What to declare | +|---|---|---| +| `authorized_local_accounts` | `no-unauthorized-accounts` | Local accounts allowed on the host, by user name or numeric UID | +| `authorized_privileged_users` | `authorized-privileged-users` | Accounts allowed to act as root (wheel or sudo membership, sudoers entries, UID 0) | +| `authorized_service_accounts` | `authorized-service-accounts` | Accounts below UID 1000 allowed to hold a login shell | +| `authorized_listening_ports` | `authorized-listening-ports` | TCP and UDP ports allowed to listen | +| `authorized_services` | `authorized-enabled-services` | systemd services allowed to be enabled | +| `authorized_network_protocols` | `authorized-network-protocols` | Protocols allowed in `/proc/net/protocols` | +| `flaw_remediation_max_days` | `flaw-remediation-window` | Days a pending security advisory may stay uncorrected, as a whole number | +| `suid_sgid_baseline` | `suid-sgid-files-reviewed` | Full paths approved to carry the setuid or setgid bit, under `/usr/bin`, `/usr/sbin`, `/bin` and `/sbin` | + +Enter a list as members separated by commas, with no spaces: +`22,443,8443`. A member that is declared but absent from the host is reported +and does not fail. An empty list is a skip, never a pass. + +One declaration covers the whole fleet. Hosts that need different sets, such +as a web tier and a database tier, cannot be given separate values yet. + +**OpenWatch does not check a value's type.** The form accepts any text for any +variable, and the scan uses it as entered. A value such as `three` where a +whole number belongs produces a verdict measured against that text, not an +error. Check a value against the rule before saving it. + +--- + ## Reading scan results After a scan completes, the results are displayed on the host detail page under diff --git a/frontend/src/pages/host-detail/ComplianceTab.tsx b/frontend/src/pages/host-detail/ComplianceTab.tsx index 0011e3fa5..df5c479a0 100644 --- a/frontend/src/pages/host-detail/ComplianceTab.tsx +++ b/frontend/src/pages/host-detail/ComplianceTab.tsx @@ -447,10 +447,20 @@ function RescanButton({ // stays the single source of truth (api-hosts AC-08). // ───────────────────────────────────────────────────────────────────────── +// Keys the generic transform below would misspell, named as the backend's +// internal/framework labels name them. +const NAMED_FRAMEWORK_LABELS: Record = { + nist_800_53: 'NIST 800-53', + nist_800_171: 'NIST 800-171', + cmmc_l2: 'CMMC Level 2', +}; + // frameworkLabel renders a friendly chip label from a framework id: // cis_rhel8 -> "CIS RHEL 8", nist_800_53 -> "NIST 800-53", // stig_rhel9 -> "STIG RHEL 9", pci_dss_4 -> "PCI DSS 4". export function frameworkLabel(id: string): string { + const named = NAMED_FRAMEWORK_LABELS[id]; + if (named) return named; return id .split('_') .map((part) => { @@ -459,8 +469,7 @@ export function frameworkLabel(id: string): string { if (/^\d+$/.test(part)) return part; return part.toUpperCase(); }) - .join(' ') - .replace(/^NIST 800 53$/, 'NIST 800-53'); + .join(' '); } function LensBar({ diff --git a/frontend/tests/pages/host-detail-compliance-tab.test.tsx b/frontend/tests/pages/host-detail-compliance-tab.test.tsx index 01e151397..8222add6a 100644 --- a/frontend/tests/pages/host-detail-compliance-tab.test.tsx +++ b/frontend/tests/pages/host-detail-compliance-tab.test.tsx @@ -10,6 +10,7 @@ // AC-06 test('frontend-host-compliance-tab/AC-06 — never-scanned empty state names Run scan; errors render inline with Retry; isPending guard') // AC-07 test('frontend-host-compliance-tab/AC-07 — no stored check-output reference anywhere in the tab code') // AC-08 test('frontend-host-compliance-tab/AC-08 — Re-scan posts once with an Idempotency-Key; 409 renders Scan already running') +// AC-12 test('frontend-host-compliance-tab/AC-12 — frameworkLabel names every family the pinned corpus carries') import { describe, expect, test, beforeEach, vi } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -21,7 +22,7 @@ import { loadCriterion, trackFixture, type AnyRec } from '../support/spec-fixtur const { getMock, postMock } = vi.hoisted(() => ({ getMock: vi.fn(), postMock: vi.fn() })); vi.mock('@/api/client', () => ({ default: { GET: getMock, POST: postMock } })); -import { ComplianceTab } from '@/pages/host-detail/ComplianceTab'; +import { ComplianceTab, frameworkLabel } from '@/pages/host-detail/ComplianceTab'; const TAB_SRC = readFileSync( resolve(process.cwd(), 'src/pages/host-detail/ComplianceTab.tsx'), @@ -647,3 +648,20 @@ test('frontend-host-compliance-tab/AC-11 — absence renders as absence, coverag } expect(hits).toBe(wantForbiddenCount); }); + +describe('frontend-host-compliance-tab v1.7.0 — framework labels', () => { + // @ac AC-12 + test('frontend-host-compliance-tab/AC-12 — frameworkLabel names every family the pinned corpus carries', () => { + const cases: Record = { + cis_rhel9: 'CIS RHEL 9', + stig_ubuntu22: 'STIG UBUNTU 22', + pci_dss_4: 'PCI DSS 4', + nist_800_53: 'NIST 800-53', + nist_800_171: 'NIST 800-171', + cmmc_l2: 'CMMC Level 2', + }; + for (const [id, want] of Object.entries(cases)) { + expect(frameworkLabel(id)).toBe(want); + } + }); +}); diff --git a/go.mod b/go.mod index 221ef2fd9..253c13ae4 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.6 require ( github.com/BurntSushi/toml v1.6.0 - github.com/Hanalyx/kensa v0.9.0 + github.com/Hanalyx/kensa v0.10.0 github.com/gliderlabs/ssh v0.3.8 github.com/go-chi/chi/v5 v5.3.0 github.com/go-pdf/fpdf v0.9.0 diff --git a/go.sum b/go.sum index f2d38d876..70a416079 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Hanalyx/kensa v0.9.0 h1:nREms9TWDDNMZBwwBxRIlYw73rvdMwkghIaf4WQP49U= -github.com/Hanalyx/kensa v0.9.0/go.mod h1:RuBtdJNBlr5cCT6O8LjQqwx9gRyR+owlAiwIQrB4TRk= +github.com/Hanalyx/kensa v0.10.0 h1:XU+zsKvJLrXfOZPvv/A5vz1DaPPFXfVeT0rUnCpGwKU= +github.com/Hanalyx/kensa v0.10.0/go.mod h1:RuBtdJNBlr5cCT6O8LjQqwx9gRyR+owlAiwIQrB4TRk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= diff --git a/internal/framework/framework.go b/internal/framework/framework.go index 74e3b39c9..463a0dd22 100644 --- a/internal/framework/framework.go +++ b/internal/framework/framework.go @@ -172,6 +172,9 @@ var familyLabels = map[string]string{ "srg": "SRG", "nist_800_53": "NIST 800-53", "pci_dss_4": "PCI DSS 4", + // Added with Kensa v0.10.0, whose corpus maps 324 rules to each. + "nist_800_171": "NIST 800-171", + "cmmc_l2": "CMMC Level 2", } // Label renders a family id for display. diff --git a/internal/framework/framework_test.go b/internal/framework/framework_test.go index ec0abbb0e..4d8af33d6 100644 --- a/internal/framework/framework_test.go +++ b/internal/framework/framework_test.go @@ -19,6 +19,8 @@ func TestFamilyOf(t *testing.T) { "nist_800_53": "nist_800_53", // OS-agnostic: digits not stripped "pci_dss_4": "pci_dss_4", // trailing _4 is not an OS suffix "srg": "srg", + "nist_800_171": "nist_800_171", // Kensa v0.10.0 + "cmmc_l2": "cmmc_l2", // _l2 is a level, not an OS suffix } for key, want := range cases { if got := FamilyOf(key); got != want { @@ -29,5 +31,8 @@ func TestFamilyOf(t *testing.T) { if Label("stig") != "STIG" || Label("nist_800_53") != "NIST 800-53" { t.Errorf("Label mismatch: stig=%q nist=%q", Label("stig"), Label("nist_800_53")) } + if Label("nist_800_171") != "NIST 800-171" || Label("cmmc_l2") != "CMMC Level 2" { + t.Errorf("Label mismatch: nist_800_171=%q cmmc_l2=%q", Label("nist_800_171"), Label("cmmc_l2")) + } }) } diff --git a/internal/kensa/doc.go b/internal/kensa/doc.go index 010e4d21d..128aa8e07 100644 --- a/internal/kensa/doc.go +++ b/internal/kensa/doc.go @@ -32,7 +32,7 @@ // is invoked directly via *kensa.Kensa. Spec AC-12 source-inspects // to confirm no type `ScanEngine interface` (or similar) is declared. // -// Kensa version pin: github.com/Hanalyx/kensa v0.9.0 (matches the +// Kensa version pin: github.com/Hanalyx/kensa v0.10.0 (matches the // version recorded in the spec's context block; AC-10 verifies the // match between this comment, the spec, and go.mod). package kensa diff --git a/internal/kensa/types.go b/internal/kensa/types.go index 460901f82..16dba262b 100644 --- a/internal/kensa/types.go +++ b/internal/kensa/types.go @@ -10,7 +10,7 @@ import ( // KensaModuleVersion is the version pin recorded in the spec's context // block. AC-10 source-inspects to verify this matches the corresponding // entry in go.mod. -const KensaModuleVersion = "v0.9.0" +const KensaModuleVersion = "v0.10.0" // Sentinel errors returned by Executor.Run. Tests use errors.Is for // classification; the audit emission path maps each to a typed diff --git a/internal/kensa/variables_test.go b/internal/kensa/variables_test.go index 675eea8ff..cf6931b14 100644 --- a/internal/kensa/variables_test.go +++ b/internal/kensa/variables_test.go @@ -5,8 +5,11 @@ package kensa import ( + "context" "os" "testing" + + pkgkensa "github.com/Hanalyx/kensa/pkg/kensa" ) // corpusDir returns the dev corpus path or skips (same env the scan @@ -32,8 +35,21 @@ func TestVariableCatalog_CorpusUsedAndPlaceholders(t *testing.T) { t.Fatalf("NewVariableCatalog: %v", err) } list := cat.List() - if len(list) == 0 || len(list) > 29 { - t.Fatalf("catalog len = %d, want 1..29 (corpus-used subset of built-ins)", len(list)) + // The catalog is the corpus-used subset of kensa's built-ins + // (C-07). The bound comes from the built-in table itself, so a + // Kensa bump that adds variables does not break the test while a + // catalog that lists a non-built-in still does. + builtins, err := pkgkensa.BuiltInVars() + if err != nil { + t.Fatalf("BuiltInVars: %v", err) + } + if len(list) == 0 || len(list) > len(builtins) { + t.Fatalf("catalog len = %d, want 1..%d (corpus-used subset of built-ins)", len(list), len(builtins)) + } + for _, v := range list { + if _, ok := builtins[v.Name]; !ok { + t.Errorf("%s is listed but is not a kensa built-in variable", v.Name) + } } flagged := 0 for i, v := range list { @@ -88,3 +104,52 @@ func TestVarsFingerprint_StableAndValueSensitive(t *testing.T) { } }) } + +// @ac AC-11 +// AC-11: a list-valued override reaches the rule's check parameters +// verbatim when the corpus reloads, and the built-in default returns when +// the override is removed. authorized_listening_ports ships with an empty +// default (Kensa v0.10.0), so its rule compares against nothing until an +// operator declares the set. +func TestCorpusReload_ListOverrideReachesCheck(t *testing.T) { + t.Run("api-system-scan-config/AC-11", func(t *testing.T) { + dir := corpusDir(t) + rules, err := pkgkensa.LoadRules(dir, nil, nil) + if err != nil { + t.Fatalf("LoadRules: %v", err) + } + cache := &corpusCache{rules: rules, dir: dir} + authorized := func(overrides map[string]string) []any { + got := cache.current(context.Background(), func(context.Context) (map[string]string, error) { + return overrides, nil + }) + var vals []any + for _, r := range got { + if r.ID != "authorized-listening-ports" { + continue + } + for _, impl := range r.Implementations { + if impl.Check.Method == "set_compare" { + vals = append(vals, impl.Check.Params["authorized"]) + } + } + } + return vals + } + + assertAll := func(label string, vals []any, want string) { + t.Helper() + if len(vals) == 0 { + t.Fatalf("%s: rule authorized-listening-ports has no set_compare check", label) + } + for _, v := range vals { + if v != want { + t.Errorf("%s: authorized = %#v, want %q", label, v, want) + } + } + } + assertAll("default", authorized(nil), "") + assertAll("override", authorized(map[string]string{"authorized_listening_ports": "22,443"}), "22,443") + assertAll("override removed", authorized(map[string]string{}), "") + }) +} diff --git a/specs/api/system-scan-config.spec.yaml b/specs/api/system-scan-config.spec.yaml index b7c1466f7..bd153c018 100644 --- a/specs/api/system-scan-config.spec.yaml +++ b/specs/api/system-scan-config.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-system-scan-config title: Adaptive compliance scan scheduler config + fleet-state + schedule-preview HTTP surface - version: "1.4.0" + version: "1.5.0" status: approved tier: 2 @@ -135,3 +135,7 @@ spec: description: 'v1.2.0 - GET /hosts/{id}/compliance/schedule returns the host schedule row (compliance_state, next_scan_at, interval_minutes, host_maintenance) plus the scheduler-wide flags (scheduler_enabled, scheduler_paused = disabled OR global maintenance); a host without a schedule row returns state unknown with null next_scan_at and interval 0, never an error; unknown hosts 404 hosts.not_found before any schedule read; anonymous callers are rejected and a viewer succeeds' priority: critical references_constraints: [C-02] + - id: AC-11 + description: 'v1.5.0 - a list-valued override (for example authorized_listening_ports = "22,443") reaches the affected rule''s check parameters verbatim when the corpus reloads, and removing the override restores the built-in default on the next reload. Kensa v0.10.0 ships eight corpus-used variables whose built-in default is empty; configure_me stays limited to the three placeholder names in C-07' + priority: high + references_constraints: [C-07] diff --git a/specs/frontend/host-compliance-tab.spec.yaml b/specs/frontend/host-compliance-tab.spec.yaml index bdeb46fdd..1e356b71e 100644 --- a/specs/frontend/host-compliance-tab.spec.yaml +++ b/specs/frontend/host-compliance-tab.spec.yaml @@ -1,7 +1,7 @@ spec: id: frontend-host-compliance-tab title: Host detail Compliance tab — lens UI over the per-host compliance endpoints - version: "1.6.0" + version: "1.7.0" status: approved tier: 2 @@ -222,3 +222,15 @@ spec: forbidden_copy_occurrences: 0 counts_preserved: true counts_asserted: [passing, failing, skipped, error] + - id: AC-12 + description: >- + v1.7.0 - frameworkLabel names every framework family the pinned corpus + carries the way the backend's framework labels do. The generic + transform stays for keys with an operating-system suffix (cis_rhel9 -> + 'CIS RHEL 9', stig_ubuntu22 -> 'STIG UBUNTU 22'). The OS-agnostic keys + that transform would misspell are named explicitly: nist_800_53 -> + 'NIST 800-53', nist_800_171 -> 'NIST 800-171' and cmmc_l2 -> + 'CMMC Level 2', never 'CMMC L 2'. The last two arrived with Kensa + v0.10.0. + priority: high + references_constraints: [C-01] diff --git a/specs/system/kensa-executor.spec.yaml b/specs/system/kensa-executor.spec.yaml index 93cc226b1..d79ed7777 100644 --- a/specs/system/kensa-executor.spec.yaml +++ b/specs/system/kensa-executor.spec.yaml @@ -1,7 +1,7 @@ spec: id: system-kensa-executor title: Kensa executor wrapper - version: "2.9.0" + version: "2.10.0" status: approved tier: 1 @@ -10,7 +10,7 @@ spec: feature: Kensa scan execution bridge description: > The executor invokes Kensa (Go module github.com/Hanalyx/kensa - pinned to v0.9.0) to run a scan against a single host using the + pinned to v0.10.0) to run a scan against a single host using the FULL rule corpus applicable to the host's detected OS capabilities. The Kensa API (`Kensa.Scan(ctx, host, rules, opts...)` in the module's api package) takes a `[]*api.Rule` @@ -153,7 +153,7 @@ spec: type: technical enforcement: error - id: C-13 - description: The production scanFunc MUST compose the scan-only Kensa via api.New with pkg/kensa.NewScanner (kensa v0.9.0 — stateless, concurrency-safe shared) and this package's TransportFactory; no engine, store, or signer is constructed for the scan path. The worker subcommand binds it via WithScanFunc(NewProductionScanFunc(...)). unwiredScanFunc may remain ONLY as the test fallback NewExecutor defaults to before binding, annotated as such + description: The production scanFunc MUST compose the scan-only Kensa via api.New with pkg/kensa.NewScanner (kensa v0.10.0 — stateless, concurrency-safe shared) and this package's TransportFactory; no engine, store, or signer is constructed for the scan path. The worker subcommand binds it via WithScanFunc(NewProductionScanFunc(...)). unwiredScanFunc may remain ONLY as the test fallback NewExecutor defaults to before binding, annotated as such type: technical enforcement: error - id: C-14 From 1278018f6981476141fe37b170cd070f1a5c55c7 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Sat, 26 Sep 2026 14:53:51 -0400 Subject: [PATCH 02/10] fix(packaging): refuse a Kensa corpus beside an older engine CP bugs/OW-081. Kensa v0.9.0 cannot load the v0.10.0 corpus: LoadRules fails on the first variable it does not know, serve starts anyway, and every scan fails. kensa-rules takes its version from go.mod and openwatch required it unversioned, so a rules-only upgrade onto rc.5 or earlier produced exactly that pairing. The openwatch RPM and DEB now provide openwatch-kensa-engine at the Kensa version they link, and kensa-rules requires openwatch-kensa-engine at least its own version. Both come from one helper, packaging/common/kensa-version.sh, which the corpus stager also uses, so the two values cannot drift. No floor is written by hand: a fixed "openwatch >= rc.6" would have made the pair built on main uninstallable together until the version bump. An older corpus under a newer engine loads, so an openwatch-only upgrade stays allowed. The spec default 0.0.0 satisfies no corpus, so a build that forgets the define fails at install rather than shipping an unguarded pair. Contract: release-upgrade 1.1.0, C-06, AC-08 (built artifacts carry the provide and the requirement at the go.mod version, read from go.mod independently of the helper) and AC-09 (container behavior on RPM and DEB). package-smoke gains a kensa-rules-compat job that runs AC-09 against v0.7.1, the previous GA. Measured locally against the published v0.8.0-rc.5 packages and this tree built as rc.6, Rocky 9 and Ubuntu 24.04: - dnf, rpm -U and apt refuse kensa-rules 0.10.0 alone; the installed corpus stays 0.9.0; - openwatch-only upgrade, coordinated upgrade and fresh install succeed; - the new corpus refuses a downgrade of openwatch to rc.5; - 1:0.8.0~rc.5 < ~rc.6 < ~rc.10 < 1:0.8.0 and 0.9.0 < 0.10.0 in both formats. Mutations, each red then restored with the hash checked: kensa-rules without the floor (AC-09, both formats), openwatch without the provide (AC-09, both formats), and the DEB provide left at 0.0.0 (AC-08). Residual, measured and documented, not covered: a bare dpkg -i of the rules package unpacks it and leaves it unconfigured (the files are replaced), and rpm --nodeps bypasses the check. The documented paths use dnf and apt. The upgrade runbook and the CHANGELOG say so. --- .github/workflows/package-smoke.yml | 47 +++++ CHANGELOG.md | 10 +- docs/runbooks/UPGRADE_PROCEDURE.md | 10 ++ packaging/common/kensa-version.sh | 22 +++ packaging/common/stage-kensa-rules.sh | 3 +- packaging/deb/build-deb.sh | 4 + packaging/deb/control | 1 + packaging/kensa-rules/build-kensa-rules.sh | 1 + packaging/kensa-rules/kensa-rules.spec | 8 + packaging/rpm/build-rpm.sh | 1 + packaging/rpm/openwatch.spec | 10 ++ .../kensa-rules-compat-container-test.sh | 168 ++++++++++++++++++ .../tests/run-kensa-rules-compat-test.sh | 22 +++ packaging/tests/upgrade_test.go | 88 +++++++++ specs/release/upgrade.spec.yaml | 42 ++++- 15 files changed, 431 insertions(+), 6 deletions(-) create mode 100755 packaging/common/kensa-version.sh create mode 100755 packaging/tests/kensa-rules-compat-container-test.sh create mode 100755 packaging/tests/run-kensa-rules-compat-test.sh diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml index 0beee5800..edab5d3e6 100644 --- a/.github/workflows/package-smoke.yml +++ b/.github/workflows/package-smoke.yml @@ -212,6 +212,53 @@ jobs: bash packaging/tests/run-upgrade-from-ga-test.sh \ "${{ matrix.distro }}" "${{ matrix.kind }}" v0.6.0 + # The package manager enforces the engine/corpus pairing (spec + # release-upgrade C-06, AC-09). A Kensa engine older than its corpus + # cannot load it and the service starts anyway with every scan failing, so + # a rules-only upgrade beside an older openwatch must be refused while the + # coordinated upgrade, an openwatch-only upgrade and a fresh install + # succeed. The previous GA predates the engine provide, which is the state + # the refusal has to hold against. + kensa-rules-compat: + name: kensa-rules compat ${{ matrix.distro }} + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { distro: 'rockylinux:9', kind: rpm } + - { distro: 'ubuntu:24.04', kind: deb } + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v4 + with: + name: packages + path: dist + - name: fetch the previous GA + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p old new + if [ "${{ matrix.kind }}" = rpm ]; then + gh release download v0.7.1 -D old -p 'openwatch-*.x86_64.rpm' -p 'kensa-rules-*.noarch.rpm' + cp dist/openwatch-*.x86_64.rpm dist/kensa-rules-*.noarch.rpm new/ + else + gh release download v0.7.1 -D old -p 'openwatch_*_amd64.deb' -p 'kensa-rules_*_all.deb' + cp dist/openwatch_*_amd64.deb dist/kensa-rules_*_all.deb new/ + fi + ls old new + - uses: actions/setup-go@v6 + with: + go-version: '1.26.6' + - name: engine and corpus pairing is enforced + env: + OPENWATCH_KENSA_COMPAT_IMAGE: ${{ matrix.distro }} + OPENWATCH_KENSA_COMPAT_KIND: ${{ matrix.kind }} + run: | + OPENWATCH_KENSA_COMPAT_OLD_DIR="$PWD/old" OPENWATCH_KENSA_COMPAT_NEW_DIR="$PWD/new" \ + go test -count=1 -v -run 'TestUpgrade_PackageManagerEnforcesEngineCorpusPairing' ./packaging/tests/ + upgrade: name: Package upgrade (rpm -U auto-migrate) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index cd946415c..245aff56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,9 +66,13 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). level such as `3.1.11[b]`) and `cmmc_l2`. Each is referenced by 324 rules, and the lens picker labels them "NIST 800-171" and "CMMC Level 2". - **Install `openwatch` and `kensa-rules` in one transaction.** An earlier - `openwatch` cannot load the 0.10.0 corpus: the service starts and every scan - fails. The packages do not yet forbid that pairing (CP `bugs/OW-081`). + **Upgrade `openwatch` and `kensa-rules` together.** An earlier `openwatch` + cannot load the 0.10.0 corpus: the service starts and every scan fails. + `openwatch` now declares the Kensa engine it links, and `kensa-rules` + requires an engine at least as new as itself, so `dnf` and `apt` refuse a + rules-only upgrade onto an older `openwatch` and accept both packages in one + transaction. A bare `dpkg -i` of the rules package still replaces the files + while reporting the error (CP `bugs/OW-081`). **Verdicts change on existing hosts, so scores can move after the first scan on this release.** The change comes from the rules, not the hosts: diff --git a/docs/runbooks/UPGRADE_PROCEDURE.md b/docs/runbooks/UPGRADE_PROCEDURE.md index ea013e849..cd0b771ab 100644 --- a/docs/runbooks/UPGRADE_PROCEDURE.md +++ b/docs/runbooks/UPGRADE_PROCEDURE.md @@ -365,6 +365,16 @@ field. The rules are the separate `kensa-rules` package, installed at `openwatch` package depends on `kensa-rules` but does not pin its version, so upgrading one does not upgrade the other. +A corpus needs an engine at least as new as itself. An older engine cannot +load a newer corpus: the service starts and every scan fails. So the +`kensa-rules` package requires the engine version the `openwatch` package +provides, and `dnf` and `apt` refuse a rules-only upgrade that would pair a +corpus with an older engine. When that happens, upgrade `openwatch` in the +same transaction. A newer engine loads an older corpus, so upgrading +`openwatch` alone is allowed. Do not bypass the check with `rpm --nodeps` or +a bare `dpkg -i` of the rules package; `dpkg -i` replaces the rules on disk +even though it reports the dependency error. + To update the rules, upgrade the package and restart the service so it loads the new corpus: diff --git a/packaging/common/kensa-version.sh b/packaging/common/kensa-version.sh new file mode 100755 index 000000000..42ff32a25 --- /dev/null +++ b/packaging/common/kensa-version.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# kensa-version.sh: print the version of the Kensa Go module this tree links, +# without the leading "v" (v0.10.0 -> 0.10.0). +# +# One derivation, used by every package build. The openwatch packages +# declare it as `openwatch-kensa-engine`, the engine they link, and the +# kensa-rules package requires an engine at least as new as its own +# corpus. An engine older than the corpus cannot load it (Kensa v0.9.0 fails +# the whole v0.10.0 load on the first variable it does not know), so the +# two values must come from the same place or the guard means nothing. +# +# Usage: bash packaging/common/kensa-version.sh +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../.." +KVER="$(go list -m -f '{{.Version}}' github.com/Hanalyx/kensa)" +KVER="${KVER#v}" +if ! [[ "$KVER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "kensa-version: unexpected module version '$KVER'" >&2 + exit 1 +fi +echo "$KVER" diff --git a/packaging/common/stage-kensa-rules.sh b/packaging/common/stage-kensa-rules.sh index 6fd0c18e4..c2c4669bf 100755 --- a/packaging/common/stage-kensa-rules.sh +++ b/packaging/common/stage-kensa-rules.sh @@ -30,8 +30,7 @@ KMOD="github.com/Hanalyx/kensa" go mod download "$KMOD" KDIR="$(go list -m -f '{{.Dir}}' "$KMOD")" -KVER="$(go list -m -f '{{.Version}}' "$KMOD")" -KVER="${KVER#v}" # strip leading v: v0.4.3 -> 0.4.3 +KVER="$(bash "$SCRIPT_DIR/kensa-version.sh")" # v0.4.3 -> 0.4.3 SRC="$KDIR/rules" if [ ! -d "$SRC" ]; then diff --git a/packaging/deb/build-deb.sh b/packaging/deb/build-deb.sh index c0fc728bf..4cc6d38c6 100755 --- a/packaging/deb/build-deb.sh +++ b/packaging/deb/build-deb.sh @@ -91,8 +91,12 @@ install -m 0755 "$APP_DIR/packaging/common/cleanup-backups.sh" "$STAGE/u # Step 3: control + maintainer scripts. # Render control with the actual version and target arch inserted. +# Provides carries the linked Kensa engine for kensa-rules to depend on +# (spec release-upgrade C-06; see packaging/common/kensa-version.sh). +KENSA_ENGINE_VERSION="$(bash "$APP_DIR/packaging/common/kensa-version.sh")" sed -e "s/^Version: .*/Version: ${DEB_VERSION}/" \ -e "s/^Architecture: .*/Architecture: ${ARCH}/" \ + -e "s/^Provides: openwatch-kensa-engine (= .*)/Provides: openwatch-kensa-engine (= ${KENSA_ENGINE_VERSION})/" \ "$APP_DIR/packaging/deb/control" > "$STAGE/DEBIAN/control" install -m 0644 "$APP_DIR/packaging/deb/conffiles" "$STAGE/DEBIAN/conffiles" diff --git a/packaging/deb/control b/packaging/deb/control index 7653aa396..5089811b1 100644 --- a/packaging/deb/control +++ b/packaging/deb/control @@ -4,6 +4,7 @@ Section: admin Priority: optional Architecture: amd64 Depends: libc6 (>= 2.31), postgresql-client, kensa-rules, openssl +Provides: openwatch-kensa-engine (= 0.0.0) Maintainer: OpenWatch Build Homepage: https://github.com/Hanalyx/openwatch Description: OpenWatch Compliance Platform (Go binary) diff --git a/packaging/kensa-rules/build-kensa-rules.sh b/packaging/kensa-rules/build-kensa-rules.sh index 1a6288eb9..fc5dddcb5 100755 --- a/packaging/kensa-rules/build-kensa-rules.sh +++ b/packaging/kensa-rules/build-kensa-rules.sh @@ -62,6 +62,7 @@ Version: ${KVER} Section: admin Priority: optional Architecture: all +Depends: openwatch-kensa-engine (>= ${KVER}) Maintainer: OpenWatch Build Homepage: https://github.com/Hanalyx/kensa Description: Kensa compliance rule corpus (native YAML rules) diff --git a/packaging/kensa-rules/kensa-rules.spec b/packaging/kensa-rules/kensa-rules.spec index 9517ed414..17f82f16e 100644 --- a/packaging/kensa-rules/kensa-rules.spec +++ b/packaging/kensa-rules/kensa-rules.spec @@ -28,6 +28,14 @@ License: Business Source License 1.1 URL: https://github.com/Hanalyx/kensa Source0: %{name}-%{version}.tar.gz +# The corpus needs an engine at least as new as itself. Kensa v0.9.0 cannot +# load the v0.10.0 corpus at all, and OpenWatch starts anyway with every scan +# failing, so a rules-only upgrade beside an older openwatch is refused here. +# An older corpus under a newer engine loads, so only the floor is declared. +# The openwatch package provides openwatch-kensa-engine; releases before it +# provide nothing and so cannot satisfy this. Spec release-upgrade C-06. +Requires: openwatch-kensa-engine >= %{version} + %description The Kensa rule corpus: native YAML compliance rules consumed by the Kensa scan engine embedded in OpenWatch. Installs to diff --git a/packaging/rpm/build-rpm.sh b/packaging/rpm/build-rpm.sh index 6065cbe43..4bce6e9ea 100755 --- a/packaging/rpm/build-rpm.sh +++ b/packaging/rpm/build-rpm.sh @@ -92,6 +92,7 @@ rpmbuild \ --define "_topdir $RPMTOP" \ --define "ow_version ${RPM_VERSION}" \ --define "ow_release ${RPM_RELEASE}" \ + --define "kensa_engine_version $(bash "$APP_DIR/packaging/common/kensa-version.sh")" \ --target "${RPM_ARCH}" \ -bb "$APP_DIR/packaging/rpm/openwatch.spec" >/dev/null diff --git a/packaging/rpm/openwatch.spec b/packaging/rpm/openwatch.spec index 1fa6afc69..afdc49fe4 100644 --- a/packaging/rpm/openwatch.spec +++ b/packaging/rpm/openwatch.spec @@ -42,6 +42,16 @@ Requires: postgresql-server # rather than the service booting and every scan failing. Unversioned so the # corpus can advance on its own line; tighten to a floor when OTA lands. Requires: kensa-rules +# The Kensa engine this binary links, for kensa-rules to require. An engine +# older than the corpus cannot load it: Kensa v0.9.0 fails the whole v0.10.0 +# load, the service still starts, and every scan fails. kensa-rules requires +# openwatch-kensa-engine >= its own version, so a rules-only upgrade beside an +# older openwatch is refused. Injected by build-rpm.sh from +# packaging/common/kensa-version.sh. The 0.0.0 default satisfies no corpus, +# so a build that forgets the define fails at install rather than shipping an +# unguarded pair. Spec release-upgrade C-06. +%{!?kensa_engine_version: %global kensa_engine_version 0.0.0} +Provides: openwatch-kensa-engine = %{kensa_engine_version} # openssl: the %post scriptlet generates the JWT signing key and credential # DEK at install time (the server refuses to auto-generate them in production). Requires: openssl diff --git a/packaging/tests/kensa-rules-compat-container-test.sh b/packaging/tests/kensa-rules-compat-container-test.sh new file mode 100755 index 000000000..3729f09e7 --- /dev/null +++ b/packaging/tests/kensa-rules-compat-container-test.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# kensa-rules-compat-container-test.sh: runs INSIDE a disposable container. +# +# Proves the package manager enforces the engine/corpus pairing +# (spec release-upgrade C-06, AC-09). An engine older than its corpus cannot +# load it, and OpenWatch starts anyway with every scan failing, so: +# +# 1. a rules-only upgrade beside an older openwatch is REFUSED, and the +# installed corpus is unchanged afterwards; +# 2. an openwatch-only upgrade over the older corpus succeeds (a newer +# engine loads an older corpus); +# 3. the coordinated upgrade, both packages in one transaction, succeeds; +# 4. a fresh install of the new pair succeeds; +# 5. the new corpus refuses to stay beside a downgraded openwatch; +# 6. release-candidate versions order correctly: rc.5 < rc.6 < rc.10 < GA, +# with the epoch both package formats carry. +# +# Usage: kensa-rules-compat-container-test.sh +# old-dir: an openwatch release that predates the engine provide, and its +# kensa-rules (the published v0.8.0-rc.5 assets). +# new-dir: openwatch and kensa-rules built from this tree. +# +# RPM transactions run with tsflags=noscripts: scriptlets are covered by the +# setup and upgrade harnesses, and this test is about dependency resolution, +# which noscripts leaves untouched. The DEB scriptlets tolerate a container +# without systemd, so apt runs them. +set -uo pipefail + +KIND="${1:?usage: $0 }" +OLD="${2:?old-dir}" +NEW="${3:?new-dir}" + +FAILURES=0 +pass() { echo "PASS: $*"; } +fail() { echo "FAIL: $*"; FAILURES=$((FAILURES + 1)); } + +# Installed version of a package, or "absent". +installed() { + if [ "$KIND" = deb ]; then + dpkg-query -W -f='${Status} ${Version}\n' "$1" 2>/dev/null | + awk '$1=="install" && $3=="installed" {print $4; f=1} END {if (!f) print "absent"}' + elif rpm -q "$1" >/dev/null 2>&1; then + # Checked first: `rpm -q` reports a missing package on stdout, and a + # pipe would hide its exit status. + rpm -q --qf '%{EPOCH}:%{VERSION}\n' "$1" | sed 's/^(none)://' + else + echo absent + fi +} + +# Run a package transaction; its output goes to a log, its status is returned. +txn() { + local log="/tmp/txn.$RANDOM.log" + if [ "$KIND" = deb ]; then + DEBIAN_FRONTEND=noninteractive apt-get install -y --allow-downgrades "$@" >"$log" 2>&1 + else + dnf install -y --setopt=tsflags=noscripts "$@" >"$log" 2>&1 + fi + local rc=$? + LAST_LOG="$log" + return $rc +} + +# The files of one package kind in a directory. +pkg() { # pkg + if [ "$KIND" = deb ]; then + ls "$1"/"$2"_*.deb + else + ls "$1"/"$2"-*.rpm + fi +} + +OLD_OW="$(pkg "$OLD" openwatch)" +OLD_KR="$(pkg "$OLD" kensa-rules)" +NEW_OW="$(pkg "$NEW" openwatch)" +NEW_KR="$(pkg "$NEW" kensa-rules)" + +if [ "$KIND" = deb ]; then + apt-get update -qq >/dev/null 2>&1 || { echo "apt-get update failed" >&2; exit 2; } +fi + +reset_to_old() { + if [ "$KIND" = deb ]; then + dpkg --purge kensa-rules openwatch >/dev/null 2>&1 + else + rpm -e --nodeps --noscripts kensa-rules openwatch >/dev/null 2>&1 + fi + txn "$OLD_OW" "$OLD_KR" || { echo "could not install the old pair; log follows" >&2; cat "$LAST_LOG" >&2; exit 2; } +} + +echo "### 1. rules-only upgrade beside the older openwatch is refused" +reset_to_old +before_kr="$(installed kensa-rules)" +if txn "$NEW_KR"; then + fail "the package manager installed $(basename "$NEW_KR") beside openwatch $(installed openwatch)" +else + pass "refused: $(grep -m1 -iE 'openwatch-kensa-engine' "$LAST_LOG" | sed 's/^ *//')" +fi +[ "$(installed kensa-rules)" = "$before_kr" ] && + pass "the installed corpus is unchanged ($before_kr)" || + fail "the installed corpus changed from $before_kr to $(installed kensa-rules)" +if [ "$KIND" = rpm ]; then + # The plain rpm path checks dependencies too; --nodeps is the only bypass. + if rpm -U --noscripts "$NEW_KR" >/tmp/rpmU.log 2>&1; then + fail "rpm -U installed the new corpus beside the older openwatch" + rpm -U --oldpackage --nodeps --noscripts "$OLD_KR" >/dev/null 2>&1 + else + pass "rpm -U refused: $(grep -m1 openwatch-kensa-engine /tmp/rpmU.log | sed 's/^[[:space:]]*//')" + fi +fi + +echo "### 2. openwatch-only upgrade over the older corpus succeeds" +reset_to_old +if txn "$NEW_OW"; then + pass "openwatch $(installed openwatch) installed beside kensa-rules $(installed kensa-rules)" +else + fail "openwatch-only upgrade failed; log follows"; cat "$LAST_LOG" +fi + +echo "### 3. coordinated upgrade succeeds" +reset_to_old +if txn "$NEW_OW" "$NEW_KR"; then + pass "upgraded to openwatch $(installed openwatch) and kensa-rules $(installed kensa-rules)" +else + fail "coordinated upgrade failed; log follows"; cat "$LAST_LOG" +fi + +echo "### 5. the new corpus refuses a downgraded openwatch" +if txn "$OLD_OW"; then + fail "openwatch downgraded to $(installed openwatch) beside kensa-rules $(installed kensa-rules)" +else + pass "refused while kensa-rules $(installed kensa-rules) is installed" +fi + +echo "### 4. fresh install of the new pair succeeds" +if [ "$KIND" = deb ]; then + dpkg --purge kensa-rules openwatch >/dev/null 2>&1 +else + rpm -e --nodeps --noscripts kensa-rules openwatch >/dev/null 2>&1 +fi +[ "$(installed openwatch)" = absent ] || fail "cleanup left openwatch installed" +if txn "$NEW_OW" "$NEW_KR"; then + pass "fresh install: openwatch $(installed openwatch), kensa-rules $(installed kensa-rules)" +else + fail "fresh install failed; log follows"; cat "$LAST_LOG" +fi + +echo "### 6. release-candidate ordering" +older_than() { # older_than A B: true when A sorts strictly before B + if [ "$KIND" = deb ]; then + dpkg --compare-versions "$1" lt "$2" + else + [ "$(rpm --eval "%{lua: print(rpm.vercmp('$1', '$2'))}")" = "-1" ] + fi +} +for pair in "1:0.8.0~rc.5 1:0.8.0~rc.6" "1:0.8.0~rc.6 1:0.8.0~rc.10" "1:0.8.0~rc.10 1:0.8.0" \ + "1:0.7.1 1:0.8.0~rc.5" "0.9.0 0.10.0"; do + set -- $pair + older_than "$1" "$2" && pass "$1 < $2" || fail "$1 does not sort before $2" +done + +echo +if [ "$FAILURES" -eq 0 ]; then + echo "kensa-rules compat ($KIND): all checks passed" + exit 0 +fi +echo "kensa-rules compat ($KIND): $FAILURES check(s) failed" +exit 1 diff --git a/packaging/tests/run-kensa-rules-compat-test.sh b/packaging/tests/run-kensa-rules-compat-test.sh new file mode 100755 index 000000000..859ee0896 --- /dev/null +++ b/packaging/tests/run-kensa-rules-compat-test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# run-kensa-rules-compat-test.sh: host-side runner for +# kensa-rules-compat-container-test.sh (spec release-upgrade AC-09). +# +# Usage: run-kensa-rules-compat-test.sh +# e.g. run-kensa-rules-compat-test.sh rockylinux:9 rpm /tmp/old dist +# old-dir holds a release that predates the engine provide (the published +# v0.8.0-rc.5 openwatch and kensa-rules assets); new-dir holds the packages +# built from this tree. Needs docker. +set -euo pipefail +IMAGE="${1:?usage: $0 }" +KIND="${2:?kind}" +OLD="$(cd "${3:?old-dir}" && pwd)" +NEW="$(cd "${4:?new-dir}" && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +command -v docker >/dev/null || { echo "docker is required" >&2; exit 1; } + +docker run --rm \ + -v "$OLD":/pk/old:ro \ + -v "$NEW":/pk/new:ro \ + -v "$SCRIPT_DIR/kensa-rules-compat-container-test.sh":/t.sh:ro \ + "$IMAGE" bash /t.sh "$KIND" /pk/old /pk/new diff --git a/packaging/tests/upgrade_test.go b/packaging/tests/upgrade_test.go index 9bbc054fc..47357cdc9 100644 --- a/packaging/tests/upgrade_test.go +++ b/packaging/tests/upgrade_test.go @@ -7,10 +7,16 @@ // AC-05 TestUpgrade_HelperSequenceAndFailSafe // AC-06 TestUpgrade_CleanupTimerShippedAndKeepsNewest // AC-07 TestUpgrade_PayloadShipsUpgradeFiles +// AC-08 TestUpgrade_PackagesDeclareEngineCorpusPairing +// AC-09 TestUpgrade_PackageManagerEnforcesEngineCorpusPairing package packaging_test import ( + "os" + "os/exec" + "path/filepath" + "regexp" "strings" "testing" ) @@ -138,3 +144,85 @@ func TestUpgrade_PayloadShipsUpgradeFiles(t *testing.T) { } }) } + +// linkedKensaVersion reads the Kensa module version from go.mod directly, +// not through packaging/common/kensa-version.sh, so the check below is not +// the build's own derivation grading itself. +func linkedKensaVersion(t *testing.T) string { + t.Helper() + m := regexp.MustCompile(`(?m)^\s*github\.com/Hanalyx/kensa v(\d+\.\d+\.\d+)\s*$`). + FindStringSubmatch(readRepoFile(t, "go.mod")) + if m == nil { + t.Fatal("go.mod does not pin github.com/Hanalyx/kensa at a release version") + } + return m[1] +} + +func debField(t *testing.T, deb, field string) string { + t.Helper() + out, err := exec.Command("dpkg-deb", "-f", deb, field).Output() + if err != nil { + t.Fatalf("dpkg-deb -f %s %s: %v", deb, field, err) + } + return strings.TrimSpace(string(out)) +} + +// @ac AC-08 +// AC-08: the built packages carry the engine/corpus pairing at the version +// go.mod pins. +func TestUpgrade_PackagesDeclareEngineCorpusPairing(t *testing.T) { + t.Run("release-upgrade/AC-08", func(t *testing.T) { + v := linkedKensaVersion(t) + + owRPM := rpmPath(t) + if got := rpmQuery(t, owRPM, "[%{PROVIDENAME} %{PROVIDEFLAGS:depflags} %{PROVIDEVERSION}\n]"); !strings.Contains(got, "openwatch-kensa-engine = "+v+"\n") { + t.Errorf("openwatch RPM provides:\n%s\nwant openwatch-kensa-engine = %s", got, v) + } + krRPM := kensaRulesRPMPath(t) + if got := rpmQuery(t, krRPM, "%{VERSION}"); got != v { + t.Errorf("kensa-rules RPM version = %q, want %q", got, v) + } + if got := rpmQuery(t, krRPM, "[%{REQUIRENAME} %{REQUIREFLAGS:depflags} %{REQUIREVERSION}\n]"); !strings.Contains(got, "openwatch-kensa-engine >= "+v+"\n") { + t.Errorf("kensa-rules RPM requires:\n%s\nwant openwatch-kensa-engine >= %s", got, v) + } + + owDEB := debPath(t) + if got, want := debField(t, owDEB, "Provides"), "openwatch-kensa-engine (= "+v+")"; !strings.Contains(got, want) { + t.Errorf("openwatch DEB Provides = %q, want it to contain %q", got, want) + } + krDEB := kensaRulesDebPath(t) + if got := debField(t, krDEB, "Version"); got != v { + t.Errorf("kensa-rules DEB version = %q, want %q", got, v) + } + if got, want := debField(t, krDEB, "Depends"), "openwatch-kensa-engine (>= "+v+")"; !strings.Contains(got, want) { + t.Errorf("kensa-rules DEB Depends = %q, want it to contain %q", got, want) + } + }) +} + +// @ac AC-09 +// AC-09: the package manager enforces the pairing. The container scenarios +// live in kensa-rules-compat-container-test.sh; this runs them for one +// image. package-smoke sets the four variables (previous GA as the old +// release, the candidate's packages as the new); elsewhere it skips. +func TestUpgrade_PackageManagerEnforcesEngineCorpusPairing(t *testing.T) { + t.Run("release-upgrade/AC-09", func(t *testing.T) { + image := os.Getenv("OPENWATCH_KENSA_COMPAT_IMAGE") + kind := os.Getenv("OPENWATCH_KENSA_COMPAT_KIND") + oldDir := os.Getenv("OPENWATCH_KENSA_COMPAT_OLD_DIR") + newDir := os.Getenv("OPENWATCH_KENSA_COMPAT_NEW_DIR") + if image == "" || kind == "" || oldDir == "" || newDir == "" { + t.Skip("set OPENWATCH_KENSA_COMPAT_{IMAGE,KIND,OLD_DIR,NEW_DIR} to run the container pairing test") + } + haveTool(t, "docker") + runner := filepath.Join(appDir(t), "packaging", "tests", "run-kensa-rules-compat-test.sh") + out, err := exec.Command("bash", runner, image, kind, oldDir, newDir).CombinedOutput() + t.Logf("%s", out) + if err != nil { + t.Fatalf("pairing test failed on %s (%s): %v", image, kind, err) + } + if !strings.Contains(string(out), "kensa-rules compat ("+kind+"): all checks passed") { + t.Fatal("the container test did not report a complete pass") + } + }) +} diff --git a/specs/release/upgrade.spec.yaml b/specs/release/upgrade.spec.yaml index 388017787..96f45a5a4 100644 --- a/specs/release/upgrade.spec.yaml +++ b/specs/release/upgrade.spec.yaml @@ -1,7 +1,7 @@ spec: id: release-upgrade title: One-command package upgrade with auto-migrate + backup - version: "1.0.0" + version: "1.1.0" status: approved tier: 2 @@ -61,6 +61,23 @@ spec: description: The backup-cleanup MUST always keep the most recent dump regardless of age, pruning only older dumps past the retention window. type: technical enforcement: error + - id: C-06 + description: > + v1.1.0 - the package manager MUST refuse to pair a Kensa rule corpus with + an older Kensa engine. The openwatch RPM and DEB declare the engine they + link as openwatch-kensa-engine at the linked module version, and the + kensa-rules RPM and DEB require openwatch-kensa-engine at least their + own version. Both values come from packaging/common/kensa-version.sh, so + they cannot drift apart. An older engine cannot load a newer corpus + (Kensa v0.9.0 fails the whole v0.10.0 load and the service starts with + every scan failing, CP bugs/OW-081); a newer engine loads an older + corpus, so an openwatch-only upgrade stays allowed. Packages built + before this constraint (v0.8.0-rc.5 and earlier) provide no engine and + so cannot satisfy a corpus that requires one. Residual, measured and not covered: raw `dpkg -i` of the + corpus alone unpacks it and leaves it unconfigured, and `rpm --nodeps` + bypasses the check; the documented paths use dnf and apt. + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -91,3 +108,26 @@ spec: description: The package payloads contain /usr/lib/openwatch/openwatch-upgrade.sh, /usr/lib/openwatch/cleanup-backups.sh, the two systemd units, and the empty /var/lib/openwatch/backups directory; /etc/openwatch/upgrade.conf ships as a noreplace config file. priority: high references_constraints: [C-03] + - id: AC-08 + description: > + v1.1.0 - the built packages carry the pairing: the openwatch RPM + provides openwatch-kensa-engine = V and the openwatch DEB lists + Provides openwatch-kensa-engine (= V), where V is the Kensa module + version in go.mod; the kensa-rules RPM is version V and requires + openwatch-kensa-engine >= V, and the kensa-rules DEB depends on + openwatch-kensa-engine (>= V). Checked against built artifacts, not + against the spec files. + priority: critical + references_constraints: [C-06] + - id: AC-09 + description: > + v1.1.0 - in a container, with a release that predates the engine + provide installed, the package manager refuses kensa-rules alone and + leaves the installed corpus unchanged (dnf, rpm -U and apt); an + openwatch-only upgrade succeeds; the coordinated upgrade of both + packages succeeds; a fresh install of the new pair succeeds; the new + corpus refuses a downgrade of openwatch to that release; and versions + order 1:0.8.0~rc.5 < 1:0.8.0~rc.6 < 1:0.8.0~rc.10 < 1:0.8.0 and 0.9.0 < + 0.10.0 in both formats. Runs on RPM and DEB. + priority: critical + references_constraints: [C-06] From d635a1195206875c862d6b12b2182f4e27f4b57e Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Sat, 26 Sep 2026 14:58:19 -0400 Subject: [PATCH 03/10] fix(kensa): flag variables the operator must decide and keep NIST lift to 800-53 configure_me (api-system-scan-config C-07, amended in 1.5.0, which is unpublished). It marked the three placeholder defaults only. It now marks every variable whose built-in value cannot be right for a real site: the three placeholders and every corpus-used variable Kensa ships with an empty default. For v0.10.0 that is eleven, named in C-07 and pinned in the test: the three plus authorized_listening_ports, authorized_local_accounts, authorized_network_protocols, authorized_privileged_users, authorized_service_accounts, authorized_services, flaw_remediation_max_days and suid_sgid_baseline. The other 32 carry a benchmark value or a generic list valid as shipped. C-07 says configure_me marks a decision to make and does not classify a scan result, which needs KN-OW-021. The card's count no longer calls every flagged value a placeholder; the OpenAPI description follows and the generated code is refreshed. The catalog test (AC-08) now checks contents: the entry set, each default and each rule-id list against values built in the test from the two library tables, two fixed points, and the ConfigureMe set against the named inventory. Mutations, each red then restored with the hash checked: flag placeholders only; trim whitespace from defaults, which changes only banner_text. Remediation projected lift (api-remediation 1.8.0, C-07, AC-18). The nist bucket matched every key starting "nist", so v0.10.0's nist_800_171 joined it: 19 rules carry 800-171 and not 800-53, so they would be quoted a NIST gain, and the denominator would count both frameworks. The bucket is now the 800-53 family, in both the class match and the SQL denominator. AC-18 discriminates each half, and a mutation of each half turns it red (25 instead of 33.33; a lift quoted for an 800-171-only rule). --- CHANGELOG.md | 11 +- api/openapi.yaml | 9 +- docs/guides/SCANNING_AND_COMPLIANCE.md | 3 + frontend/src/api/schema.d.ts | 9 +- .../components/settings/ScanVariablesCard.tsx | 7 +- internal/kensa/variables.go | 19 ++-- internal/kensa/variables_test.go | 107 +++++++++++++----- internal/remediation/lift_nist_test.go | 56 +++++++++ internal/remediation/service.go | 12 +- internal/server/api/server.gen.go | 2 +- specs/api/remediation.spec.yaml | 15 ++- specs/api/system-scan-config.spec.yaml | 6 +- 12 files changed, 199 insertions(+), 57 deletions(-) create mode 100644 internal/remediation/lift_nist_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 245aff56f..6104e9a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,9 +91,14 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). RHEL 8 instead of reporting not applicable. Eight new scan variables ship with no default, and seven rules report - skipped until theirs is declared. The scanning guide lists them under - "Scan variables". Values are not type checked when saved (CP - `bugs/OW-080`). + skipped until theirs is declared. Settings marks them "Configure me", + alongside the three placeholder defaults it already marked, and the + scanning guide lists them under "Scan variables". Values are not type + checked when saved (CP `bugs/OW-080`). + + The remediation "NIST" projected lift counts NIST SP 800-53 rules only. + Matching every `nist` key would have folded in the new 800-171 mapping, + quoting a NIST gain for 19 rules that are not in 800-53. - **When an outcome cannot be confirmed, OpenWatch says so.** A sign-in, refresh, logout or administrative change whose commit result is unknown diff --git a/api/openapi.yaml b/api/openapi.yaml index 60467f514..75f98df6c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2002,9 +2002,10 @@ paths: uses; unused ones are not listed). Each entry carries the built-in default, the operator override when set, the count and ids of affected rules, and the configure_me flag marking - organization-specific placeholder defaults - (rsyslog_remote_server, chrony_ntp_pool, banner_text) that - operators should always review. Spec api-system-scan-config. + variables the operator has to decide: the organization-specific + placeholder defaults (rsyslog_remote_server, chrony_ntp_pool, + banner_text) and every variable the corpus ships with no value, + whose rule skips until it is declared. Spec api-system-scan-config. responses: '200': description: Corpus-used variables sorted by name @@ -6360,7 +6361,7 @@ components: description: The referencing rule ids, sorted configure_me: type: boolean - description: Organization-specific placeholder default the operator should always review + description: The built-in value cannot be right for a real site (a placeholder, or no value at all), so the operator has to set it ScanVariableOverrides: type: object diff --git a/docs/guides/SCANNING_AND_COMPLIANCE.md b/docs/guides/SCANNING_AND_COMPLIANCE.md index d5cc28cce..04c73363a 100644 --- a/docs/guides/SCANNING_AND_COMPLIANCE.md +++ b/docs/guides/SCANNING_AND_COMPLIANCE.md @@ -181,6 +181,9 @@ rule. Until you declare the variable, seven of those rules report **skipped** and name the variable to set. A skipped rule is left out of the score, so it is neither a pass nor a fail. The eighth, `suid-sgid-files-reviewed`, still runs its world-writable check; only its inventory comparison needs the baseline. +The card marks each of the eight "Configure me" until you set it, as it does +the three placeholder defaults (`rsyslog_remote_server`, `chrony_ntp_pool` +and `banner_text`). | Variable | Rule | What to declare | |---|---|---| diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index fe7953ec5..f5c1359e1 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1163,9 +1163,10 @@ export interface paths { * uses; unused ones are not listed). Each entry carries the * built-in default, the operator override when set, the count * and ids of affected rules, and the configure_me flag marking - * organization-specific placeholder defaults - * (rsyslog_remote_server, chrony_ntp_pool, banner_text) that - * operators should always review. Spec api-system-scan-config. + * variables the operator has to decide: the organization-specific + * placeholder defaults (rsyslog_remote_server, chrony_ntp_pool, + * banner_text) and every variable the corpus ships with no value, + * whose rule skips until it is declared. Spec api-system-scan-config. */ get: operations["getSystemScanVariables"]; /** @@ -3901,7 +3902,7 @@ export interface components { affects_rules: number; /** @description The referencing rule ids, sorted */ rule_ids: string[]; - /** @description Organization-specific placeholder default the operator should always review */ + /** @description The built-in value cannot be right for a real site (a placeholder, or no value at all), so the operator has to set it */ configure_me: boolean; }[]; }; diff --git a/frontend/src/components/settings/ScanVariablesCard.tsx b/frontend/src/components/settings/ScanVariablesCard.tsx index 472f9c062..ef4974098 100644 --- a/frontend/src/components/settings/ScanVariablesCard.tsx +++ b/frontend/src/components/settings/ScanVariablesCard.tsx @@ -7,8 +7,9 @@ import { Btn, Callout } from '@/components/settings/primitives'; // ───────────────────────────────────────────────────────────────────────── // Scan variables — operator overrides for the kensa rule-template // variables (GET/PUT /system/scan/variables). Only corpus-used -// variables are listed; the three organization-specific placeholder -// defaults carry a "Configure me" chip. Section-local save: the PUT +// variables are listed; a variable the operator has to decide (a +// placeholder default, or no built-in value) carries a "Configure me" +// chip. Section-local save: the PUT // replaces the full override map (values equal to the default are // dropped server-side). The scan path picks the change up on the // next scan. Lives on Settings > Compliance policies: the values @@ -109,7 +110,7 @@ export function ScanVariablesCard() {

Values are substituted into rule templates at scan time. Defaults are STIG-strict. {configureMeCount > 0 && - ` ${configureMeCount} placeholder ${configureMeCount === 1 ? 'value needs' : 'values need'} your organization's settings.`} + ` ${configureMeCount} ${configureMeCount === 1 ? 'value needs' : 'values need'} your organization's settings.`}

{vars.map((v, i) => { diff --git a/internal/kensa/variables.go b/internal/kensa/variables.go index 58a78ba5d..53992ecad 100644 --- a/internal/kensa/variables.go +++ b/internal/kensa/variables.go @@ -2,16 +2,18 @@ // their built-in defaults, for the Settings scan-variables UI and the // PUT validation path. // -// kensa.BuiltInVars ships ~29 defaults but only the variables that -// corpus rules actually reference are rendered or accepted as -// overrides (the plan's "20 used variables; don't render the unused -// 9"). Three defaults are organization-specific placeholders the -// operator should always review; ConfigureMe flags them. +// kensa.BuiltInVars ships more defaults than the corpus uses; only the +// variables that corpus rules actually reference are rendered or +// accepted as overrides. ConfigureMe flags the ones whose built-in value +// cannot be right for a real site, so the operator has to decide: the +// three organization-specific placeholders, and every variable Kensa +// ships with no value at all (eight in v0.10.0, each gating one rule +// that skips until it is declared). // // internal/kensa is the only package allowed to import the upstream // kensa module — consumers depend on this wrapper. // -// Spec: api-system-scan-config v1.1.0. +// Spec: api-system-scan-config v1.1.0, C-07 amended in v1.5.0. package kensa import ( @@ -36,7 +38,8 @@ type VariableInfo struct { Name string Default string // kensa built-in default Rules []string // rule ids referencing the variable, sorted - // ConfigureMe marks organization-specific placeholder defaults. + // ConfigureMe marks a variable the operator has to decide: a + // placeholder default, or no built-in value. ConfigureMe bool } @@ -68,7 +71,7 @@ func NewVariableCatalog(rulesDir string) (*VariableCatalog, error) { Name: name, Default: defaults[name], // "" when a rule references an undefaulted var Rules: sorted, - ConfigureMe: placeholderVars[name], + ConfigureMe: placeholderVars[name] || defaults[name] == "", } } return &VariableCatalog{vars: m}, nil diff --git a/internal/kensa/variables_test.go b/internal/kensa/variables_test.go index cf6931b14..8ecc851a6 100644 --- a/internal/kensa/variables_test.go +++ b/internal/kensa/variables_test.go @@ -7,6 +7,8 @@ package kensa import ( "context" "os" + "reflect" + "sort" "testing" pkgkensa "github.com/Hanalyx/kensa/pkg/kensa" @@ -25,58 +27,92 @@ func corpusDir(t *testing.T) string { } // @ac AC-08 -// AC-08 (catalog half): only corpus-used variables are listed, sorted, -// each with rules attributed; exactly the three placeholder names -// carry ConfigureMe; nil catalog is inert. +// AC-08 (catalog half): the catalog is exactly the corpus-used subset of +// kensa's built-ins, each entry carrying its built-in default and the +// sorted ids of the rules that use it; ConfigureMe marks exactly the +// variables the operator has to decide; nil catalog is inert. func TestVariableCatalog_CorpusUsedAndPlaceholders(t *testing.T) { t.Run("api-system-scan-config/AC-08", func(t *testing.T) { - cat, err := NewVariableCatalog(corpusDir(t)) + dir := corpusDir(t) + cat, err := NewVariableCatalog(dir) if err != nil { t.Fatalf("NewVariableCatalog: %v", err) } - list := cat.List() - // The catalog is the corpus-used subset of kensa's built-ins - // (C-07). The bound comes from the built-in table itself, so a - // Kensa bump that adds variables does not break the test while a - // catalog that lists a non-built-in still does. builtins, err := pkgkensa.BuiltInVars() if err != nil { t.Fatalf("BuiltInVars: %v", err) } - if len(list) == 0 || len(list) > len(builtins) { - t.Fatalf("catalog len = %d, want 1..%d (corpus-used subset of built-ins)", len(list), len(builtins)) + used, err := pkgkensa.RuleVariables(dir) + if err != nil { + t.Fatalf("RuleVariables: %v", err) } - for _, v := range list { - if _, ok := builtins[v.Name]; !ok { - t.Errorf("%s is listed but is not a kensa built-in variable", v.Name) + + // Contents, not only shape: the expected entries are built here from + // the two library tables, and every listed entry must match one. + want := map[string]VariableInfo{} + for name, rules := range used { + if _, ok := builtins[name]; !ok { + continue } + sorted := append([]string(nil), rules...) + sort.Strings(sorted) + want[name] = VariableInfo{Name: name, Default: builtins[name], Rules: sorted} + } + list := cat.List() + if len(list) != len(want) { + t.Errorf("catalog len = %d, want %d (corpus-used built-ins)", len(list), len(want)) } - flagged := 0 for i, v := range list { if i > 0 && list[i-1].Name >= v.Name { t.Errorf("list not sorted: %q >= %q", list[i-1].Name, v.Name) } - if len(v.Rules) == 0 { - t.Errorf("%s: corpus-used variable with zero rules", v.Name) + w, ok := want[v.Name] + if !ok { + t.Errorf("%s is listed but is not a corpus-used kensa built-in", v.Name) + continue } - if v.ConfigureMe { - flagged++ - if !placeholderVars[v.Name] { - t.Errorf("%s flagged ConfigureMe but is not a placeholder", v.Name) - } + if v.Default != w.Default { + t.Errorf("%s default = %q, want %q", v.Name, v.Default, w.Default) + } + if !reflect.DeepEqual(v.Rules, w.Rules) { + t.Errorf("%s rules = %v, want %v", v.Name, v.Rules, w.Rules) } if !cat.Has(v.Name) { t.Errorf("Has(%s) = false for a listed variable", v.Name) } } - // The three placeholders are corpus-used in the shipped rules. - if flagged != 3 { - t.Errorf("ConfigureMe count = %d, want 3 (rsyslog_remote_server, chrony_ntp_pool, banner_text)", flagged) + + // Two fixed points, so a library change that moved both tables in + // step would still be noticed. + for name, rule := range map[string]string{ + "authorized_listening_ports": "authorized-listening-ports", + "authorized_local_accounts": "no-unauthorized-accounts", + } { + var got *VariableInfo + for i := range list { + if list[i].Name == name { + got = &list[i] + } + } + if got == nil || got.Default != "" || !reflect.DeepEqual(got.Rules, []string{rule}) { + t.Errorf("%s = %+v, want an empty default used only by %s", name, got, rule) + } + } + + // ConfigureMe: exactly the inventory C-07 names for this corpus. + var flagged []string + for _, v := range list { + if v.ConfigureMe { + flagged = append(flagged, v.Name) + } + } + if !reflect.DeepEqual(flagged, configureMeInventory) { + t.Errorf("ConfigureMe set = %v\nwant (C-07 inventory) %v", flagged, configureMeInventory) } + if cat.Has("definitely_not_a_variable") { t.Errorf("Has(unknown) = true") } - var nilCat *VariableCatalog if nilCat.List() != nil || nilCat.Has("x") || nilCat.Len() != 0 { t.Errorf("nil catalog not inert") @@ -84,6 +120,25 @@ func TestVariableCatalog_CorpusUsedAndPlaceholders(t *testing.T) { }) } +// configureMeInventory is the variable inventory api-system-scan-config +// C-07 names for the pinned corpus (Kensa v0.10.0): the three placeholder +// defaults and the eight variables Kensa ships with no value. A Kensa bump +// that changes the set fails AC-08 on purpose, so the inventory and the +// contract are reviewed together. +var configureMeInventory = []string{ + "authorized_listening_ports", + "authorized_local_accounts", + "authorized_network_protocols", + "authorized_privileged_users", + "authorized_service_accounts", + "authorized_services", + "banner_text", + "chrony_ntp_pool", + "flaw_remediation_max_days", + "rsyslog_remote_server", + "suid_sgid_baseline", +} + // @ac AC-09 // AC-09 (reload half): varsFingerprint is order-independent and // value-sensitive — the corpus cache reloads exactly when the diff --git a/internal/remediation/lift_nist_test.go b/internal/remediation/lift_nist_test.go new file mode 100644 index 000000000..52aeffa5c --- /dev/null +++ b/internal/remediation/lift_nist_test.go @@ -0,0 +1,56 @@ +// @spec api-remediation +// +// AC traceability (DSN-gated): +// +// AC-18 TestProjectLift_NISTIsSP80053Only + +package remediation + +import ( + "context" + "math" + "testing" +) + +// @ac AC-18 +// AC-18: the nist projection is NIST SP 800-53 only. Kensa v0.10.0 added +// nist_800_171, and a "nist" prefix match folds it in two ways at once: a +// rule mapped to 800-171 alone is quoted a NIST lift, and every 800-171-only +// rule inflates the denominator. The fixture discriminates both: with the +// prefix match the first rule reads 25 (one of four) and the second rule +// gets a projection; with the family match it reads 100/3 and gets none. +func TestProjectLift_NISTIsSP80053Only(t *testing.T) { + t.Run("api-remediation/AC-18", func(t *testing.T) { + pool := freshPool(t) + ctx := context.Background() + user := seedUser(t, pool, "lift-nist") + hostID := seedHost(t, pool, user) + svc := NewService(pool, fakeEmitter(&[]emitCall{})) + + seedRuleState(t, pool, hostID, "r53-fail", "fail", `{"nist_800_53":["AC-2"]}`) + seedRuleState(t, pool, hostID, "r53-pass", "pass", `{"nist_800_53":["AC-3"]}`) + seedRuleState(t, pool, hostID, "rboth-pass", "pass", `{"nist_800_53":["AC-6"],"nist_800_171":["3.1.5[a]"]}`) + seedRuleState(t, pool, hostID, "r171-fail", "fail", `{"nist_800_171":["3.13.9[c]"]}`) + + got, err := svc.ProjectLift(ctx, hostID, "r53-fail") + if err != nil { + t.Fatalf("ProjectLift: %v", err) + } + if got.NIST == nil { + t.Fatal("no nist projection for a failing 800-53 rule") + } + if want := math.Round(100.0/3*100) / 100; *got.NIST != want { + t.Errorf("nist lift = %v, want %v (one of three 800-53 rules). 25 means the "+ + "denominator counted the 800-171-only rule", *got.NIST, want) + } + + only171, err := svc.ProjectLift(ctx, hostID, "r171-fail") + if err != nil { + t.Fatalf("ProjectLift: %v", err) + } + if only171.NIST != nil { + t.Errorf("nist lift for a rule mapped only to nist_800_171 = %v, want none: "+ + "the field is labeled NIST and means 800-53", *only171.NIST) + } + }) +} diff --git a/internal/remediation/service.go b/internal/remediation/service.go index ecbfa98c8..16eaf0d0a 100644 --- a/internal/remediation/service.go +++ b/internal/remediation/service.go @@ -345,7 +345,7 @@ func (s *Service) ProjectLift(ctx context.Context, hostID uuid.UUID, ruleID stri SELECT count(*) FILTER (WHERE EXISTS (SELECT 1 FROM jsonb_object_keys(framework_refs) k WHERE k LIKE 'cis%')), count(*) FILTER (WHERE EXISTS (SELECT 1 FROM jsonb_object_keys(framework_refs) k WHERE k LIKE 'stig%')), - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM jsonb_object_keys(framework_refs) k WHERE k LIKE 'nist%')) + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM jsonb_object_keys(framework_refs) k WHERE k = 'nist_800_53' OR k LIKE 'nist\_800\_53\_%')) FROM host_rule_state_current WHERE host_id = $1`, hostID).Scan(&nCIS, &nSTIG, &nNIST) if err != nil { return ProjectedLift{}, fmt.Errorf("remediation: project lift denom: %w", err) @@ -365,14 +365,20 @@ func (s *Service) ProjectLift(ctx context.Context, hostID uuid.UUID, ruleID stri } // frameworkClass maps a kensa framework_id (e.g. "cis_rhel9_v2", -// "stig_rhel9_v2r7", "nist_800_53_r5") to the cis/stig/nist projection bucket. +// "stig_rhel9_v2r7", "nist_800_53") to the cis/stig/nist projection bucket. +// +// The nist bucket is NIST SP 800-53 only. Kensa v0.10.0 added nist_800_171, +// and a "nist" prefix would fold it in: 19 rules carry 800-171 and not +// 800-53, so they would quote a NIST lift, and the denominator would count +// both frameworks. The field would then describe no single framework, which +// is what spec api-remediation C-07 requires of it. func frameworkClass(fwID string) string { switch { case strings.HasPrefix(fwID, "cis"): return "cis" case strings.HasPrefix(fwID, "stig"): return "stig" - case strings.HasPrefix(fwID, "nist"): + case fwID == "nist_800_53" || strings.HasPrefix(fwID, "nist_800_53_"): return "nist" } return "" diff --git a/internal/server/api/server.gen.go b/internal/server/api/server.gen.go index c61d76cfd..489b6b46f 100644 --- a/internal/server/api/server.gen.go +++ b/internal/server/api/server.gen.go @@ -4122,7 +4122,7 @@ type ScanVariablesResponse struct { // AffectsRules Count of corpus rules referencing the variable AffectsRules int `json:"affects_rules"` - // ConfigureMe Organization-specific placeholder default the operator should always review + // ConfigureMe The built-in value cannot be right for a real site (a placeholder, or no value at all), so the operator has to set it ConfigureMe bool `json:"configure_me"` // Default kensa built-in default in effect without an override diff --git a/specs/api/remediation.spec.yaml b/specs/api/remediation.spec.yaml index fa2f41776..46162a8ff 100644 --- a/specs/api/remediation.spec.yaml +++ b/specs/api/remediation.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-remediation title: Remediation governance - request/approve lifecycle, projected lift, and queued single-rule execute/rollback (OpenWatch Core, free) - version: "1.7.0" + version: "1.8.0" status: approved tier: 1 @@ -115,7 +115,7 @@ spec: type: technical enforcement: error - id: C-07 - description: 'ProjectLift is read-only and best-effort: for a failing rule it returns per-framework projected score deltas derived from host_rule_state.framework_refs (delta ~= 100/N where N is the count of that framework''s rules on the host); when framework data is absent or unparseable it returns an empty projection and never errors or blocks the request' + description: 'ProjectLift is read-only and best-effort: for a failing rule it returns per-framework projected score deltas derived from host_rule_state.framework_refs (delta ~= 100/N where N is the count of that framework''s rules on the host). v1.8.0 - each field names one framework family: cis is every cis key, stig every stig key, and nist is NIST SP 800-53 only (nist_800_53 and its revisions), never nist_800_171, which Kensa v0.10.0 added and which the UI would otherwise present under the same NIST label; when framework data is absent or unparseable it returns an empty projection and never errors or blocks the request' type: technical enforcement: warning - id: C-09 @@ -304,3 +304,14 @@ spec: shape) still verifies. priority: critical references_constraints: [C-08, C-10] + - id: AC-18 + description: > + v1.8.0 - the nist projection counts NIST SP 800-53 only. On a host whose + current corpus holds a failing rule mapped to nist_800_53, a passing + rule mapped to nist_800_53, a passing rule mapped to both nist_800_53 + and nist_800_171, and a failing rule mapped to nist_800_171 alone, + ProjectLift quotes 100/3 for the first rule (three 800-53 rules, not + four NIST-prefixed ones) and no nist projection for the 800-171-only + rule. + priority: high + references_constraints: [C-07] diff --git a/specs/api/system-scan-config.spec.yaml b/specs/api/system-scan-config.spec.yaml index bd153c018..c1b33180d 100644 --- a/specs/api/system-scan-config.spec.yaml +++ b/specs/api/system-scan-config.spec.yaml @@ -90,7 +90,7 @@ spec: enforcement: error - id: C-07 - description: 'v1.1.0 - the variables surface lists ONLY corpus-used variables (kensa.RuleVariables intersected with BuiltInVars via the internal/kensa VariableCatalog; unused built-ins are not rendered). The three organization-specific placeholder defaults (rsyslog_remote_server, chrony_ntp_pool, banner_text) carry configure_me: true. PUT replaces the FULL override map, rejects unknown names with 400 naming the key, and drops overrides equal to the built-in default. The scan path reloads the rule corpus with the merged variables when the override set changes (fingerprint compare; reload failure keeps the last-good corpus)' + description: 'v1.1.0 - the variables surface lists ONLY corpus-used variables (kensa.RuleVariables intersected with BuiltInVars via the internal/kensa VariableCatalog; unused built-ins are not rendered). configure_me is true exactly for a variable whose built-in value cannot be right for a real site, so the operator has to decide it. v1.5.0 amends this from the three placeholders alone. Two kinds qualify: the three organization-specific placeholder defaults (rsyslog_remote_server, chrony_ntp_pool, banner_text), and every corpus-used variable Kensa ships with an empty default. For the pinned corpus (Kensa v0.10.0) the inventory is eleven variables: those three plus authorized_listening_ports, authorized_local_accounts, authorized_network_protocols, authorized_privileged_users, authorized_service_accounts, authorized_services, flaw_remediation_max_days and suid_sgid_baseline, each of which leaves its one rule skipped until declared. The other 32 corpus-used variables carry a benchmark value or a generic list that is valid as shipped and are not flagged. configure_me marks a decision to make; it does not classify a scan result, which needs the typed skip reason in features/KN-OW-021. PUT replaces the FULL override map, rejects unknown names with 400 naming the key, and drops overrides equal to the built-in default. The scan path reloads the rule corpus with the merged variables when the override set changes (fingerprint compare; reload failure keeps the last-good corpus)' type: technical enforcement: error @@ -124,7 +124,7 @@ spec: priority: critical references_constraints: [C-06] - id: AC-08 - description: 'v1.1.0 - GET /system/scan/variables returns one entry per corpus-used variable sorted by name, each with the built-in default, the effective value (override when set), overridden flag, affects_rules count + sorted rule_ids, and configure_me true for exactly the three placeholder names; anonymous callers are rejected' + description: 'v1.1.0 - GET /system/scan/variables returns one entry per corpus-used variable sorted by name, each with the built-in default, the effective value (override when set), overridden flag, affects_rules count + sorted rule_ids, and configure_me true for exactly the C-07 inventory (v1.5.0: the eleven variables, checked as a named set); the catalog content is checked, not only its shape: every entry''s default equals the Kensa built-in and its rule ids equal the sorted corpus references; anonymous callers are rejected' priority: critical references_constraints: [C-02, C-07] - id: AC-09 @@ -136,6 +136,6 @@ spec: priority: critical references_constraints: [C-02] - id: AC-11 - description: 'v1.5.0 - a list-valued override (for example authorized_listening_ports = "22,443") reaches the affected rule''s check parameters verbatim when the corpus reloads, and removing the override restores the built-in default on the next reload. Kensa v0.10.0 ships eight corpus-used variables whose built-in default is empty; configure_me stays limited to the three placeholder names in C-07' + description: 'v1.5.0 - a list-valued override (for example authorized_listening_ports = "22,443") reaches the affected rule''s check parameters verbatim when the corpus reloads, and removing the override restores the built-in default on the next reload. Kensa v0.10.0 ships eight corpus-used variables whose built-in default is empty, and C-07 flags them configure_me' priority: high references_constraints: [C-07] From b7454d4b7c772bf954a37ab6100558258cf78e5c Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Sat, 26 Sep 2026 15:38:50 -0400 Subject: [PATCH 04/10] test(packaging): verify the engine pairing against the resolvers in Go CI Go CI failed its specter sync gate on 3a243ae4: release-upgrade AC-09 skipped there, because its only test drove containers, which only package-smoke provides. A skipped criterion is uncovered. AC-09 now asks the resolvers directly, with no root, container or network: rpm --test against a scratch rpmdb, and apt-get -s against a fixture dpkg status, using the real packages the tree builds. The older openwatch is a payload-free fixture at 1:0.7.1 that provides no engine. It must sort below the tree's own build (packaging/version.env's rc), which the first draft of this test got wrong: with the fixture at rc.5 the resolvers saw the same version already installed and tested nothing. A base fixture supplies every other requirement of the packages under test, derived from their own Requires and Depends. Scenarios in both formats: rules-only upgrade refused naming openwatch-kensa-engine; openwatch-only, coordinated and fresh install accepted; downgrade to the fixture refused; version ordering. Mutations, each red then restored with the hash checked: kensa-rules without the floor (rules-only and downgrade accepted, both formats) and openwatch without the provide (coordinated and fresh refused, both formats). The container test stays, without a criterion of its own, and package-smoke's kensa-rules-compat job runs it by its new name. AC-09's text now says how it is verified. --- .github/workflows/package-smoke.yml | 5 +- packaging/tests/engine_pairing_test.go | 324 +++++++++++++++++++++++++ packaging/tests/upgrade_test.go | 17 +- specs/release/upgrade.spec.yaml | 20 +- 4 files changed, 348 insertions(+), 18 deletions(-) create mode 100644 packaging/tests/engine_pairing_test.go diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml index edab5d3e6..9ebd66b4f 100644 --- a/.github/workflows/package-smoke.yml +++ b/.github/workflows/package-smoke.yml @@ -213,7 +213,8 @@ jobs: "${{ matrix.distro }}" "${{ matrix.kind }}" v0.6.0 # The package manager enforces the engine/corpus pairing (spec - # release-upgrade C-06, AC-09). A Kensa engine older than its corpus + # release-upgrade C-06). AC-09 is verified in Go CI against the resolvers; + # this runs the same scenarios in real containers, scriptlets included. A Kensa engine older than its corpus # cannot load it and the service starts anyway with every scan failing, so # a rules-only upgrade beside an older openwatch must be refused while the # coordinated upgrade, an openwatch-only upgrade and a fresh install @@ -257,7 +258,7 @@ jobs: OPENWATCH_KENSA_COMPAT_KIND: ${{ matrix.kind }} run: | OPENWATCH_KENSA_COMPAT_OLD_DIR="$PWD/old" OPENWATCH_KENSA_COMPAT_NEW_DIR="$PWD/new" \ - go test -count=1 -v -run 'TestUpgrade_PackageManagerEnforcesEngineCorpusPairing' ./packaging/tests/ + go test -count=1 -v -run 'TestUpgrade_EngineCorpusPairingInContainers' ./packaging/tests/ upgrade: name: Package upgrade (rpm -U auto-migrate) diff --git a/packaging/tests/engine_pairing_test.go b/packaging/tests/engine_pairing_test.go new file mode 100644 index 000000000..e710d9088 --- /dev/null +++ b/packaging/tests/engine_pairing_test.go @@ -0,0 +1,324 @@ +// @spec release-upgrade +// +// AC traceability (this file): +// +// AC-09 TestUpgrade_PackageManagerEnforcesEngineCorpusPairing +// +// The pairing is enforced by the package managers' own resolvers, so this +// asks them, with no root, no container and no network: `rpm --test` against +// a temporary rpmdb, and `apt-get -s` against a fixture dpkg status file. +// The packages under test are the real ones this tree builds. The older +// openwatch is a fixture package that provides no engine, which is what every +// release before C-06 is. The same scenarios also run in real containers in +// package-smoke (kensa-rules-compat), which is where fresh installs run their +// scriptlets. + +package packaging_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// pairingOldOW is the fixture openwatch that provides no engine: v0.7.1, the +// previous GA. It must sort below the tree's own build, which carries +// packaging/version.env's version (an rc of the next release), or the +// resolver sees the same version already installed and tests nothing. +const pairingOldOW = "1:0.7.1" + +// run executes a command and returns its combined output and whether it +// exited zero. The exit status is read directly, never through a pipe. +func run(t *testing.T, name string, args ...string) (string, bool) { + t.Helper() + out, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + if _, ok := err.(*exec.ExitError); !ok { + t.Fatalf("%s: %v", name, err) + } + return string(out), false + } + return string(out), true +} + +// ---- RPM ------------------------------------------------------------------- + +type rpmFixture struct { + name, epoch, version string + provides, files []string +} + +// buildFixtureRPM builds a payload-free package for the given arch. +func buildFixtureRPM(t *testing.T, dir, arch string, f rpmFixture) string { + t.Helper() + top := filepath.Join(dir, "rpmtop-"+f.name) + var spec strings.Builder + fmt.Fprintf(&spec, "Name: %s\n", f.name) + if f.epoch != "" { + fmt.Fprintf(&spec, "Epoch: %s\n", f.epoch) + } + fmt.Fprintf(&spec, "Version: %s\nRelease: 1\nSummary: fixture\nLicense: none\n", f.version) + fmt.Fprintf(&spec, "AutoReqProv: no\n") + for _, p := range f.provides { + fmt.Fprintf(&spec, "Provides: %s\n", p) + } + spec.WriteString("%description\nfixture\n%install\n") + for _, file := range f.files { + fmt.Fprintf(&spec, "mkdir -p %%{buildroot}%s && touch %%{buildroot}%s\n", filepath.Dir(file), file) + } + spec.WriteString("%files\n") + for _, file := range f.files { + spec.WriteString(file + "\n") + } + specPath := filepath.Join(dir, f.name+".spec") + if err := os.WriteFile(specPath, []byte(spec.String()), 0o644); err != nil { + t.Fatal(err) + } + if out, ok := run(t, "rpmbuild", "--define", "_topdir "+top, "--target", arch, "-bb", specPath); !ok { + t.Fatalf("rpmbuild %s:\n%s", f.name, out) + } + matches, _ := filepath.Glob(filepath.Join(top, "RPMS", "*", f.name+"-*.rpm")) + if len(matches) != 1 { + t.Fatalf("fixture %s: built %d packages", f.name, len(matches)) + } + return matches[0] +} + +// rpmBaseFixture provides every requirement of pkgs that neither of the two +// packages under test supplies: the host's base system, as far as the +// resolver needs to know. +func rpmBaseFixture(t *testing.T, pkgs ...string) rpmFixture { + t.Helper() + base := rpmFixture{name: "ow-fixture-base", version: "1"} + seen := map[string]bool{} + for _, p := range pkgs { + reqs := rpmQuery(t, p, "[%{REQUIRENAME}|%{REQUIREFLAGS:depflags}|%{REQUIREVERSION}\n]") + for _, line := range strings.Split(strings.TrimSpace(reqs), "\n") { + parts := strings.SplitN(line, "|", 3) + if len(parts) != 3 || seen[parts[0]] { + continue + } + name, ver := parts[0], strings.TrimSpace(parts[2]) + seen[name] = true + switch { + case strings.HasPrefix(name, "rpmlib("), name == "openwatch", name == "kensa-rules", + name == "openwatch-kensa-engine", strings.HasPrefix(name, "config(openwatch)"): + continue + case strings.HasPrefix(name, "/"): + base.files = append(base.files, name) + case ver != "": + base.provides = append(base.provides, name+" = "+ver) + default: + base.provides = append(base.provides, name) + } + } + } + return base +} + +// rpmDB is a scratch rpm database holding the given packages as installed. +func rpmDB(t *testing.T, dir, label string, installed ...string) string { + t.Helper() + db := filepath.Join(dir, "rpmdb-"+label) + if out, ok := run(t, "rpm", "--dbpath", db, "--initdb"); !ok { + t.Fatalf("rpm --initdb:\n%s", out) + } + args := append([]string{"--dbpath", db, "-i", "--justdb", "--nodeps", "--noscripts", "--notriggers", "--ignoresize"}, installed...) + if out, ok := run(t, "rpm", args...); !ok { + t.Fatalf("seed %s:\n%s", label, out) + } + return db +} + +func rpmTest(t *testing.T, db string, args ...string) (string, bool) { + t.Helper() + return run(t, "rpm", append([]string{"--dbpath", db, "--test", "--ignoresize"}, args...)...) +} + +// ---- DEB ------------------------------------------------------------------- + +type statusEntry struct{ pkg, version, arch, provides, depends string } + +func (e statusEntry) String() string { + var b strings.Builder + fmt.Fprintf(&b, "Package: %s\nStatus: install ok installed\nPriority: optional\nSection: admin\n", e.pkg) + fmt.Fprintf(&b, "Maintainer: fixture\nArchitecture: %s\nVersion: %s\n", e.arch, e.version) + if e.provides != "" { + fmt.Fprintf(&b, "Provides: %s\n", e.provides) + } + if e.depends != "" { + fmt.Fprintf(&b, "Depends: %s\n", e.depends) + } + b.WriteString("Description: fixture\n\n") + return b.String() +} + +// debBase lists every Depends entry of debs that the packages under test do +// not supply, installed at the version the constraint names. +func debBase(t *testing.T, arch string, debs ...string) []statusEntry { + t.Helper() + var out []statusEntry + seen := map[string]bool{} + for _, d := range debs { + for _, dep := range strings.Split(debField(t, d, "Depends"), ",") { + alt := strings.TrimSpace(strings.Split(dep, "|")[0]) + if alt == "" { + continue + } + name, ver := alt, "1" + if i := strings.Index(alt, "("); i > 0 { + name = strings.TrimSpace(alt[:i]) + f := strings.Fields(strings.Trim(alt[i:], "()")) + if len(f) == 2 { + ver = f[1] + } + } + if seen[name] || name == "kensa-rules" || name == "openwatch" || name == "openwatch-kensa-engine" { + continue + } + seen[name] = true + out = append(out, statusEntry{pkg: name, version: ver, arch: arch}) + } + } + return out +} + +// aptSimulate runs `apt-get -s install` against a private apt tree whose dpkg +// status holds exactly the given entries. +func aptSimulate(t *testing.T, dir, label, arch string, installed []statusEntry, debs ...string) (string, bool) { + t.Helper() + root := filepath.Join(dir, "apt-"+label) + for _, d := range []string{"state/lists/partial", "cache/archives/partial", "etc/apt.conf.d", "etc/preferences.d", "etc/sources.list.d"} { + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { + t.Fatal(err) + } + } + var status strings.Builder + for _, e := range installed { + status.WriteString(e.String()) + } + if err := os.WriteFile(filepath.Join(root, "state", "status"), []byte(status.String()), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "sources.list"), nil, 0o644); err != nil { + t.Fatal(err) + } + args := []string{ + "-o", "Dir::State=" + filepath.Join(root, "state"), + "-o", "Dir::State::status=" + filepath.Join(root, "state", "status"), + "-o", "Dir::Cache=" + filepath.Join(root, "cache"), + "-o", "Dir::Etc=" + filepath.Join(root, "etc"), + "-o", "Dir::Etc::sourcelist=" + filepath.Join(root, "etc", "sources.list"), + "-o", "Dir::Etc::sourceparts=" + filepath.Join(root, "etc", "sources.list.d"), + "-o", "Dir::Etc::parts=" + filepath.Join(root, "etc", "apt.conf.d"), + "-o", "Dir::Etc::preferencesparts=" + filepath.Join(root, "etc", "preferences.d"), + "-o", "Debug::NoLocking=1", + "-o", "APT::Architecture=" + arch, + "-s", "install", "--allow-downgrades", + } + for _, d := range debs { + abs, err := filepath.Abs(d) + if err != nil { + t.Fatal(err) + } + args = append(args, abs) + } + return run(t, "apt-get", args...) +} + +// buildFixtureDeb builds a payload-free openwatch that provides no engine. +func buildFixtureDeb(t *testing.T, dir, arch, version string) string { + t.Helper() + root := filepath.Join(dir, "deb-openwatch-old") + if err := os.MkdirAll(filepath.Join(root, "DEBIAN"), 0o755); err != nil { + t.Fatal(err) + } + control := fmt.Sprintf("Package: openwatch\nVersion: %s\nArchitecture: %s\nMaintainer: fixture\nDescription: fixture\n", version, arch) + if err := os.WriteFile(filepath.Join(root, "DEBIAN", "control"), []byte(control), 0o644); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "openwatch-old-fixture.deb") + if o, ok := run(t, "dpkg-deb", "--root-owner-group", "--build", root, out); !ok { + t.Fatalf("dpkg-deb:\n%s", o) + } + return out +} + +// @ac AC-09 +// AC-09: the package managers refuse a corpus beside an older engine and +// accept every pairing that works, in both formats. +func TestUpgrade_PackageManagerEnforcesEngineCorpusPairing(t *testing.T) { + t.Run("release-upgrade/AC-09", func(t *testing.T) { + haveTool(t, "rpm") + haveTool(t, "apt-get") + v := linkedKensaVersion(t) + dir := t.TempDir() + + t.Run("rpm", func(t *testing.T) { + newOW, newKR := rpmPath(t), kensaRulesRPMPath(t) + arch := rpmQuery(t, newOW, "%{ARCH}") + oldOW := buildFixtureRPM(t, dir, arch, rpmFixture{name: "openwatch", epoch: "1", version: "0.7.1"}) + oldKR := buildFixtureRPM(t, dir, "noarch", rpmFixture{name: "kensa-rules", version: "0.9.0"}) + base := buildFixtureRPM(t, dir, arch, rpmBaseFixture(t, newOW, newKR)) + + older := rpmDB(t, dir, "older", base, oldOW, oldKR) + if out, ok := rpmTest(t, older, "-U", newKR); ok || !strings.Contains(out, "openwatch-kensa-engine >= "+v) { + t.Errorf("rules-only upgrade beside openwatch %s: accepted=%v, want refused naming the engine:\n%s", pairingOldOW, ok, out) + } + if out, ok := rpmTest(t, older, "-U", newOW); !ok { + t.Errorf("openwatch-only upgrade refused:\n%s", out) + } + if out, ok := rpmTest(t, older, "-U", newOW, newKR); !ok { + t.Errorf("coordinated upgrade refused:\n%s", out) + } + if out, ok := rpmTest(t, rpmDB(t, dir, "fresh", base), "-i", newOW, newKR); !ok { + t.Errorf("fresh install refused:\n%s", out) + } + if out, ok := rpmTest(t, rpmDB(t, dir, "current", base, newOW, newKR), "-U", "--oldpackage", oldOW); ok { + t.Errorf("openwatch downgraded to %s beside kensa-rules %s:\n%s", pairingOldOW, v, out) + } + for _, p := range [][2]string{{"1:0.8.0~rc.5", "1:0.8.0~rc.6"}, {"1:0.8.0~rc.6", "1:0.8.0~rc.10"}, {"1:0.8.0~rc.10", "1:0.8.0"}, {"0.9.0", "0.10.0"}} { + out, _ := run(t, "rpm", "--eval", fmt.Sprintf("%%{lua: print(rpm.vercmp('%s', '%s'))}", p[0], p[1])) + if strings.TrimSpace(out) != "-1" { + t.Errorf("rpm orders %s against %s as %q, want -1", p[0], p[1], strings.TrimSpace(out)) + } + } + }) + + t.Run("deb", func(t *testing.T) { + newOW, newKR := debPath(t), kensaRulesDebPath(t) + arch := debField(t, newOW, "Architecture") + base := debBase(t, arch, newOW, newKR) + oldOW := statusEntry{pkg: "openwatch", version: pairingOldOW, arch: arch} + oldKR := statusEntry{pkg: "kensa-rules", version: "0.9.0", arch: "all"} + older := append(append([]statusEntry(nil), base...), oldOW, oldKR) + + if out, ok := aptSimulate(t, dir, "rules-only", arch, older, newKR); ok || !strings.Contains(out, "openwatch-kensa-engine") { + t.Errorf("rules-only upgrade beside openwatch %s: accepted=%v, want refused naming the engine:\n%s", pairingOldOW, ok, out) + } + if out, ok := aptSimulate(t, dir, "ow-only", arch, older, newOW); !ok { + t.Errorf("openwatch-only upgrade refused:\n%s", out) + } + if out, ok := aptSimulate(t, dir, "coordinated", arch, older, newOW, newKR); !ok { + t.Errorf("coordinated upgrade refused:\n%s", out) + } + if out, ok := aptSimulate(t, dir, "fresh", arch, base, newOW, newKR); !ok { + t.Errorf("fresh install refused:\n%s", out) + } + current := append(append([]statusEntry(nil), base...), + statusEntry{pkg: "openwatch", version: debField(t, newOW, "Version"), arch: arch, provides: debField(t, newOW, "Provides")}, + statusEntry{pkg: "kensa-rules", version: v, arch: "all", depends: debField(t, newKR, "Depends")}) + if out, ok := aptSimulate(t, dir, "downgrade", arch, current, buildFixtureDeb(t, dir, arch, pairingOldOW)); ok && !strings.Contains(out, "Remv kensa-rules") { + t.Errorf("openwatch downgraded to %s beside kensa-rules %s:\n%s", pairingOldOW, v, out) + } + for _, p := range [][2]string{{"1:0.8.0~rc.5", "1:0.8.0~rc.6"}, {"1:0.8.0~rc.6", "1:0.8.0~rc.10"}, {"1:0.8.0~rc.10", "1:0.8.0"}, {"0.9.0", "0.10.0"}} { + if _, ok := run(t, "dpkg", "--compare-versions", p[0], "lt", p[1]); !ok { + t.Errorf("dpkg does not order %s before %s", p[0], p[1]) + } + } + }) + }) +} diff --git a/packaging/tests/upgrade_test.go b/packaging/tests/upgrade_test.go index 47357cdc9..d422ed142 100644 --- a/packaging/tests/upgrade_test.go +++ b/packaging/tests/upgrade_test.go @@ -8,7 +8,7 @@ // AC-06 TestUpgrade_CleanupTimerShippedAndKeepsNewest // AC-07 TestUpgrade_PayloadShipsUpgradeFiles // AC-08 TestUpgrade_PackagesDeclareEngineCorpusPairing -// AC-09 TestUpgrade_PackageManagerEnforcesEngineCorpusPairing +// AC-09 in engine_pairing_test.go package packaging_test @@ -200,13 +200,14 @@ func TestUpgrade_PackagesDeclareEngineCorpusPairing(t *testing.T) { }) } -// @ac AC-09 -// AC-09: the package manager enforces the pairing. The container scenarios -// live in kensa-rules-compat-container-test.sh; this runs them for one -// image. package-smoke sets the four variables (previous GA as the old -// release, the candidate's packages as the new); elsewhere it skips. -func TestUpgrade_PackageManagerEnforcesEngineCorpusPairing(t *testing.T) { - t.Run("release-upgrade/AC-09", func(t *testing.T) { +// TestUpgrade_EngineCorpusPairingInContainers runs the AC-09 scenarios in +// real containers, where installs run their scriptlets. It carries no +// criterion of its own: AC-09 is verified by the resolver test in +// engine_pairing_test.go, which runs in Go CI. package-smoke's +// kensa-rules-compat job sets the four variables (previous GA as the old +// release, the candidate's packages as the new); elsewhere this skips. +func TestUpgrade_EngineCorpusPairingInContainers(t *testing.T) { + t.Run("containers", func(t *testing.T) { image := os.Getenv("OPENWATCH_KENSA_COMPAT_IMAGE") kind := os.Getenv("OPENWATCH_KENSA_COMPAT_KIND") oldDir := os.Getenv("OPENWATCH_KENSA_COMPAT_OLD_DIR") diff --git a/specs/release/upgrade.spec.yaml b/specs/release/upgrade.spec.yaml index 96f45a5a4..20105a65d 100644 --- a/specs/release/upgrade.spec.yaml +++ b/specs/release/upgrade.spec.yaml @@ -121,13 +121,17 @@ spec: references_constraints: [C-06] - id: AC-09 description: > - v1.1.0 - in a container, with a release that predates the engine - provide installed, the package manager refuses kensa-rules alone and - leaves the installed corpus unchanged (dnf, rpm -U and apt); an - openwatch-only upgrade succeeds; the coordinated upgrade of both - packages succeeds; a fresh install of the new pair succeeds; the new - corpus refuses a downgrade of openwatch to that release; and versions - order 1:0.8.0~rc.5 < 1:0.8.0~rc.6 < 1:0.8.0~rc.10 < 1:0.8.0 and 0.9.0 < - 0.10.0 in both formats. Runs on RPM and DEB. + v1.1.0 - the package managers' own resolvers refuse a corpus beside an + older engine and accept every pairing that works. With an openwatch + that provides no engine installed (a fixture at v0.7.1, the previous + GA), they refuse kensa-rules alone and name openwatch-kensa-engine; they + accept an openwatch-only upgrade, the coordinated upgrade of both + packages, and a fresh install of the new pair; and with the new pair + installed they refuse a downgrade of openwatch to that release. + Versions order 1:0.8.0~rc.5 < 1:0.8.0~rc.6 < 1:0.8.0~rc.10 < 1:0.8.0 and + 0.9.0 < 0.10.0. Verified for RPM with rpm --test against a scratch + rpmdb and for DEB with apt-get -s against a fixture dpkg status, using + the packages this tree builds; package-smoke also runs the same + scenarios in real containers against the published v0.7.1 packages. priority: critical references_constraints: [C-06] From d9d2efd7bb3fbb26caa1ac74f8d626a3c142e1ae Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Sat, 26 Sep 2026 17:25:08 -0400 Subject: [PATCH 05/10] feat(frameworks): name every framework with Kensa's label (D-2 S-7) Founder decision 2026-09-26: S-7 is in v0.8. OpenWatch held three label sources that disagreed (internal/framework's map, the Compliance tab's id transform, and the report's first-token collapse), and the rule library and scan detail grouped every nist* key as one "NIST" tone. So an 800-53 report and an 800-171 report carried the same "NIST" on their cover, OSCAL title and file name. One source now: internal/kensa.FrameworkLabel wraps pkg/kensa.FrameworkFromID. An id Kensa does not know is returned as it is; OpenWatch derives no label. - framework.Label (family labels) and the report scope label use it. - The API carries it: label on GET /hosts/{id}/compliance/frameworks and GET /reports/frameworks, framework_labels on GET /rules and GET /scans/{id}. Additive fields; generated code refreshed. - The UI renders them: lens chips and panels, the report picker, and the rule library and scan detail tags (title and accessible name). The rule library filter offers one option per framework, by label. Signed artifacts: the attestation content still carries the exact key. The scope label is computed at generation and stored, and every face reads the stored value, so a report generated before this change keeps its label, its JSON face still hashes to content_sha256 and its signature verifies (api-reports AC-27). Contracts: system-compliance-lens 1.7.0 C-08, AC-02 amended, AC-14; api-reports 1.19.0 AC-07 and AC-09 amended, AC-27; frontend-host-compliance-tab 1.7.0 AC-12 rewritten (unpublished, so in place); frontend-reports 1.14.0 AC-09; frontend-rules-library 1.1.0 AC-05; frontend-scan-detail 1.2.0 AC-09. Mutations, each red then restored with the hash checked. The first backend round failed to build, because removing the only kensa call left an unused import, so it proved nothing; each was re-run in a form that compiles: - Label upper-casing the id (AC-02); - the scope label's first-token collapse (AC-07, AC-27); - the host label set to the raw id (AC-14); - scan detail labels emptied (AC-14); - the chip rendering framework_id (AC-02); - the rule filter ignoring labels (AC-05); - tags ignoring labels (AC-05). Visible changes: "CIS RHEL 9" reads "CIS (RHEL 9)", "PCI DSS 4" reads "PCI DSS 4.0". Kensa formats only RHEL versions, so Ubuntu benchmarks read "CIS (ubuntu22)"; requested from Kensa as CP features/KN-OW-023 rather than patched locally. --- CHANGELOG.md | 14 +- api/openapi.yaml | 36 ++++- frontend/src/api/schema.d.ts | 14 +- .../src/pages/host-detail/ComplianceTab.tsx | 59 +++----- frontend/src/pages/reports/ReportsPage.tsx | 2 +- frontend/src/pages/scans/RulesTab.tsx | 72 +++++---- frontend/src/pages/scans/ScanDetailPage.tsx | 17 ++- .../pages/host-detail-compliance-tab.test.tsx | 98 +++++++++--- frontend/tests/pages/reports.test.ts | 6 + frontend/tests/pages/rules-tab.test.tsx | 56 +++++++ frontend/tests/pages/scan-detail.test.tsx | 12 ++ internal/framework/framework.go | 25 +-- internal/framework/framework_test.go | 21 ++- internal/kensa/framework_labels.go | 50 ++++++ internal/report/framework_labels_db_test.go | 95 ++++++++++++ internal/report/service.go | 28 ++-- internal/report/service_db_test.go | 16 +- internal/report/service_test.go | 28 ++-- internal/server/api/server.gen.go | 16 +- internal/server/framework_labels_test.go | 143 ++++++++++++++++++ .../server/host_compliance_lens_handler.go | 4 +- internal/server/report_handlers.go | 2 + internal/server/rules_handler.go | 3 + internal/server/scans_handlers.go | 4 + specs/api/reports.spec.yaml | 34 +++-- specs/frontend/host-compliance-tab.spec.yaml | 21 ++- specs/frontend/reports.spec.yaml | 6 +- specs/frontend/rules-library.spec.yaml | 13 +- specs/frontend/scan-detail.spec.yaml | 11 +- specs/system/compliance-lens.spec.yaml | 32 +++- 30 files changed, 745 insertions(+), 193 deletions(-) create mode 100644 internal/kensa/framework_labels.go create mode 100644 internal/report/framework_labels_db_test.go create mode 100644 internal/server/framework_labels_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6104e9a78..8692be51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,8 +63,18 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Kensa 0.10.0.** The rule corpus grows from 769 to 779 rules and gains two framework keys, `nist_800_171` (NIST SP 800-171 Rev 2, cited at objective - level such as `3.1.11[b]`) and `cmmc_l2`. Each is referenced by 324 rules, - and the lens picker labels them "NIST 800-171" and "CMMC Level 2". + level such as `3.1.11[b]`) and `cmmc_l2`. Each is referenced by 324 rules. + + **Framework names now come from Kensa everywhere.** The lens chips, the + rule library and scan detail, the report picker and every new report's + scope label (cover, OSCAL title, file name) use one vocabulary. NIST + 800-53, "NIST SP 800-171 Rev 2" and "CMMC Level 2" are distinct wherever + they appear; reports used to call both NIST frameworks "NIST" and CMMC + "CMMC". Some familiar names change: "CIS RHEL 9" is now "CIS (RHEL 9)" and + "PCI DSS 4" is "PCI DSS 4.0". Ubuntu benchmarks read "CIS (ubuntu22)" until + Kensa formats Ubuntu versions (CP `features/KN-OW-023`). Reports generated before the upgrade keep the + names they were generated with, and signed report content, which carries + the exact framework key, is unchanged. **Upgrade `openwatch` and `kensa-rules` together.** An earlier `openwatch` cannot load the 0.10.0 corpus: the service starts and every scan fails. diff --git a/api/openapi.yaml b/api/openapi.yaml index 75f98df6c..17170cda9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5503,9 +5503,16 @@ components: HostComplianceFramework: type: object - required: [framework_id, rule_count, passing, failing, score_pct, envelope] + required: [framework_id, label, rule_count, passing, failing, score_pct, envelope] properties: framework_id: {type: string} + label: + type: string + description: >- + Display label for framework_id, taken from Kensa's framework + vocabulary ("NIST SP 800-171 Rev 2", "CIS (RHEL 9)"). An id Kensa + does not know is returned as it is. "All rules" on the overall + entry, whose framework_id is "all". rule_count: type: integer format: int64 @@ -6883,12 +6890,17 @@ components: ReportFramework: type: object - required: [framework, rule_count] + required: [framework, label, rule_count] description: A framework lens present in the fleet, with its rule count. properties: framework: type: string - description: The framework_refs key (e.g. cis_rhel9_v2.0.0). + description: The framework_refs key (e.g. cis_rhel9). + label: + type: string + description: >- + Display label for the key, from Kensa's framework vocabulary. An + id Kensa does not know is returned as it is. rule_count: type: integer description: Distinct rules mapped to this framework across the fleet. @@ -7049,12 +7061,19 @@ components: RuleList: type: object - required: [rules, total] + required: [rules, total, framework_labels] properties: rules: type: array items: {$ref: '#/components/schemas/RuleListItem'} total: {type: integer, description: total rules in the library} + framework_labels: + type: object + additionalProperties: {type: string} + description: >- + Display label, from Kensa's framework vocabulary, for every + framework id used as a framework_refs key in this response. An id + Kensa does not know maps to itself. ScanRuleResult: type: object @@ -7082,12 +7101,19 @@ components: ScanDetail: type: object - required: [scan, results] + required: [scan, results, framework_labels] properties: scan: {$ref: '#/components/schemas/ScanSummary'} results: type: array items: {$ref: '#/components/schemas/ScanRuleResult'} + framework_labels: + type: object + additionalProperties: {type: string} + description: >- + Display label, from Kensa's framework vocabulary, for every + framework id used as a framework_refs key in this response. An id + Kensa does not know maps to itself. ScanCheckEvidence: type: object diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index f5c1359e1..30a6466f3 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -3374,6 +3374,8 @@ export interface components { }; HostComplianceFramework: { framework_id: string; + /** @description Display label for framework_id, taken from Kensa's framework vocabulary ("NIST SP 800-171 Rev 2", "CIS (RHEL 9)"). An id Kensa does not know is returned as it is. "All rules" on the overall entry, whose framework_id is "all". */ + label: string; /** * Format: int64 * @description Number of host_rule_state rows mapped to this framework. @@ -4335,8 +4337,10 @@ export interface components { }; /** @description A framework lens present in the fleet, with its rule count. */ ReportFramework: { - /** @description The framework_refs key (e.g. cis_rhel9_v2.0.0). */ + /** @description The framework_refs key (e.g. cis_rhel9). */ framework: string; + /** @description Display label for the key, from Kensa's framework vocabulary. An id Kensa does not know is returned as it is. */ + label: string; /** @description Distinct rules mapped to this framework across the fleet. */ rule_count: number; }; @@ -4475,6 +4479,10 @@ export interface components { rules: components["schemas"]["RuleListItem"][]; /** @description total rules in the library */ total: number; + /** @description Display label, from Kensa's framework vocabulary, for every framework id used as a framework_refs key in this response. An id Kensa does not know maps to itself. */ + framework_labels: { + [key: string]: string; + }; }; /** @description One rule's durable verdict for a scan. No inline check output. */ ScanRuleResult: { @@ -4500,6 +4508,10 @@ export interface components { ScanDetail: { scan: components["schemas"]["ScanSummary"]; results: components["schemas"]["ScanRuleResult"][]; + /** @description Display label, from Kensa's framework vocabulary, for every framework id used as a framework_refs key in this response. An id Kensa does not know maps to itself. */ + framework_labels: { + [key: string]: string; + }; }; /** @description One command's reproducible evidence (mirrors kensa CheckEvidence). */ ScanCheckEvidence: { diff --git a/frontend/src/pages/host-detail/ComplianceTab.tsx b/frontend/src/pages/host-detail/ComplianceTab.tsx index df5c479a0..75e6ecac1 100644 --- a/frontend/src/pages/host-detail/ComplianceTab.tsx +++ b/frontend/src/pages/host-detail/ComplianceTab.tsx @@ -122,6 +122,9 @@ export function ComplianceTab({ }, enabled: !!hostId, }); + const frameworkName = framework + ? frameworkLabelFor(frameworksQuery.data?.frameworks, framework) + : undefined; // CLIENT-SIDE status filter + search — clicking or typing never // refetches. Spec C-03 / AC-04. @@ -208,10 +211,10 @@ export function ComplianceTab({ }} > - + @@ -447,29 +450,15 @@ function RescanButton({ // stays the single source of truth (api-hosts AC-08). // ───────────────────────────────────────────────────────────────────────── -// Keys the generic transform below would misspell, named as the backend's -// internal/framework labels name them. -const NAMED_FRAMEWORK_LABELS: Record = { - nist_800_53: 'NIST 800-53', - nist_800_171: 'NIST 800-171', - cmmc_l2: 'CMMC Level 2', -}; - -// frameworkLabel renders a friendly chip label from a framework id: -// cis_rhel8 -> "CIS RHEL 8", nist_800_53 -> "NIST 800-53", -// stig_rhel9 -> "STIG RHEL 9", pci_dss_4 -> "PCI DSS 4". -export function frameworkLabel(id: string): string { - const named = NAMED_FRAMEWORK_LABELS[id]; - if (named) return named; - return id - .split('_') - .map((part) => { - const m = /^([a-z]+)(\d+)$/.exec(part); - if (m) return `${m[1]!.toUpperCase()} ${m[2]!}`; - if (/^\d+$/.test(part)) return part; - return part.toUpperCase(); - }) - .join(' '); +// frameworkLabelFor returns the label the API sent for a framework id. The +// labels are Kensa's, supplied by the server (spec system-compliance-lens +// C-08), so every page names a framework the same way. The UI derives none: +// an id the API did not label is shown as it is. +export function frameworkLabelFor( + frameworks: { framework_id: string; label: string }[] | undefined, + id: string, +): string { + return frameworks?.find((f) => f.framework_id === id)?.label ?? id; } function LensBar({ @@ -510,7 +499,7 @@ function LensBar({ active={framework === opt.framework_id} onClick={() => onFrameworkChange(opt.framework_id)} > - {frameworkLabel(opt.framework_id)} + {opt.label} {opt.rule_count} rules {chipScore(opt.score_pct)} @@ -738,10 +727,10 @@ function ScorePanel({ summary }: { summary: LensResponse['summary'] }) { function ResultMixPanel({ summary, - framework, + frameworkName, }: { summary: LensResponse['summary']; - framework?: string; + frameworkName?: string; }) { const max = Math.max(1, summary.passing, summary.failing); const rows: { label: string; value: number; color: string }[] = [ @@ -750,7 +739,7 @@ function ResultMixPanel({ ]; return (
-

Result mix{framework ? ` · ${frameworkLabel(framework)}` : ''}

+

Result mix{frameworkName ? ` · ${frameworkName}` : ''}

{rows.map((row) => (

Scan

- - {framework ? frameworkLabel(framework) : 'All rules (no lens)'} - + {frameworkName ?? 'All rules (no lens)'} {ran} {scanContext.duration_seconds != null ? ( diff --git a/frontend/src/pages/reports/ReportsPage.tsx b/frontend/src/pages/reports/ReportsPage.tsx index 318295de7..6dd4aa989 100644 --- a/frontend/src/pages/reports/ReportsPage.tsx +++ b/frontend/src/pages/reports/ReportsPage.tsx @@ -480,7 +480,7 @@ export function ReportsPage() { {frameworks.map((f) => ( ))} diff --git a/frontend/src/pages/scans/RulesTab.tsx b/frontend/src/pages/scans/RulesTab.tsx index ec5eb3562..172ef52a8 100644 --- a/frontend/src/pages/scans/RulesTab.tsx +++ b/frontend/src/pages/scans/RulesTab.tsx @@ -38,22 +38,27 @@ function fwTag(frameworkId: string, control: string): { label: string; tone: Ton if (fam === 'pci') return { label: `PCI-${control}`, tone: 'pci' }; return { label: control, tone: fam }; } -function flattenRefs(refs: Record): { label: string; tone: Tone; key: string }[] { +// Each tag also carries the framework's name: the label the API sent +// (Kensa's, spec system-compliance-lens C-08), or the raw id when none came. +// The tone is presentation only; the name is what tells NIST 800-53 from +// NIST SP 800-171 and CMMC Level 2. +function flattenRefs( + refs: Record, + labels: Record, +): { label: string; tone: Tone; key: string; framework: string }[] { const order = (id: string) => id.startsWith('cis') ? 0 : id.startsWith('stig') ? 1 : id.startsWith('nist') ? 2 : 3; return Object.keys(refs) .sort((a, b) => order(a) - order(b) || a.localeCompare(b)) - .flatMap((fid) => (refs[fid] ?? []).map((c) => ({ ...fwTag(fid, c), key: `${fid}:${c}` }))); + .flatMap((fid) => + (refs[fid] ?? []).map((c) => ({ + ...fwTag(fid, c), + key: `${fid}:${c}`, + framework: labels[fid] ?? fid, + })), + ); } -const FAMILY_LABEL: Record = { - cis: 'CIS', - stig: 'STIG', - nist: 'NIST', - pci: 'PCI-DSS', - other: 'Other', -}; - // RulesTab — the Kensa rule-library browser on /scans. Reference data // (GET /api/v1/rules), filtered entirely client-side: search, severity, // category, and framework family. Export downloads the filtered set as CSV. @@ -63,7 +68,7 @@ export function RulesTab() { const [search, setSearch] = useState(''); const [sev, setSev] = useState<'all' | 'critical' | 'high' | 'medium' | 'low'>('all'); const [category, setCategory] = useState('all'); - const [family, setFamily] = useState<'all' | Tone>('all'); + const [framework, setFramework] = useState('all'); const q = useQuery({ queryKey: ['rules'], @@ -76,17 +81,19 @@ export function RulesTab() { }); const rules: Rule[] = useMemo(() => q.data?.rules ?? [], [q.data]); + const labels: Record = useMemo(() => q.data?.framework_labels ?? {}, [q.data]); const categories = useMemo( () => Array.from(new Set(rules.map((r) => r.category).filter(Boolean))).sort(), [rules], ); - const families = useMemo(() => { - const fams = new Set(); - for (const r of rules) - for (const fid of Object.keys(r.framework_refs ?? {})) fams.add(fwFamily(fid)); - return (['cis', 'stig', 'nist', 'pci', 'other'] as Tone[]).filter((f) => fams.has(f)); - }, [rules]); + // One option per framework id the library references, named with its label + // and sorted by that name. + const frameworks = useMemo(() => { + const ids = new Set(); + for (const r of rules) for (const fid of Object.keys(r.framework_refs ?? {})) ids.add(fid); + return Array.from(ids).sort((a, b) => (labels[a] ?? a).localeCompare(labels[b] ?? b)); + }, [rules, labels]); const sevsPresent = useMemo(() => { const s = new Set(rules.map((r) => r.severity)); return (['critical', 'high', 'medium', 'low'] as const).filter((x) => s.has(x)); @@ -97,10 +104,7 @@ export function RulesTab() { return rules.filter((r) => { if (sev !== 'all' && r.severity !== sev) return false; if (category !== 'all' && r.category !== category) return false; - if ( - family !== 'all' && - !Object.keys(r.framework_refs ?? {}).some((fid) => fwFamily(fid) === family) - ) + if (framework !== 'all' && !Object.keys(r.framework_refs ?? {}).includes(framework)) return false; if (!term) return true; const hay = [r.id, r.title, r.description, ...Object.values(r.framework_refs ?? {}).flat()] @@ -108,7 +112,7 @@ export function RulesTab() { .toLowerCase(); return hay.includes(term); }); - }, [rules, search, sev, category, family]); + }, [rules, search, sev, category, framework]); if (q.isPending) return ( @@ -174,11 +178,11 @@ export function RulesTab() { options={categories} />