Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions scripts/check_evidence_claims.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions scripts/check_public_claims.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down
8 changes: 8 additions & 0 deletions tests/release/public-claims.test.sh
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading