diff --git a/SECURITY.md b/SECURITY.md index fd88c7c9..eb446b08 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -40,8 +40,45 @@ We will: ## Security Model -SysKnife uses a layered enforcement model. Every layer is independent; a -bypass of one does not bypass the others. +SysKnife uses a layered enforcement model. Each layer gates a different +stage of one request path: intent validation, action-name allowlisting, +role authorization, one-time approval receipts, and atomic execution claims. +Multi-step and user-scoped actions use independently validated, +operation-restricted helpers. Other grants still cover powerful +administrative tools: the daemon's authorization, validation and audit +remain essential. + +The layers are sequential gates, not independent walls. They all run inside +one process, and that process runs as the `sysknife` service account +(`User=sysknife` in `packaging/sysknife-daemon.service`), which is +root-equivalent by design. That account holds `NOPASSWD` grants for +`env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a /usr/bin/apt-get *`, +`/usr/bin/snap install *` and `/usr/bin/rpm-ostree install *`, each of which +reaches root on its own: apt through `-o APT::Update::Pre-Invoke::`, snap and +rpm-ostree through the install hooks and scriptlets they run as root. A compromise of the daemon process is +therefore a compromise of root on that host. An operator sizing the blast +radius of a daemon compromise should read the grants in +`packaging/sysknife-sudoers` alongside this model. + +### What the denylist is — and is not + +`ROOT_SHELL_UNITS` in `crates/sysknife-daemon/src/actions/validate.rs` +refuses typed actions naming `debug-shell`, `emergency`, `rescue`, +`runlevel1`, or `single`. The denylist stops a unit name arriving from the +LLM or from an MCP client. + +Until 0.21.0 it was also trivially bypassable: the account held one `systemctl` +grant per subcommand, each ending in a wildcard, so +`sudo -n /usr/bin/systemctl start debug-shell.service` reached a root shell with +no validator in the path. The unit verbs now run through +`/usr/lib/sysknife/action-steps`, which applies the same list to the verbs that +bring a unit up whether the daemon or a direct sudo call invoked it +(GHSA-c7rw-23qw-5w33). + +That closes the path around the denylist; it does not make the denylist +containment. It is a list of five unit names, and the package-manager grants +above reach root without naming a unit at all. Do not treat it as a boundary +around the daemon. ### Layer 1 — Intent validation (sysknife-brain, before LLM call) @@ -369,8 +406,8 @@ removal detectable. ## Known Limitations These are acknowledged gaps tracked as open issues. They do not -represent exploitable vulnerabilities in normal use — the downstream -enforcement layers cap their blast radius — but they are relevant for +represent exploitable vulnerabilities in normal use — the later gates on +the same request path still apply — but they are relevant for security certification work. | Gap | Issue | Notes | diff --git a/scripts/check_evidence_claims.py b/scripts/check_evidence_claims.py old mode 100755 new mode 100644 index 8bd0f3e9..7a348afb --- a/scripts/check_evidence_claims.py +++ b/scripts/check_evidence_claims.py @@ -53,11 +53,17 @@ # So the rule is now: if a file states a figure about this project, it belongs # here. Adding a file is cheap; the guard only complains about numbers it can # derive an answer for. +# +# SECURITY.md carries the Security Model wording that +# check_public_claims.sh's independence pin screens. Without it listed here the +# pin ran over claim_files and could not fire on the one file it was written +# for: restoring the retired sentence left the check green. CLAIM_FILES = ( "README.md", "ROADMAP.md", "CONTRIBUTING.md", "HACKING.md", + "SECURITY.md", "docs/introduction.md", "docs/quickstart.md", "docs/distro-support.md", diff --git a/scripts/check_public_claims.sh b/scripts/check_public_claims.sh index 045acd71..615ee010 100755 --- a/scripts/check_public_claims.sh +++ b/scripts/check_public_claims.sh @@ -63,6 +63,13 @@ if ! python3 "$script_dir/check_evidence_claims.py" "$repo_root"; then fi reject_pattern 'until npm publish lands|publish[- ]pending' \ 'setup package is documented as unpublished' "${claim_files[@]}" +# SECURITY.md once said every enforcement layer is independent and a bypass of +# one does not bypass the others. The layers are sequential gates on one +# request path sharing one root-equivalent account (see issue 483); the sudoers +# header wording is the accurate one. Pin the claim so the independence +# framing cannot drift back. +reject_pattern 'Every layer is independent|a bypass of one does not bypass the others' \ + 'layers are sequential gates on one request path, not independent walls' "${claim_files[@]}" reject_pattern 'Fedora([^\n]|$)*(Workstation|Server)([^\n]|$)*fully supported|(Workstation|Server)([^\n]|$)*fully supported' \ 'plain Fedora requires the unfinished dnf action family' "${claim_files[@]}" reject_pattern 'plan and approve from inside (Claude|chat)|chat approval is sufficient' \ diff --git a/tests/release/public-claims.test.sh b/tests/release/public-claims.test.sh old mode 100755 new mode 100644 index bae23353..5a8dd68e --- a/tests/release/public-claims.test.sh +++ b/tests/release/public-claims.test.sh @@ -779,6 +779,14 @@ printf '\nFedora Workstation 44 is fully supported.\n' >> "$fixture/docs/archite assert_rejected 'forbidden claim in a file only the Python list knew about' cp "$repo_root/docs/architecture.md" "$fixture/docs/architecture.md" +# The layer-independence framing the Security Model rewrite retired. +# SECURITY.md was not in CLAIM_FILES, so the pin written for this file +# could not fire on it: restoring the sentence left the check green even +# with the reject_pattern in place. Both lists now include SECURITY.md. +printf '\nSysKnife uses a layered enforcement model. Every layer is independent; a\nbypass of one does not bypass the others.\n' >> "$fixture/SECURITY.md" +assert_rejected 'layer-independence framing restored in SECURITY.md' +cp "$repo_root/SECURITY.md" "$fixture/SECURITY.md" + printf '\nlocal-clone path until npm publish lands\n' >> "$fixture/README.md" assert_rejected 'publish-pending setup language' cp "$repo_root/README.md" "$fixture/README.md"