From 211350200901d11f18017882be41ad82724892f0 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Mon, 10 Aug 2026 10:11:37 -0400 Subject: [PATCH] chore(release): stamp v0.10.0 Bumps VERSION to 0.10.0 and stamps the accumulated Unreleased section as a dated release. MINOR rather than PATCH because the release is additive across the CLI, the check methods and the corpus. The frozen api/ package changed in comments only, which was checked mechanically rather than by eye: the diff since v0.9.0 has no line that is not a comment, so no signature moved. The per-PR Added/Fixed blocks are consolidated into one block per category, matching how every released section is written. Entry text is copied verbatim. The consolidation was verified lossless by sorting the entry lines on both sides and diffing them. Three defects found while running the gates, each fixed here: - Two merged features shipped with no changelog entry. The four antimalware rules for 800-171 3.14.2, 3.14.4 and 3.14.5, and the measured 800-171 coverage denominator with its derived CMMC Level 2 practice refs. Both are user-visible and would have gone out unannounced. Entries added. - The committed man/kensa.1 had drifted from the binary. It described --config-dir as reading only defaults.yml, which stopped being true when per-host, per-group and conf.d files landed, and it was missing --allow-conflicts entirely. A packaged manual page that documents behavior the binary no longer has is worse than none. Regenerated. - The roundtrip-check and roundtrip-baseline guards never fired. RT_REPORTS joins two wildcards, so with both empty it expands to a single space, and test -n reads a space as non-empty. The guard fell through to a bare python call and its argparse usage error instead of saying to run 'make roundtrip' first. Fixed with strip. README carries the new version. Its Status line no longer says the current version is "released and signed", which is not true of a version until the tag exists; the signing guarantee is stated as a property of every release instead, so the line is accurate before and after the tag. Gates run: go test, golangci-lint, specter sync + coverage-strict (148/148 specs), kensa-validate (779 rules, 0 errors, 1 known W005), catalog-check, comment-lint, docs-check, docs-style, docs-style-sync, mod-tidy-check, manpage-check, proto-check, cli-smoke (225/225), goreleaser check, and a release snapshot producing the documented 11 artifacts. govulncheck reports no called vulnerabilities, and no dependency changed since v0.9.0. Tagging stays with the founder; this commit only prepares the release. --- CHANGELOG.md | 635 +++++++++++++++++++++++++++------------------------ Makefile | 5 +- README.md | 5 +- VERSION | 2 +- man/kensa.1 | 5 +- 5 files changed, 345 insertions(+), 307 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 590a7068..9dd477fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,15 @@ The CLI is governed by GNU/POSIX conventions. Long-form flags are the canonical names; short forms are listed in `cmd/kensa/flags.go`. Compare any two releases at - (swap the tags for + (swap the tags for any pair). ## Unreleased +## v0.10.0 (2026-08-10) + ### Added + - **A rule that measures how long a known security flaw has gone uncorrected.** `flaw-remediation-window` takes the oldest pending security advisory and compares its age against `flaw_remediation_max_days`, which the operator declares. @@ -49,68 +52,12 @@ any pair). operator-declared value had to pick between pass, fail and error, all of which are wrong. It is opt-in: without the param every exit code keeps its meaning. -### Fixed -- **Eight audit and session rules did not run on RHEL 8.** They declared - `rhel >= 9`, so on RHEL 8 they reported "not applicable" and the host was - never checked: `logind-idle-session-timeout`, `auditd-write-logs`, - `auditd-immutable-rules`, and `audit-cmd-init`, `-poweroff`, `-reboot`, - `-shutdown` and `-sudoedit`. - - Each floor matched the earliest release with a published benchmark for that - rule, not anything the check needs. The clearest evidence is the audit-cmd - family itself: 24 of its 29 rules already declared `rhel >= 8`, with the same - check method and the same remediation mechanism as the five that did not. - - Measured on RHEL 8.10 before changing anything. `write_logs` is present in - `auditd.conf` with audit 3.1.2, `/etc/audit/rules.d/audit.rules` and `-e 2` - immutability are supported, `/usr/sbin/init` exists, and `StopIdleSessionSec` - ships as a commented default that logind accepts and applies when set. - - Now `rhel >= 8`. Verified on RHEL, AlmaLinux, Rocky and Oracle Linux 8, all - four with identical results: six rules complete a full round-trip (fail, - remediate, pass, rollback, fail, with `/etc` byte-identical), `auditd-write-logs` - runs and already passes, and `logind-idle-session-timeout` runs and reports a - real verdict with remediation left manual as it was before. - -- **`no-unauthorized-accounts` did not run on RHEL 8 or RHEL 9.** The rule - declared `rhel >= 10`, so on any earlier release it reported "not applicable" - and the host was never checked for local accounts nobody authorized. - - The floor came from the rule's source benchmark, which is published for - RHEL 10, rather than from anything the check needs. The check reads - `/etc/passwd` with awk and compares against the operator's - `authorized_local_accounts` set, which works the same on every supported - release. Its six sibling rules already declared `rhel >= 8` and - `ubuntu >= 22`. - - Now declared `rhel >= 8` and `ubuntu >= 22`, matching the siblings. Verified - on RHEL, AlmaLinux, Rocky and Oracle Linux at 8, 9 and 10: with no set - declared the rule reports that it cannot assess the host, with the host's own - accounts declared it passes, and with a wrong set it fails and names the - account that is present but not authorized. - -- **`security-updates-installed` reported compliant on a host with 384 pending - updates.** The check ran `if dnf check-update; then ... fi` and then read `$?` - on the next line, expecting dnf's exit 100. After an `if` whose condition fails - and which has no `else`, the compound statement itself returns 0, so `$?` was - always 0, the pending-updates branch was unreachable, and the check fell - through to its OK path. - - Measured on a live fleet host: `dnf check-update` exited 100 with 384 packages - pending, and the rule reported PASS. It now reports FAIL and names the count. - - A dnf failure is also a failure now. The old version printed OK when the check - could not run at all, which meant no network or a broken repository read as - patched. - -### Added - **A corpus test for unreachable failure branches.** The existing always-passing test could not see this one: it looks for a check where every exit is 0, and this rule contained a real `exit 1` that simply could not be reached. Reading `$?` immediately after a `fi` is now a build failure, with the fix in the message. -### Added - **A rule that compares who holds privileged access against the set a site declared.** `authorized-privileged-users` reports any account able to act as root that is not in `authorized_privileged_users`. @@ -126,7 +73,6 @@ any pair). correctly configured sudo policy granting root to someone who left last year is both least-privilege and wrong. -### Added - **A rule that compares registered network protocols against the set a site declared.** `authorized-network-protocols` reads what the kernel has registered in `/proc/net/protocols` and reports anything not in @@ -141,23 +87,6 @@ any pair). real evidence and they structurally cannot speak to a protocol nobody thought to blacklist, which is the one worth finding. -### Changed -- **`suid-sgid-files-reviewed` now compares the full inventory against a declared - baseline**, which is what its own output had been asking for. It printed - "review full inventory against baseline" and left that to a human; it now does - it, against `suid_sgid_baseline`. - - The world-writable check it already had is unchanged and still runs first. - Those are two different assertions: one is wrong on any host and needs no site - input, the other cannot be answered without one. - - A package list would be the obvious way to answer "nonessential programs" and - it was measured and rejected: 682 packages on one fleet host, 626 on another, - and the set changes on every patch, so a declared list is stale within a cycle - and produces failures nobody reads. The SUID inventory is about twenty entries - and barely moves, which is what makes a baseline worth keeping accurate. - -### Added - **A rule that compares enabled services against the set a site declared.** `authorized-enabled-services` reports every enabled systemd service that is not in `authorized_services`, and skips with a stated reason until that set is @@ -178,7 +107,6 @@ any pair). drawback, since the unintended ones are invisible until the intended set is written down once. -### Added - **A rule that checks which system accounts can obtain access.** `authorized-service-accounts` reports every account below UID 1000 that holds a real login shell and is not in `authorized_service_accounts`, and skips with a @@ -195,7 +123,6 @@ any pair). almost every case, but an account that legitimately runs a login shell is rare and real, and taking it away can stop the service the host exists for. -### Added - **A rule that compares listening ports against the set a site declared.** `authorized-listening-ports` reports every listening TCP and UDP port that is not in `authorized_listening_ports`, and skips with a stated reason until that @@ -210,88 +137,16 @@ any pair). Remediation is deliberately manual. The same finding can mean an unwanted service or an out-of-date list, and only the operator knows which. -### Fixed -- **Six remediation mechanisms could cut the SSH control channel without arming - the dead man timer.** The timer is what lets a host reverse a change on its own - when the controller never confirms, and the decision came from a static list - that six mechanisms, used by 124 rules, were missing from entirely. A missing - entry read as "cannot cut the channel", so each was silently unprotected. - - `crypto_policy_set` is the measured case. Setting a host to FIPS regenerates - the SSH daemon's accepted key algorithms **without ed25519**, so an operator - using a modern default key is locked out the moment the change commits, by the - change Kensa just made, over the connection Kensa was using. Both crypto - mechanisms are also non-capturable, so there was no pre-state to return to - either. - - `apt_absent` had the same gap as its already-classified counterpart - `package_absent`: removing the SSH server ends the session. `authselect_feature_enable` - and `pam_module_arg` both rewrite authentication stacks, like the - `pam_module_configure` that was already covered. - - Verified as a before and after on a live container: a plan for a crypto policy - rule reported `ControlChannelSensitive: false` and now reports `true`, and the - same for a rule whose only mechanism is `apt_absent`. - -### Added - **A test that fails when a mechanism the corpus uses is not classified.** The defect was not a wrong judgment about any one mechanism, it was a list that had to be remembered and quietly fell behind six times. Forgetting it is now a build failure that names the mechanism and an example rule. -### Fixed -- **A rule skipped for an undeclared variable now appears in the results.** - `kensa check` warned on stderr and then dropped the rule, leaving no trace in - machine output. Anything counting coverage saw the objective quietly leave the - denominator, which is the failure this area exists to prevent. Such a rule is - now reported as `skipped`, carrying its severity and the name of the variable - to declare. - - The rule's identity is recovered by reading its `id:` line rather than by - decoding the document. Once substitution has failed the file still holds - `{{ name }}`, and unquoted that is a YAML mapping rather than text, so the - document may not decode at all. The `id:` is a plain value on its own line and - is never templated. - - `LoadRules` stays strict and keeps failing the whole load, deliberately. Both - halves of its stated contract are now honored, by different means: a caller - that receives rules has no other channel to learn some are missing, so an - error it must handle is the honest way to say so, while the command line now - reports the same fact as a visible result. - -### Changed -- **A check can now report that it cannot reach a verdict, and the scan records - that as skipped.** Previously every path out of a check was pass, fail, or - something broke, because the result carried only a boolean. A check that - establishes it has nothing to judge had to pick one of those, and all three - were wrong. - -- **An empty declared set is now a skip rather than an error.** It still cannot - become a pass. The change is that a site which has not written its policy yet - is a normal state, not a fault, so it no longer puts a red row on every host in - a fleet that has simply not been configured. The skip carries the name of the - variable to declare. - -- **`no-unauthorized-accounts` reaches a real verdict.** It used to list local - accounts, print `MANUAL REVIEW REQUIRED` and exit 0, so it reported **pass on - every host** for something nobody had verified. It now compares the accounts - against `authorized_local_accounts` and fails naming any account not in it. - - Members may be user names, numeric UIDs, or a mix, because a site may - authorize an account either way and both name the same account. - - **On a fleet that has not declared the set, this rule moves from pass to - skipped.** That is a verdict change on shipped STIG and 800-53 surface, in the - honest direction: it stops counting as covered something that was never - checked. - -### Added - **`alias_separator` on `set_compare`**, for when one thing on the host answers to several names. Each observed line becomes one entity, authorized if any of its names was declared. Without it, a host emitting both a name and a UID would report the UID as an unauthorized extra whenever only the name was declared. -### Added - **Variable values are checked against a declared type before a host is contacted.** The type comes from the built-in defaults, so the file that already holds the values is the only place a type is written down and there is @@ -313,7 +168,6 @@ any pair). A variable Kensa ships no default for, such as one a site's own rule introduces, is not type checked. -### Added - **A rule variable can hold a list.** Declare one as a YAML list, or pass it as comma-separated members on the command line: @@ -346,43 +200,11 @@ any pair). member acceptable, and neither is true. A variable no tier declares leaves the rule **skipped** with the name to declare, rather than guessed at. -### Fixed -- **The `--config-dir` help text described a state the code left long ago.** It - said only `defaults.yml` was read. Measured against a live host, - `hosts/.yml` and `conf.d/*.yml` are read as well, and `groups/` - applies in inventory mode, where a host belongs to a group. - -### Fixed -- **Agent mode ignored `--port` and `--key`, and could reach a different host - than the one named.** The agent session built its own SSH invocation carrying - neither, so `-H 127.0.0.1 -P 2231` connected to 127.0.0.1 port 22. That - attempt failed only because authentication happened to fail there; with a - working key it would have applied the remediation to the wrong machine and - recorded it against the right one. - - The session now multiplexes over the connection that already bootstrapped the - agent, so it inherits the port, identity file, host key policy and - authentication method instead of re-deriving them. Settings cannot drift, - because there is no second set to keep in step. It also gains SSH password - authentication, which the agent path could never do. - - If that connection is unavailable, Kensa now **refuses** and names the - workaround rather than falling back to a bare connection that ignores your - settings. - -- **Two connections to one host shared a control socket.** The socket name is - built from user, host, port and process, which two connections to the same - host share, so closing one tore down the other. It was harmless while both - were short-lived, and it stopped being harmless once a long-lived agent - session rode one of them. The connection the agent uses is now private to it. - -### Added - **A transport modes chapter in the guide**, covering agent mode against direct SSH: how the connection is made, what each mode can and cannot do, where connection settings come from, how privileges and capability probes interact with `--sudo`, and which command uses which mode. -### Added - **Identity findings now match how the host actually resolves users.** Two checks run only where identity comes from a directory: the PAM auth stack loading `pam_sss`, and nsswitch routing group lookups to the directory as well @@ -394,84 +216,11 @@ any pair). same as "does identity come from a directory", because certificate and smart card authentication use SSSD with no directory at all. -### Fixed -- **A local-accounts host no longer fails for not running SSSD.** - `sssd-service-enabled-active` had no gate, so it demanded SSSD of every Ubuntu - host. A measured server with local accounts and no SSSD installed reported - FAIL, which reads as a broken host rather than a rule that does not apply. It - now skips there, and it covers RHEL as well. -- **A host configured for certificate login no longer goes silent when its - daemon is dead.** An earlier version of this change gated the service check on - `directory_joined`, which made a certificate-auth host with a stopped SSSD - report `skipped`. That claims the control does not apply, when the truth was - that it did and the daemon was down. A skip removes an objective from the - coverage denominator, so it has to be true, not merely quieter than a wrong - failure. Gating on `sssd_configured` restores the finding: a hardened fleet - host carrying an SSSD certificate configuration with a failed daemon now - reports FAIL, where before this work it was never checked at all. -- **The directory probe no longer reports a never-joined host as joined.** - Testing that `sssd.conf` exists fails in both directions: `/etc/sssd` is mode - 0700, so an unprivileged caller always reads "no". The probe now looks for a - configured `[domain/...]` section, which survives a stopped daemon and is - absent on a host that never joined. - -### Changed -- **CI no longer runs the full suite twice per merge.** The workflow fired on - both the pull request and the push that follows the squash merge. Measured - across the last 100 runs, **39 of 40 push runs re-tested a tree a pull request - run had already tested byte for byte**. Comparing commit hashes hides this, - because squashing mints a new one; comparing trees shows it. - - The push run is now a canary of two jobs rather than a second full suite. It - keeps unit tests and the static-link check, which are the cheapest pair that - would catch the two cases a pull request run genuinely cannot see: two changes - that each pass alone but break main together, and a squash of a branch that was - stale when it merged. - -- **Two checks moved to a daily schedule.** The vulnerability scan reads an - advisory database that changes without us, and the release snapshot only breaks - when packaging config or dependencies change. Asking either one on every pull - request runs them far more often than their answers can change, and it lets an - unrelated upstream advisory turn a docs-only change red. - -- **One in-flight run per branch.** Pushing three times to a pull request used to - run three full suites to completion. Superseded runs are now canceled. Runs on - main are never canceled, because they are the post-merge record. - - Measured effect at the current merge rate: **187 to 75 job-hours per month**, - with the merge gate unchanged. Every one of the nine required checks still runs - on every pull request. - -### Removed -- **The standalone secret-scan job**, which ran `detect-secrets` a second time. - The `detect-secrets` hook in `.pre-commit-config.yaml` is default stage, so the - required Pre-commit hygiene job already runs the same version against the same - baseline with the same excludes. Enforcement is unchanged and still sits on a - required check. - -### Added - **A `workflow_dispatch` trigger on CI.** During the GitHub Actions incident of 2026-08-06, webhook delivery was throttled and no push or pull request event created a workflow run for several hours. There was no way to start CI by hand. Now there is. -### Fixed -- **Two rules reported compliance when the tool they inspect was absent.** - `selinux-user-mapping` guarded on `command -v semanage` and - `firewalld-loopback-source` on `command -v firewall-cmd`, each returning - success when the tool was missing. Both assert a requirement, so absence means - the control is not in place, not that the host is compliant. They are now gated - on the `selinux` and `firewalld` capabilities, which already existed, so such a - host is skipped with a reason. - - Fixing the first one exposed a second false pass underneath it. With the guard - removed, a host where SELinux is enforcing but `semanage` is absent read an - empty login mapping and fell through to the OK path, printing - `OK: __default__ SELinux login is` with no value. An unreadable mapping is an - inability to verify, not evidence of confinement, and it now fails with that - reason. - -### Added - **Two corpus tests for checks that can report compliance without verifying it.** This class has shipped four times: a banner rule passing on a host with no graphical stack, a timeout rule passing a host at double its limit, a @@ -490,28 +239,6 @@ any pair). whether a rule's title states a requirement or a prohibition, and the other needs scan output from real hosts, so neither can be a gate. -### Fixed -- **Four rules gated on a capability that was never probed, so their real check - could never run.** `nftables-default-deny`, `nftables-base-chains`, - `nftables-loopback` and `journald-to-rsyslog` gate on `nftables_active` or - `rsyslog_active`. Neither existed as a probe, so the gate never matched and - every host fell through to a default implementation that answered a different - question than the rule asks. - - `nftables-default-deny` is the clearest case. Its default only checked whether - firewalld was running. On a fleet host it reported **pass** while - `nft list ruleset` showed `policy accept`, a pass for deny-by-default on a host - that does not have it. `journald-to-rsyslog` was worse: its default was an - unconditional `exit 0`, so the rule passed everywhere. With the gate working it - now fails on a host where rsyslog is active and journald forwarding is off, - which is a real finding it had been masking. - - Both capabilities are now probed, and the permissive defaults are removed. With - every implementation capability-gated the engine reports `skipped` where the - rule does not apply, which is the honest verdict, instead of a pass that - answers a different question. - -### Added - **A `directory_joined` capability, so a rule can tell a host whose users are local from one whose identity lives in a directory.** The existing `sssd` capability only reports that the unit file is installed, which a base image @@ -531,29 +258,6 @@ any pair). sssd is actually running. That false positive was caught on a real host after the container tests passed. -### Fixed -- **`rule.KnownCapabilities` is now derived from the probe list instead of being - maintained by hand.** The two had drifted: `ufw`, `apt`, `apparmor`, `dpkg` and - three others were probed while absent from the known set, so a rule gating on - any of them would fail `--cap-check` for a capability the engine does detect. - A new test asserts every probed capability is known, which is what found it. - -### Fixed -- **A declared framework key with no value is now a validation error.** Twelve - rules carried one: eight with a bare `nist_800_53:` and four with a bare - `cis:`. An empty key is the worst of both readings. A human scanning the YAML - sees the rule mapped to that framework; the engine produces no FrameworkRef, so - a coverage query sees it unmapped. Neither reader is warned. - - The twelve keys were removed rather than populated. Filling them in would have - meant inventing citations: the eight sysctl rules have siblings citing `CM-6`, - which is a plausible fit and not evidence, and a framework citation nobody - checks until an assessor does is worse missing than absent. Populating them - properly needs the benchmark text and is separate work. - - `kensa-validate` now rejects the pattern, so it cannot come back. - -### Added - **NIST SP 800-171 is now a framework the corpus carries.** `nist_800_171` and `nist_800_171_r3` are registered in `pkg/kensa/catalog.go`, and 313 rules carry reviewed references at OBJECTIVE granularity, `3.1.11[b]` rather than `3.1.11`, with 463 @@ -581,7 +285,131 @@ any pair). verified crosswalk rather than a mechanical reformat. The published claim stays pinned to Revision 2, which is what 32 CFR 170.2 keys CMMC to. +- **Four antimalware rules**, covering 800-171 3.14.2, 3.14.4 and 3.14.5: + `antimalware-service-running`, `antimalware-signature-updates`, + `antimalware-on-access-scan` and `antimalware-scheduled-scan`. + + The corpus carried no antimalware rules at all. No benchmark backs these. All + ten CIS and STIG benchmarks mention virus, malicious code, antimalware and + clamav zero times. The source is NIST 800-53 SI-3, which each rule cites, and + no CIS or STIG coverage changes. + + No product is hardcoded. Each check reads a candidate unit list from a + variable covering ClamAV and common endpoint agents, which `--var` overrides. + Hardcoding ClamAV would have failed a host running Trellix, CrowdStrike or + Sophos. + + Remediation is manual on all four. Picking and licensing an antimalware + product is an operator decision, on-access scanning taxes every file + operation, and a full scan needs a maintenance window. + +- **`kensa coverage --framework nist_800_171` now reports against a measured + denominator.** Covered assessment objectives are counted against a number the + report shows its work for, rather than against 320 or as a bare percent. + + Most of 800-171 is not host state. Of the 320 Rev 2 objectives, 194 describe + interviews, records, buildings and organizational decisions. No configuration + scanner evidences those, so a percentage against 320 flatters whoever has the + larger numerator. The assessable count is always printed with the buckets + removed to reach it. + + The denominator depends on the fleet. Seven objectives are answerable when a + host owns its accounts and not when a directory does. The count is computed + from the `directory_joined` capability that scan output already carries. With + no scan report both ceilings are shown and neither is chosen. + + The numerator is reviewed mapping status, never scan posture. Coverage answers + whether Kensa can evidence an objective at all, which is a property of the + corpus. Whether a host passes is a separate question. Letting scan results + move this number would make a fleet of broken hosts look worse covered than an + identical healthy one. + + Other frameworks are unchanged and stay numerator-only. Kensa has no control + catalog for CIS, STIG or 800-53, and inventing a denominator would measure the + standard rather than the product. + +- **CMMC Level 2 practice refs, derived from the 800-171 refs a rule already + carries.** There are 422 refs across 324 rules, covering 51 distinct + practices, so scan output can be filtered by CMMC. + + This is a renaming, not a crosswalk. 32 CFR 170 defines Level 2 as the 110 + NIST SP 800-171 Rev 2 requirements, one for one, and names each practice + `.L2-`. A practice id follows from a reviewed requirement + citation and asserts nothing the 800-171 ref did not already assert. + + Derivation reads only reviewed refs, never the candidate set. Validation + rejects an extra practice as an unreviewed claim, and a missing one as a stale + emission. A committed script writes the refs into rule YAML, so a synthesized + ref always shows up in a reviewable diff. + ### Changed + +- **`suid-sgid-files-reviewed` now compares the full inventory against a declared + baseline**, which is what its own output had been asking for. It printed + "review full inventory against baseline" and left that to a human; it now does + it, against `suid_sgid_baseline`. + + The world-writable check it already had is unchanged and still runs first. + Those are two different assertions: one is wrong on any host and needs no site + input, the other cannot be answered without one. + + A package list would be the obvious way to answer "nonessential programs" and + it was measured and rejected: 682 packages on one fleet host, 626 on another, + and the set changes on every patch, so a declared list is stale within a cycle + and produces failures nobody reads. The SUID inventory is about twenty entries + and barely moves, which is what makes a baseline worth keeping accurate. + +- **A check can now report that it cannot reach a verdict, and the scan records + that as skipped.** Previously every path out of a check was pass, fail, or + something broke, because the result carried only a boolean. A check that + establishes it has nothing to judge had to pick one of those, and all three + were wrong. + +- **An empty declared set is now a skip rather than an error.** It still cannot + become a pass. The change is that a site which has not written its policy yet + is a normal state, not a fault, so it no longer puts a red row on every host in + a fleet that has simply not been configured. The skip carries the name of the + variable to declare. + +- **`no-unauthorized-accounts` reaches a real verdict.** It used to list local + accounts, print `MANUAL REVIEW REQUIRED` and exit 0, so it reported **pass on + every host** for something nobody had verified. It now compares the accounts + against `authorized_local_accounts` and fails naming any account not in it. + + Members may be user names, numeric UIDs, or a mix, because a site may + authorize an account either way and both name the same account. + + **On a fleet that has not declared the set, this rule moves from pass to + skipped.** That is a verdict change on shipped STIG and 800-53 surface, in the + honest direction: it stops counting as covered something that was never + checked. + +- **CI no longer runs the full suite twice per merge.** The workflow fired on + both the pull request and the push that follows the squash merge. Measured + across the last 100 runs, **39 of 40 push runs re-tested a tree a pull request + run had already tested byte for byte**. Comparing commit hashes hides this, + because squashing mints a new one; comparing trees shows it. + + The push run is now a canary of two jobs rather than a second full suite. It + keeps unit tests and the static-link check, which are the cheapest pair that + would catch the two cases a pull request run genuinely cannot see: two changes + that each pass alone but break main together, and a squash of a branch that was + stale when it merged. + +- **Two checks moved to a daily schedule.** The vulnerability scan reads an + advisory database that changes without us, and the release snapshot only breaks + when packaging config or dependencies change. Asking either one on every pull + request runs them far more often than their answers can change, and it lets an + unrelated upstream advisory turn a docs-only change red. + +- **One in-flight run per branch.** Pushing three times to a pull request used to + run three full suites to completion. Superseded runs are now canceled. Runs on + main are never canceled, because they are the post-merge record. + + Measured effect at the current merge rate: **187 to 75 job-hours per month**, + with the merge gate unchanged. Every one of the nine required checks still runs + on every pull request. + - **Seven more policy thresholds are operator-declarable.** Password maximum and minimum age on existing accounts, the PAM SHA-512 rounds floor, the legacy pam_faillock lockout count, the pam_faildelay interval, root's umask, and the @@ -611,7 +439,6 @@ any pair). declared umask is padded and compared digit by digit rather than against a literal 027. -### Changed - **Session-inactivity rules now compare against a period the operator can declare, instead of a benchmark constant.** `gdm-idle-delay`, `gdm-screensaver-lock`, `logind-idle-session-timeout` and `shell-timeout` took their thresholds from CIS @@ -670,6 +497,13 @@ any pair). sha256 with no residue. ### Removed + +- **The standalone secret-scan job**, which ran `detect-secrets` a second time. + The `detect-secrets` hook in `.pre-commit-config.yaml` is default stage, so the + required Pre-commit hygiene job already runs the same version against the same + baseline with the same excludes. Enforcement is unchanged and still sits on a + required check. + - **`shell-timeout-600` and `shell-idle-timeout-tmout` are merged into `shell-timeout`.** They were the same control split by framework (DISA's 600 seconds versus CIS's 900) and by operating system (the Ubuntu STIG). Both splits @@ -690,6 +524,205 @@ any pair). `skipping shell-timeout-600 (superseded by shell-timeout)` and evaluates one rule. +### Fixed + +- **Eight audit and session rules did not run on RHEL 8.** They declared + `rhel >= 9`, so on RHEL 8 they reported "not applicable" and the host was + never checked: `logind-idle-session-timeout`, `auditd-write-logs`, + `auditd-immutable-rules`, and `audit-cmd-init`, `-poweroff`, `-reboot`, + `-shutdown` and `-sudoedit`. + + Each floor matched the earliest release with a published benchmark for that + rule, not anything the check needs. The clearest evidence is the audit-cmd + family itself: 24 of its 29 rules already declared `rhel >= 8`, with the same + check method and the same remediation mechanism as the five that did not. + + Measured on RHEL 8.10 before changing anything. `write_logs` is present in + `auditd.conf` with audit 3.1.2, `/etc/audit/rules.d/audit.rules` and `-e 2` + immutability are supported, `/usr/sbin/init` exists, and `StopIdleSessionSec` + ships as a commented default that logind accepts and applies when set. + + Now `rhel >= 8`. Verified on RHEL, AlmaLinux, Rocky and Oracle Linux 8, all + four with identical results: six rules complete a full round-trip (fail, + remediate, pass, rollback, fail, with `/etc` byte-identical), `auditd-write-logs` + runs and already passes, and `logind-idle-session-timeout` runs and reports a + real verdict with remediation left manual as it was before. + +- **`no-unauthorized-accounts` did not run on RHEL 8 or RHEL 9.** The rule + declared `rhel >= 10`, so on any earlier release it reported "not applicable" + and the host was never checked for local accounts nobody authorized. + + The floor came from the rule's source benchmark, which is published for + RHEL 10, rather than from anything the check needs. The check reads + `/etc/passwd` with awk and compares against the operator's + `authorized_local_accounts` set, which works the same on every supported + release. Its six sibling rules already declared `rhel >= 8` and + `ubuntu >= 22`. + + Now declared `rhel >= 8` and `ubuntu >= 22`, matching the siblings. Verified + on RHEL, AlmaLinux, Rocky and Oracle Linux at 8, 9 and 10: with no set + declared the rule reports that it cannot assess the host, with the host's own + accounts declared it passes, and with a wrong set it fails and names the + account that is present but not authorized. + +- **`security-updates-installed` reported compliant on a host with 384 pending + updates.** The check ran `if dnf check-update; then ... fi` and then read `$?` + on the next line, expecting dnf's exit 100. After an `if` whose condition fails + and which has no `else`, the compound statement itself returns 0, so `$?` was + always 0, the pending-updates branch was unreachable, and the check fell + through to its OK path. + + Measured on a live fleet host: `dnf check-update` exited 100 with 384 packages + pending, and the rule reported PASS. It now reports FAIL and names the count. + + A dnf failure is also a failure now. The old version printed OK when the check + could not run at all, which meant no network or a broken repository read as + patched. + +- **Six remediation mechanisms could cut the SSH control channel without arming + the dead man timer.** The timer is what lets a host reverse a change on its own + when the controller never confirms, and the decision came from a static list + that six mechanisms, used by 124 rules, were missing from entirely. A missing + entry read as "cannot cut the channel", so each was silently unprotected. + + `crypto_policy_set` is the measured case. Setting a host to FIPS regenerates + the SSH daemon's accepted key algorithms **without ed25519**, so an operator + using a modern default key is locked out the moment the change commits, by the + change Kensa just made, over the connection Kensa was using. Both crypto + mechanisms are also non-capturable, so there was no pre-state to return to + either. + + `apt_absent` had the same gap as its already-classified counterpart + `package_absent`: removing the SSH server ends the session. `authselect_feature_enable` + and `pam_module_arg` both rewrite authentication stacks, like the + `pam_module_configure` that was already covered. + + Verified as a before and after on a live container: a plan for a crypto policy + rule reported `ControlChannelSensitive: false` and now reports `true`, and the + same for a rule whose only mechanism is `apt_absent`. + +- **A rule skipped for an undeclared variable now appears in the results.** + `kensa check` warned on stderr and then dropped the rule, leaving no trace in + machine output. Anything counting coverage saw the objective quietly leave the + denominator, which is the failure this area exists to prevent. Such a rule is + now reported as `skipped`, carrying its severity and the name of the variable + to declare. + + The rule's identity is recovered by reading its `id:` line rather than by + decoding the document. Once substitution has failed the file still holds + `{{ name }}`, and unquoted that is a YAML mapping rather than text, so the + document may not decode at all. The `id:` is a plain value on its own line and + is never templated. + + `LoadRules` stays strict and keeps failing the whole load, deliberately. Both + halves of its stated contract are now honored, by different means: a caller + that receives rules has no other channel to learn some are missing, so an + error it must handle is the honest way to say so, while the command line now + reports the same fact as a visible result. + +- **The `--config-dir` help text described a state the code left long ago.** It + said only `defaults.yml` was read. Measured against a live host, + `hosts/.yml` and `conf.d/*.yml` are read as well, and `groups/` + applies in inventory mode, where a host belongs to a group. + +- **Agent mode ignored `--port` and `--key`, and could reach a different host + than the one named.** The agent session built its own SSH invocation carrying + neither, so `-H 127.0.0.1 -P 2231` connected to 127.0.0.1 port 22. That + attempt failed only because authentication happened to fail there; with a + working key it would have applied the remediation to the wrong machine and + recorded it against the right one. + + The session now multiplexes over the connection that already bootstrapped the + agent, so it inherits the port, identity file, host key policy and + authentication method instead of re-deriving them. Settings cannot drift, + because there is no second set to keep in step. It also gains SSH password + authentication, which the agent path could never do. + + If that connection is unavailable, Kensa now **refuses** and names the + workaround rather than falling back to a bare connection that ignores your + settings. + +- **Two connections to one host shared a control socket.** The socket name is + built from user, host, port and process, which two connections to the same + host share, so closing one tore down the other. It was harmless while both + were short-lived, and it stopped being harmless once a long-lived agent + session rode one of them. The connection the agent uses is now private to it. + +- **A local-accounts host no longer fails for not running SSSD.** + `sssd-service-enabled-active` had no gate, so it demanded SSSD of every Ubuntu + host. A measured server with local accounts and no SSSD installed reported + FAIL, which reads as a broken host rather than a rule that does not apply. It + now skips there, and it covers RHEL as well. +- **A host configured for certificate login no longer goes silent when its + daemon is dead.** An earlier version of this change gated the service check on + `directory_joined`, which made a certificate-auth host with a stopped SSSD + report `skipped`. That claims the control does not apply, when the truth was + that it did and the daemon was down. A skip removes an objective from the + coverage denominator, so it has to be true, not merely quieter than a wrong + failure. Gating on `sssd_configured` restores the finding: a hardened fleet + host carrying an SSSD certificate configuration with a failed daemon now + reports FAIL, where before this work it was never checked at all. +- **The directory probe no longer reports a never-joined host as joined.** + Testing that `sssd.conf` exists fails in both directions: `/etc/sssd` is mode + 0700, so an unprivileged caller always reads "no". The probe now looks for a + configured `[domain/...]` section, which survives a stopped daemon and is + absent on a host that never joined. + +- **Two rules reported compliance when the tool they inspect was absent.** + `selinux-user-mapping` guarded on `command -v semanage` and + `firewalld-loopback-source` on `command -v firewall-cmd`, each returning + success when the tool was missing. Both assert a requirement, so absence means + the control is not in place, not that the host is compliant. They are now gated + on the `selinux` and `firewalld` capabilities, which already existed, so such a + host is skipped with a reason. + + Fixing the first one exposed a second false pass underneath it. With the guard + removed, a host where SELinux is enforcing but `semanage` is absent read an + empty login mapping and fell through to the OK path, printing + `OK: __default__ SELinux login is` with no value. An unreadable mapping is an + inability to verify, not evidence of confinement, and it now fails with that + reason. + +- **Four rules gated on a capability that was never probed, so their real check + could never run.** `nftables-default-deny`, `nftables-base-chains`, + `nftables-loopback` and `journald-to-rsyslog` gate on `nftables_active` or + `rsyslog_active`. Neither existed as a probe, so the gate never matched and + every host fell through to a default implementation that answered a different + question than the rule asks. + + `nftables-default-deny` is the clearest case. Its default only checked whether + firewalld was running. On a fleet host it reported **pass** while + `nft list ruleset` showed `policy accept`, a pass for deny-by-default on a host + that does not have it. `journald-to-rsyslog` was worse: its default was an + unconditional `exit 0`, so the rule passed everywhere. With the gate working it + now fails on a host where rsyslog is active and journald forwarding is off, + which is a real finding it had been masking. + + Both capabilities are now probed, and the permissive defaults are removed. With + every implementation capability-gated the engine reports `skipped` where the + rule does not apply, which is the honest verdict, instead of a pass that + answers a different question. + +- **`rule.KnownCapabilities` is now derived from the probe list instead of being + maintained by hand.** The two had drifted: `ufw`, `apt`, `apparmor`, `dpkg` and + three others were probed while absent from the known set, so a rule gating on + any of them would fail `--cap-check` for a capability the engine does detect. + A new test asserts every probed capability is known, which is what found it. + +- **A declared framework key with no value is now a validation error.** Twelve + rules carried one: eight with a bare `nist_800_53:` and four with a bare + `cis:`. An empty key is the worst of both readings. A human scanning the YAML + sees the rule mapped to that framework; the engine produces no FrameworkRef, so + a coverage query sees it unmapped. Neither reader is warned. + + The twelve keys were removed rather than populated. Filling them in would have + meant inventing citations: the eight sysctl rules have siblings citing `CM-6`, + which is a plausible fit and not evidence, and a framework citation nobody + checks until an assessor does is worse missing than absent. Populating them + properly needs the benchmark text and is separate work. + + `kensa-validate` now rejects the pattern, so it cannot come back. + ## v0.9.0 (2026-08-03) ### Fixed diff --git a/Makefile b/Makefile index 3da51be8..b21d2677 100644 --- a/Makefile +++ b/Makefile @@ -177,7 +177,10 @@ roundtrip: build ## round-trip every rule of one mechanism on a host (RT_HOST=ip # A single-host run writes RT_OUT; the sharded runner writes one report per # shard under bin/roundtrip/. Accept whichever exists, so the same target works # after either, rather than failing on a path the caller did not choose. -RT_REPORTS = $(wildcard bin/roundtrip/shard-*.json) $(wildcard $(RT_OUT)) +# strip matters: with both wildcards empty the concatenation is a single +# space, which `test -n` reads as non-empty, so the guards below would fall +# through to a bare python invocation and its argparse usage error. +RT_REPORTS = $(strip $(wildcard bin/roundtrip/shard-*.json) $(wildcard $(RT_OUT))) roundtrip-check: ## gate the last round-trip run against the recorded baseline @test -n "$(RT_REPORTS)" || { echo "no round-trip report found; run 'make roundtrip' first"; exit 2; } diff --git a/README.md b/README.md index bc6fbe97..ba3235af 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Requires Go 1.26+ and make. git clone git@github.com:Hanalyx/kensa.git cd kensa make build # builds all five binaries into bin/ -./bin/kensa --version # → kensa 0.9.0 (kensa) +./bin/kensa --version # → kensa 0.10.0 (kensa) ``` The five binaries: @@ -117,7 +117,8 @@ ldd bin/kensa # "not a dynamic executable" ## Status -`v0.9.0`, released and signed. The 0.x line is the pre-1.0 development phase. The +`v0.10.0`. Every release is signed with the Hanalyx GPG key and a Kensa cosign +key; see [`KEYS`](KEYS). The 0.x line is the pre-1.0 development phase. The `api/` Go package is frozen under v1 semver for OpenWatch's consumption; the rest of the surface (CLI flags, rule schema additions, output formats) may change between MINOR versions with one release of deprecation warning. All 29 handlers diff --git a/VERSION b/VERSION index ac39a106..78bc1abd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.10.0 diff --git a/man/kensa.1 b/man/kensa.1 index 40ef020b..8a341a8f 100644 --- a/man/kensa.1 +++ b/man/kensa.1 @@ -129,7 +129,7 @@ Rule options: \-f, \-\-framework \-f cis\-rhel9 filter rules to those mapping a control under FRAMEWORK (\-f cis_rhel9). Single value (NOT repeatable like \-s/\-t). Hyphen and underscore are interchangeable: \-f cis\-rhel9 == `\-f cis_rhel9`. \-\-control stringArray filter rules by FRAMEWORK:CONTROL (\-\-control cis\-rhel9:5.1.12); repeatable, OR across values; framework portion accepts hyphen or underscore \-x, \-\-var stringArray override a rule variable, KEY=VALUE; repeatable (e.g. \-x pam_faillock_deny=5). Wins over \-\-config\-dir/defaults.yml. VALUE is spliced literally into rule YAML and may flow into shell commands run by handlers — pass only trusted input. - \-\-config\-dir string directory holding defaults.yml (variable defaults source). Phase 3.5 minimum: only defaults.yml is read; per\-host / per\-group / conf.d overrides land in Phase 3.6. + \-\-config\-dir string directory holding variable definitions. Read in priority order: hosts/.yml, groups/.yml (inventory mode only), conf.d/*.yml alphabetically, then defaults.yml. \-\-var wins over all of them. Output options: \-\-format string output format: table, json, or jsonl (deprecated; use \-\-output) (default "table") @@ -181,7 +181,8 @@ Rule options: \-f, \-\-framework \-f cis\-rhel9 filter rules to those mapping a control under FRAMEWORK (\-f cis_rhel9). Single value (NOT repeatable like \-s/\-t). Hyphen and underscore are interchangeable: \-f cis\-rhel9 == `\-f cis_rhel9`. \-\-control stringArray filter rules by FRAMEWORK:CONTROL (\-\-control cis\-rhel9:5.1.12); repeatable, OR across values; framework portion accepts hyphen or underscore \-x, \-\-var stringArray override a rule variable, KEY=VALUE; repeatable (e.g. \-x pam_faillock_deny=5). Wins over \-\-config\-dir/defaults.yml. VALUE is spliced literally into rule YAML and may flow into shell commands run by handlers — pass only trusted input. - \-\-config\-dir string directory holding defaults.yml (variable defaults source). Phase 3.5 minimum: only defaults.yml is read; per\-host / per\-group / conf.d overrides land in Phase 3.6. + \-\-config\-dir string directory holding variable definitions. Read in priority order: hosts/.yml, groups/.yml (inventory mode only), conf.d/*.yml alphabetically, then defaults.yml. \-\-var wins over all of them. + \-\-allow\-conflicts apply even when the selected rules declare conflicts_with each other (default: refuse) Output options: \-\-format string output format: table or json (deprecated; use \-\-output) (default "table")