Zum Hauptinhalt springen
Version: Next 🚧

Guards, and why v1 does not enforce

A hardening policy can lock you out of the machine you are hardening. bitenforcer measures that risk against the session you are actually running in, and reports it before anything is applied. The same guards are the reason v1 applies nothing at all: seven independent adversarial reviewers each arrived with a lockout shape they did not recognise.

This page covers the guards, how to gate a policy on them in CI, and the full account of why enforcement is out of scope for this release. For what the tool does today, see the bitenforcer overview.

The lockout guards​

Eighteen guards, each covering a way a hardening policy can lock you out of your own machine. List them with bitenforcer guards:

ssh-operator-lockout the SSH change would lock out the account you are running as
(FR) la modification SSH verrouillerait le compte avec lequel
vous êtes connecté
--acknowledge ssh-operator-lockout

firewall-ipv6-unhandled IPv4 would be locked down while IPv6 stays open (silent bypass)
(FR) IPv4 serait verrouillé alors qu'IPv6 resterait ouvert
--acknowledge firewall-ipv6-unhandled

Full set: session-undetermined, ssh-no-auth-method, ssh-operator-lockout, ssh-listen-address-unusable, ssh-listen-address-excludes-session, ssh-crypto-narrowing, firewall-session-drop, firewall-deny-before-allow, firewall-ipv6-unhandled, selinux-relabel-required, selinux-port-context-loss, selinux-access-loss, services-remote-access-loss, users-operator-lockout, kernel-remote-access-loss, kernel-boot-risk, security-regression, unreviewed-change.

Two design rules matter:

There is no global --force

A guard is stood down only by naming it — --acknowledge <guard-id> — and acknowledging one guard leaves every other one armed. unreviewed-change cannot be acknowledged as a class at all: the token names one module, kind and key, and the refusal itself prints the exact token to use.

A session that could not be measured is not acknowledgeable for any run that touches remote access. An operator who cannot be located cannot be protected, and pretending otherwise is how people end up locked out.

Gating a policy in CI​

--guards=blocking turns any unacknowledged finding into a non-zero exit:

bitenforcer apply --config policy.yaml --dry-run --guards=blocking
# exit 1 when any guard fires — fails the pipeline

That is how you stop a hardening baseline that would sever remote access from ever reaching a change window.

Why enforcement is not in v1​

The enforcement path was built. It was then reviewed seven times, adversarially, by seven independent reviewers on real hosts. Every single one of them either locked itself out of the test host or forged the confirmation that was supposed to catch that — ten distinct lockout shapes, several of them reporting success and exit code 0 at the time.

The shapes were never the same twice: sshd_config bytes; a firewall plan; a systemd hardening drop-in that is not a "stop" and so never reached the evaluator; an owner/group change on authorized_keys; a sysctl outside the two patterns an evaluator recognised; a "surviving" auth method the operator's key-only account could not actually use; a confirmation accepted over loopback; a probe that could not be performed being read as a failure.

Each wave closed the shape it was shown, and the next reviewer arrived with a new one. That is the signature of an open-world problem: "enumerate every way a policy could make this host unreachable" has no last item, and any build of the guards is a list of the attacks somebody already thought of.

A dead-man rollback was built precisely because it needs no enumeration — whatever broke the host, the confirmation does not arrive and the change goes back. It is the right model and the code is kept. It did not survive review either, and its last finding ended the argument:

The recovery machinery became a local root privilege escalation. The dead-man rollback restored a permission through a symlink an unprivileged user had planted in her own ~/.ssh, taking /etc/shadow from 0640 to 0777. The run's manifest recorded "4 restored, 0 failed".

Shipping that would have put a root privilege escalation on every customer machine, delivered by the safety net. So enforcement is out of scope for v1.

What was proved, and how​

A whole-filesystem fingerprint — path, mode, owner, size and SHA-256 of every file under /etc, /var/lib and the systemd trees — taken before and after an all-modules run as root, in a container with real sshd, nft, iptables and getenforce, is identical. Both for apply (which refuses) and for apply --dry-run (which reports). That is 39 assertions in the repository's end-to-end surface test.

Two builds were then made deliberately wrong and run through the same script, because a test nobody has tried to break is decoration:

  • scope statement deleted → 6 failures, including ROOT apply CHANGED the filesystem;
  • the withheld deadman command re-registered → 7 failures, each naming the command that came back and the state it created.

What v1 deliberately does not ship​

CommandIn v1Why
validate✅Refuses an invalid policy — and refuses keys nothing implements.
apply --dry-run✅The pre-flight.
apply --dry-run --guards=blocking✅Policy gate for CI.
guards, session, version✅Read-only.
generate✅Read-only except with --output-file, which writes — see the caution below.
apply (enforcing)❌States the release scope in EN + FR, names what you can run, exits non-zero.
rollback, confirm❌Nothing in v1 can create a snapshot or arm a rollback, so both could only ever answer "there is nothing here" — which still advertises a capability.
deadman❌Withheld. Restoring is changing the host, over an operator-supplied state directory — this is where the privilege escalation lived.
--output-file writes to the host, and will overwrite an existing file

--output-file is a global flag, so it applies to generate too. When present, generate creates the parent directory (0750) and writes the file (0640) — with no existence check, no O_EXCL and no confirmation. Pointing it at a path that already exists replaces that file.

This page previously listed generate among the commands that are "all read-only", which is where the danger was: an operator pointed --output-file at a path under /etc on the strength of that sentence, and it overwrote the config that was already there. Everything else in the table is genuinely read-only; this one flag is the exception, and it is called out here rather than corrected quietly.

Running apply without --dry-run is not a silent no-op. It tells you the truth and exits non-zero:

$ bitenforcer apply --config policy.yaml
Error: apply: this release does not change hosts, and nothing on this one was changed.

bitenforcer v1 ships policy validation and pre-flight. Enforcement — writing
sshd_config, firewall rules, sysctls, accounts and unit state — is deliberately not
part of it, and no flag turns it on. It is held back because seven independent
adversarial reviews of that path each locked themselves out of a test host or forged
the confirmation meant to catch it.

What this build does, and does well:
bitenforcer apply --config policy.yaml --dry-run
bitenforcer apply --config policy.yaml --dry-run --guards=blocking
bitenforcer validate --config policy.yaml

(FR) Cette version ne modifie aucun hôte et rien n'a été modifié ici. […]

Nothing is deleted from the codebase. The engine, the guards, the snapshot/rollback code and the dead-man machinery all remain, with their tests. They are the foundation of the next release. They are simply not wired to anything that can change your host.

When enforcement comes back​

The bar is written down so it cannot be negotiated down later:

An independent reviewer must fail to forge a confirmation AND fail to survive a rollback, on a real host, twice running.

Two consecutive clean adversarial passes by a reviewer who did not write the fix — not "the shapes we now recognise are covered", a claim that has been made and falsified seven times.

Next steps​

War diese Seite hilfreich?