diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml index 0beee5800..9ebd66b4f 100644 --- a/.github/workflows/package-smoke.yml +++ b/.github/workflows/package-smoke.yml @@ -212,6 +212,54 @@ 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 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 + # 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_EngineCorpusPairingInContainers' ./packaging/tests/ + upgrade: name: Package upgrade (rpm -U auto-migrate) runs-on: ubuntu-latest diff --git a/.secrets.baseline b/.secrets.baseline index 9e31806cd..42f2861ed 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -197,21 +197,21 @@ "filename": "docs/guides/INSTALLATION.md", "hashed_secret": "c99a970222c9f5d73283b8be8021086dd666620b", "is_verified": false, - "line_number": 477 + "line_number": 481 }, { "type": "Basic Auth Credentials", "filename": "docs/guides/INSTALLATION.md", "hashed_secret": "c761ff698fa44c8d7f1a8e30816441866cbf7f76", "is_verified": false, - "line_number": 498 + "line_number": 502 }, { "type": "Basic Auth Credentials", "filename": "docs/guides/INSTALLATION.md", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 853 + "line_number": 857 } ], "docs/guides/PRODUCTION_DEPLOYMENT.md": [ @@ -549,14 +549,14 @@ "filename": "internal/server/api/server.gen.go", "hashed_secret": "9fd0aaae1a3d0bc789d081307161ea9a821f9dee", "is_verified": false, - "line_number": 4596 + "line_number": 4606 }, { "type": "Secret Keyword", "filename": "internal/server/api/server.gen.go", "hashed_secret": "eca525ee60b3564d9633eb140726685271d52341", "is_verified": false, - "line_number": 4734 + "line_number": 4744 } ], "internal/server/api_scans_test.go": [ @@ -809,5 +809,5 @@ } ] }, - "generated_at": "2026-09-26T01:45:11Z" + "generated_at": "2026-09-28T00:38:29Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 839d65bab..9b5ceb1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Upgrade notes +**Upgrade notes.** Read these before upgrading. - **Upgrading signs everyone out.** Migration 0065 revokes every live session and refresh token. Access tokens issued before it carry no session binding @@ -29,6 +29,26 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **API tokens with no owner stop working.** List and replace them before upgrading; the query is under Security below. (#881) +**Known limitations.** These ship in this release. + +- **Changing your own password does not sign out your other sessions.** They + stay valid until their absolute limit, 12 hours by default. To end them, ask + an administrator to reset your password. (CP `bugs/OW-072`) +- **Settings shows only the current session.** It cannot list or revoke other + sessions. +- **A Bearer-only logout revokes nothing.** An access token presented alone + stays valid until it expires, 30 minutes after issue, and a refresh token + returned in the login body has no revoke route. (CP `bugs/OW-062`) +- **The Kensa-published `kensa-rules` package is not checked.** Kensa + publishes a package of the same name and install path that does not + declare the engine it needs, and `openwatch` accepts either package. On + `openwatch` 0.8.0-rc.5 or earlier, a 0.10.0 or newer corpus from any source + makes every scan fail, and no package prevents it. Install `kensa-rules` + only from the OpenWatch release that matches your `openwatch`, and do not + configure a Kensa package repository on an OpenWatch host. The upgrade + runbook shows how to tell the packages apart and restore OpenWatch's. + (CP `bugs/OW-081`) + ### Security - **Disabling, deleting, or resetting the password of a user ends every @@ -61,6 +81,59 @@ 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. + + **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. + `openwatch` now declares the Kensa engine it links, and `kensa-rules` + requires an engine at least as new as itself. With the `kensa-rules` + package from an OpenWatch release, a rules-only upgrade onto an older + `openwatch` is refused with nothing changed, by `dnf`, `rpm -U`, `apt` and + a bare `dpkg -i`; both packages in one transaction are accepted. The + `kensa-rules` package Kensa publishes does not carry this check (see Known + limitations). Rolling `openwatch` back now means rolling `kensa-rules` + back in the same command; the upgrade runbook shows how (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. 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 answers 503 `server.error`, not retryable, and claims neither success nor @@ -104,18 +177,6 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). until it expires and names the remedy that works: an administrator can end it by resetting the user's password. (#880) -### Known limitations - -- **Changing your own password does not sign out your other sessions.** They - stay valid until their absolute limit, 12 hours by default. To end them, ask - an administrator to reset your password. (CP `bugs/OW-072`) -- **Settings shows only the current session.** It cannot list or revoke other - sessions. -- **A Bearer-only logout revokes nothing.** An access token presented alone - stays valid until it expires, 30 minutes after issue, and a refresh token - returned in the login body has no revoke route. (CP `bugs/OW-062`) - - ## [0.8.0-rc.5] Eyrie (2026-09-19) `v0.8.0-rc.4` built, passed every machine gate, and its assets were 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/api/openapi.yaml b/api/openapi.yaml index 60467f514..17170cda9 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 @@ -5502,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 @@ -6360,7 +6368,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 @@ -6882,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. @@ -7048,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 @@ -7081,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/docs/guides/INSTALLATION.md b/docs/guides/INSTALLATION.md index 6e6c661b2..d09851fcc 100644 --- a/docs/guides/INSTALLATION.md +++ b/docs/guides/INSTALLATION.md @@ -439,8 +439,12 @@ Install **both** files in one transaction. `openwatch` declares a hard dependency on `kensa-rules`: the rule corpus the scan engine loads from `/usr/share/kensa/rules`. Installing `openwatch` alone fails the dependency check (by design: a corpus-less node cannot scan). `kensa-rules` is `noarch` -and versioned on the Kensa content line (for example `0.8.0`), independent of the -platform version, so the rules can update without re-releasing OpenWatch. +and versioned on the Kensa module OpenWatch links (for example `0.10.0`), +independent of the platform version. It requires an `openwatch` whose Kensa +engine is at least that version, so a newer corpus arrives with the +`openwatch` release that links it. Install `kensa-rules` from the same +OpenWatch release as `openwatch`; the upgrade runbook explains why a +`kensa-rules` package from a Kensa release is not checked. Use the filenames you downloaded (`aarch64` for the arm64 openwatch RPM; the `kensa-rules` package is the same `noarch` file for every arch). Installing the 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..04c73363a 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,50 @@ 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. +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 | +|---|---|---| +| `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/docs/runbooks/UPGRADE_PROCEDURE.md b/docs/runbooks/UPGRADE_PROCEDURE.md index ea013e849..7c27b9629 100644 --- a/docs/runbooks/UPGRADE_PROCEDURE.md +++ b/docs/runbooks/UPGRADE_PROCEDURE.md @@ -320,14 +320,18 @@ Step 2. Same number, code-only rollback. Higher number, full rollback. If the target version applied no new migrations (the version recorded in Step 2 is unchanged), reinstall the previous package. The previous package's -scriptlet runs too, finds nothing to migrate, and starts the service: +scriptlet runs too, finds nothing to migrate, and starts the service. + +If the upgrade also installed a newer `kensa-rules`, roll it back in the same +command. The package manager refuses an `openwatch` whose engine is older +than the installed corpus, and changes nothing: ```bash sudo systemctl stop openwatch # RHEL family: -sudo dnf install ./openwatch-..rpm +sudo dnf install ./openwatch-..rpm ./kensa-rules-.noarch.rpm # Debian/Ubuntu: -sudo apt install ./openwatch__.deb +sudo apt install --allow-downgrades ./openwatch__.deb ./kensa-rules__all.deb sudo systemctl start openwatch curl -k https://localhost:8443/api/v1/health ``` @@ -365,11 +369,78 @@ 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. -To update the rules, upgrade the package and restart the service so it loads -the new corpus: +A corpus needs an engine at least as new as itself. The `openwatch` package +declares the Kensa engine it contains, and `kensa-rules` requires at least +its own version. This is the boundary tested today: the engine in rc.5 and +earlier cannot load the 0.10.0 corpus, and the service would start with every +scan failing. The 0.10.0 engine loads the 0.9.0 corpus, so upgrading +`openwatch` alone is allowed. + +A rules-only upgrade onto an older `openwatch` is refused, and nothing is +changed: `dnf`, `rpm -U` and `apt` refuse it from the dependency, and a bare +`dpkg -i` is refused by the package's own check before any file is replaced. +Upgrade both packages in one transaction instead. With `dpkg -i`, list +`openwatch` first; if the rules package is listed first it is refused, and +`openwatch` alone is upgraded, which is a working pair. Run the command again +to finish. + +### What this check does not cover + +The check travels with the `kensa-rules` package that OpenWatch builds and +publishes with each release. Kensa also publishes a package named +`kensa-rules`, from its own releases, with the same install path. **Kensa's +package does not carry this check**, and nothing in `openwatch` refuses it: +`openwatch` depends on the name `kensa-rules`, which either package +satisfies. If a host has a Kensa package repository configured, or someone +installs a `kensa-rules` file from a Kensa release, the package manager can +put Kensa's corpus in place of OpenWatch's with no refusal. + +- **On `openwatch` 0.8.0-rc.5 or earlier**, which declares no engine, no + `kensa-rules` package of either origin is checked. A 0.10.0 or newer + corpus from any source makes every scan fail. +- **On this release**, OpenWatch's own `kensa-rules` is checked as described + above. Whether a Kensa-published corpus loads depends on its version and is + not checked. + +This is a stated limit, not a protection. To stay inside what is tested, +install `kensa-rules` only from the OpenWatch release that matches your +`openwatch`, and do not configure a Kensa package repository on an OpenWatch +host. To see which package is installed, look for the engine requirement, +which only OpenWatch's package carries from this release on: + +```bash +rpm -q --requires kensa-rules | grep openwatch-kensa-engine # apt: dpkg -s kensa-rules | grep openwatch-kensa-engine +``` + +No output means the installed package is Kensa's, or OpenWatch's from before +this release. To put OpenWatch's package back, reinstall it from the matching +release (`sudo dnf install ./kensa-rules-.noarch.rpm`; use +`reinstall` in place of `install` when the same version is installed, and +`downgrade` when a higher one is; on Debian, +`sudo apt install --reinstall --allow-downgrades ./kensa-rules__all.deb`), +then restart the service. + +### A corpus newer than the engine + +If a corpus newer than the engine is on disk anyway (installed with +`rpm --nodeps` or `dpkg --force-depends`, or from a Kensa package), every +scan fails. Either install +the matching `openwatch` +(`sudo dpkg -i ./openwatch__.deb && sudo dpkg --configure -a`, +or `sudo dnf install ./openwatch-..rpm`) or put the previous +rules back (`sudo dpkg -i ./kensa-rules__all.deb`, or +`sudo dnf downgrade ./kensa-rules-.noarch.rpm`), then restart the +service. On Debian, plain `apt install` refuses to start from that broken +state; `dpkg -i` followed by `dpkg --configure -a` works. + +A newer corpus arrives with the OpenWatch release that links a matching +engine. To update the rules, install both packages from that release in one +transaction, as in the upgrade steps above, and restart the service so it +loads the new corpus: ```bash -sudo dnf upgrade kensa-rules # apt: sudo apt install --only-upgrade kensa-rules +sudo dnf install ./openwatch-..rpm ./kensa-rules-.noarch.rpm +# apt: sudo apt install ./openwatch__.deb ./kensa-rules__all.deb sudo systemctl restart openwatch rpm -q kensa-rules # apt: dpkg -s kensa-rules | grep Version ``` diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index fe7953ec5..30a6466f3 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"]; /** @@ -3373,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. @@ -3901,7 +3904,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; }[]; }; @@ -4334,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; }; @@ -4474,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: { @@ -4499,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/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/frontend/src/pages/host-detail/ComplianceTab.tsx b/frontend/src/pages/host-detail/ComplianceTab.tsx index 0011e3fa5..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,20 +450,15 @@ function RescanButton({ // stays the single source of truth (api-hosts AC-08). // ───────────────────────────────────────────────────────────────────────── -// 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 { - 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(' ') - .replace(/^NIST 800 53$/, 'NIST 800-53'); +// 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({ @@ -501,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)} @@ -729,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 }[] = [ @@ -741,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} />