Protection opt-out: --allow-degraded / --disable per-protection#71
Open
dzerik wants to merge 17 commits into
Open
Protection opt-out: --allow-degraded / --disable per-protection#71dzerik wants to merge 17 commits into
dzerik wants to merge 17 commits into
Conversation
The Protection setters took `sandlock_protection_t` and matched on it exhaustively, so a C or Python caller passing an integer outside the known discriminant range (0..=5) produced undefined behaviour at the Rust match — `#[repr(C)]` enums are UB to construct from arbitrary bits. Change the three entry-points (`sandlock_protection_min_abi`, `sandlock_sandbox_builder_allow_degraded`, `sandlock_sandbox_builder_disable`) to accept `u32` and route every incoming value through `try_protection_from_raw`. Unknown values are now handled at the boundary: - `min_abi(unknown)` returns 0 — a sentinel that cannot collide with any real `min_abi()` (those start at 2). - The builder setters return the input pointer untouched, mirroring the null-builder convention already used elsewhere in the C ABI. The Python wrapper adds a stricter guard: an out-of-range int raises `ValueError` at SDK boundary rather than silently no-op'ing through the FFI, because the Python contract should fail loudly on a typed mistake. Update the C header to declare the new signatures (`uint32_t` instead of the enum type) and document the sentinel and no-op behaviour. The `sandlock_protection_t` enum is kept as a labelling type for callers who want the names; passing an enum constant still works because C implicitly promotes to `uint32_t`. Tests: - 3 new FFI regression tests cover the boundary: min_abi sentinel, setter no-op, and "bad call then good call" to catch builder corruption in the bad path. - 4 new Python tests cover ValueError on out-of-range, negative, and well-formed plain-int inputs.
The previous name omitted the noun "Socket" — reading "abstract unix scope" does not parse, and the kernel constant is `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET` (where SCOPE is the family, not part of the protection's name). The other v6 scope already uses the `Signal` + `Scope` pattern; mirror it. Before this commit the same protection was spelled four different ways across the bindings: | Layer | Old name | |--------|--------------------------------| | Rust | `Protection::AbstractUnixScope` | | C ABI | `AbstractUnixScopeSocket` | | Python | `ABSTRACT_UNIX_SOCKET_SCOPE` | | CLI | `abstract-unix-scope-socket` | After this commit all four agree on the canonical `AbstractUnixSocketScope` / `abstract-unix-socket-scope` form, which also matches the existing `Protection::SignalScope` / `signal-scope` pattern. Updates touch: - `Protection` enum (and every match arm in core / FFI / tests). - C ABI: the discriminant value at index 5 is unchanged (`PROT_ABSTRACT_UNIX_SOCKET_SCOPE` / `SANDLOCK_PROTECTION_ABSTRACT_UNIX_SOCKET_SCOPE` in the header already match this spelling). - CLI parser: the primary string is now `abstract-unix-socket-scope`; the previous `abstract-unix-scope-socket` is kept as an alias so any out-in-the-wild script still parses. Help text and error message updated to the canonical name. - Python re-export: `Protection.ABSTRACT_UNIX_SOCKET_SCOPE` was already canonical; the IntEnum is unchanged. No behaviour change. 287 lib + 18 integration + 10 FFI + 12 Python tests still pass.
…ondition The 18 integration tests in `test_protection.rs` exercise policy-state storage and `resolve()` resolution mechanics — necessary, but they do not verify the *observable* Landlock attrs that exit `confine_inner`. A regression that mis-computes the `handled_access_fs` or `scoped` masks would have left every existing test green while silently degrading the security boundary at the syscall layer. Add 14 unit tests for the three mask helpers (`compute_scope_mask`, `compute_fs_mask`, `compute_net_mask`) that check the actual Landlock bits produced for each (Protection, host_abi, ProtectionState) cell that matters. Tests live alongside the helpers in `landlock.rs` so they can call the `pub(crate)` `compute_scope_mask` without widening the public surface. Coverage: - scope_mask: strict-v6 sets both scope bits; disable(SignalScope) clears only SIGNAL; disable(AbstractUnixSocketScope) clears only ABSTRACT_UNIX_SOCKET; disable both → mask=0; Degradable scopes on a v5 host → mask=0. - fs_mask: strict-v6 includes REFER+TRUNCATE+IOCTL_DEV; each `Disabled` clears exactly one bit; Degraded FsIoctlDev on a v4 host omits the IOCTL_DEV bit (pins the bf9490d fix). - net_mask: handle_net=false → (0, false); strict no-wildcard → (BIND|CONNECT, false); Disabled NetTcp → (0, false); Degradable NetTcp on a v3 host → (0, false). Also document the `compute_scope_mask` precondition explicitly: callers must filter `Resolved::StrictlyUnavailable` upstream (`confine_inner` does, via the `Protection::all()` walk). A `debug_assert!` per scope protection pins the invariant in test builds, so a future caller that forgets the upstream guard fails loudly instead of silently producing a mask=0.
`ubuntu-latest` and `ubuntu-24.04-arm` both run kernel 6.8 — Landlock ABI v4. That leaves the v3 path (FsTruncate as the highest available protection, NetTcp / FsIoctlDev / both v6 scopes unavailable) exercised only by synthetic-ABI unit tests, never by a real landlock_create_ruleset on a v3 kernel. Add `ubuntu-22.04` (kernel 5.15, ABI v3 vanilla) so the v3 path stays covered on every push and PR even as the runner images roll forward. A future regression that mishandles "v3 host: bits above v3 must not be requested" would now fail a real-kernel integration test, not just a unit test against a synthetic ABI value. Also add a `Report Landlock ABI` step that runs `sandlock check` and prints the host's ABI line in the job log. This makes it possible to diagnose a Landlock-version-sensitive regression by glancing at the CI log without re-running the job locally. CI matrix coverage after this commit: - ubuntu-22.04 → kernel 5.15 → ABI v3 (new) - ubuntu-latest → kernel 6.8 → ABI v4 - ubuntu-24.04-arm → kernel 6.8 → ABI v4 (arm64) ABIs v5 and v6 are not yet reachable on GitHub's hosted runners (stock ubuntu-latest is below 6.7 / 6.12); the per-protection availability matrix for v5 and v6 is still covered by the synthetic- ABI unit tests in `landlock::mask_contract_tests` and the out-of-band VM matrix protocol.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17.
Implements the opt-out polarity we agreed on in the design ack comment on #17: default behaviour is
Strictfor every protection, and two new builder methods (allow_degraded(Protection)anddisable(Protection)) opt out per-protection. The result is that callers on a v5 kernel (RHEL 9, Ubuntu 22.04, etc.) can write a single line —.disable(Protection::SignalScope).disable(Protection::AbstractUnixSocketScope)— and get the v5-level FS + REFER + truncate + TCP + ioctl-dev sandbox without the two v6 IPC scopes, exactly as you described it in your first comment on this issue.The hard
MIN_ABI = 6floor inlandlock.rsis gone; with the defaultProtectionPolicy::strict_all()on a v6 host every protection still resolves toActive, so the pre-refactor floor is preserved exactly. The constant itself stays for downstream backwards-compat (it now expresses "minimum ABI when every protection is inStrict").Layers
Same RFC-chain shape as #43 / #46 / #54. Commit prefixes mark the boundary:
core (8 commits,
15b09ce..30ad30c):Protectionenum and its per-variantmin_abi();ProtectionState(Strict / Degradable / Disabled) andProtectionPolicy;ProtectionStatusruntime view;Resolved4-way (Active / Degraded / Disabled / StrictlyUnavailable) at the syscall boundary;Sandbox::protection_policyfield defaulting tostrict_all();confine_innerwalksProtection::all()and returnsConfinementError::ProtectionUnavailable { protection, required_abi, host_abi }for any strict + unavailable combination;compute_fs_mask/compute_net_mask/compute_scope_maskderive Landlock attrs from the resolution;Sandbox::active_protections()exposes the runtime view;sandlock checklearns a per-protection availability table.ffi (1 commit,
265b3c1): C ABI forProtection, two builder setters with move-semantics, andsandlock_protection_min_abi()introspection. The C header declares the discriminants and the new functions.python (1 commit,
53af1d1):ProtectionIntEnum re-exported at the package top level;allow_degradedanddisablekwargs on theSandboxdataclass (last-write-wins to mirrorProtectionPolicy::set); ctypes bindings call through to the C ABI.cli (2 commits,
b443597..2be594b):sandlock checkextended with the per-protection availability table;sandlock runlearns--allow-degraded <name>and--disable <name>(repeatable; case-insensitive kebab-case).docs (1 commit,
a43c1d6): a "Protection opt-out" section in bothdocs/extension-handlers.md(Rust) anddocs/python-handlers.md(Python), and a one-line README pointer.maintainer-lens follow-up (3 commits,
ceae31c..0d5e5fa, added after a deep code review pass): FFI input validation so an out-of-range discriminant from C or Python is rejected at the boundary instead of triggering UB at a Rustmatchover a#[repr(C)]enum; canonical-name rename (the previousProtection::AbstractUnixScopewas missing the nounSocketand didn't agree with the PythonABSTRACT_UNIX_SOCKET_SCOPEspelling — the four bindings now all useAbstractUnixSocketScope/abstract-unix-socket-scope, with the old CLI spelling kept as an alias); 14 mask-contract tests asserting the actual Landlock attribute bits produced by eachcompute_*_maskfor each (host ABI, ProtectionState) cell, plus acompute_scope_maskprecondition docstring anddebug_assert!.ci (1 commit,
8c1d36f):ubuntu-22.04added to the Rust matrix so the v3 path is exercised on a real kernel on every push; aReport Landlock ABIstep prints the host'ssandlock checkoutput to each job's log for visibility.Public API surface added
Trying to keep this minimal per your standing #36 priority. Everything new under
sandlock_core:::Protection(enum, 6 variants — one per kernel ABI floor);Protection::min_abi();Protection::all()ProtectionState(enum);ProtectionPolicy(struct +strict_all()/state()/iter();set()is#[doc(hidden)] pubso the FFI-tests can drive resolution directly)ProtectionStatus(enum, 4-way runtime view)Sandbox::active_protections()(runtime accessor)SandboxBuilder::allow_degraded(Protection) -> Self;SandboxBuilder::disable(Protection) -> SelfSandbox::protection_policy(public field, mirrors the rest ofSandbox)ConfinementError::ProtectionUnavailable { protection, required_abi, host_abi }(existing enum variant)landlock::compute_fs_mask/compute_net_mask(alreadypubfor downstream tests in this repo;compute_scope_maskdeliberately stayedpub(crate))C ABI:
sandlock_protection_tenum (6 named discriminants),sandlock_protection_min_abi(uint32_t) -> uint32_t,sandlock_sandbox_builder_allow_degraded,sandlock_sandbox_builder_disable. Setter functions takeuint32_tfor the discriminant (not the enum type) so an out-of-range value is rejected at the boundary;min_abi(unknown)returns 0 as a sentinel.Python:
ProtectionIntEnum re-exported; two new kwargs on theSandboxdataclass.Three states per protection
Strict(default)ConfinementError::ProtectionUnavailableat build/runMIN_ABI=6behaviourDegradable(allow_degraded)active_protections()andsandlock check)Disabled(disable)Disableddeliberately works on a capable kernel — per your answer to question 3 in the design thread.Validation
Tests: 301 lib (includes 14 new
mask_contract_testsasserting Landlock bits per cell), 18 integration (tests/integration/test_protection.rscovers the policy-state andresolve()mechanics), 10 FFI integration, 12 Python (tests/test_protection.py). The mask-contract tests catch the bug class that the original 18 integration tests miss — i.e. a regression that would mis-computehandled_access_fsorscopedwould now fail a test instead of silently degrading the sandbox.VM matrix (full protocol with reproducer recipe attached out-of-band; this is the relevant table):
sandlock checkABI--disablethat producesexit=0required protection SignalScope is not available: host Landlock ABI is v5, requires v6--disable signal-scope --disable abstract-unix-socket-scopelandlock_create_rulesetwithEINVAL— backport reports v6 but does not provide v5/v6 attrs (see finding F1 below)--disable fs-ioctl-dev --disable signal-scope --disable abstract-unix-socket-scoperequired protection FsRefer is not available: host Landlock ABI is v1, requires v2--disableof every v2+ protection--disableof every v3+ protection--disable signal-scope --disable abstract-unix-socket-scope(also exercised--allow-degradedfor the same two — sameexit=0)Every cell runs a stock
git cloneof this branch (no local patches; the seccomp fallback fix already merged in #63 is now inmain),cargo build --release,cargo test --release --lib -p sandlock-core, the integration tests, and then asandlock runof/usr/bin/truewith the listed--disableflags.Two findings worth flagging
F1 — RHEL 9.7 reports ABI v6 but the kernel does not provide it. The version returned by
landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)is 6 on 9.7, but the actual ruleset creation with v5/v6 attrs fails withEINVAL. The opt-out covers it (the user--disables the affected protections and the ruleset assembles cleanly), but it does mean thesandlock checkABI line cannot be trusted as a capability statement on backport distros. Per-protection probing atconfine_inneris the reliable signal, which is what this PR already does. Not requesting a change — flagged for context.F2 — Initial pre-rebase implementation of
compute_fs_maskonly masked offDisabledprotections, notDegradedones. A test againstcompute_fs_mask(v4, policy_with_degradable_ioctl_dev)would have asserted the IOCTL_DEV bit absent and gotten it present, thenlandlock_create_rulesetwould have failed withEINVALbecause v4 doesn't know the bit. Fixed inbf9490d(Disabled | Degradedmatched together), pinned by the newfs_mask_degraded_protections_get_masked_off_on_low_abi_hosttest in0d5e5fa.CI
ubuntu-22.04added to.github/workflows/ci.yml. With the existing matrix[ubuntu-latest, ubuntu-24.04-arm]both runners now report ABI v6 or higher, so the v3/v4 path was unreached by real-kernel CI. AReport Landlock ABIstep prints the host ABI to the job log on every runner for visibility — verified on the first fork-internal dispatch:sandlock checkABI6.8.0-azure6.17.0-azure6.17.0-azure(Ubuntu LTS labels actually ship Azure-specific kernels, so
ubuntu-22.04is not stock 5.15 — seeactions/runner-images/images/ubuntu/Ubuntu2204-Readme.md.)Known coverage gap — Landlock ABI v5 is unreachable on any GitHub-hosted runner today. The hosted Ubuntu images jump from v4 (22.04) to v6+ (24.04), so the
FsIoctlDev-only code path (a kernel that has v5 but not v6 — exactly the production fleet shape on Rocky 9.6 / Fedora 41) cannot be exercised against a reallandlock_create_rulesetsyscall in this CI. The v5 cells are covered by the synthetic-ABIlandlock::mask_contract_tests(which run on every runner) and by the out-of-band VM matrix on Rocky 9.6 (kernel 5.14.0-570.x.el9_6) and Fedora 41 (kernel 6.11.4). If you want real-kernel v5 coverage in CI we'd need a self-hosted runner pointed at a v5 box; happy to advise on configuration, but the infrastructure decision is yours.The integration tests are split per cell: on
ubuntu-22.04onlytest_protectionruns (the policy/resolution-mechanics subset that uses a synthetic ABI and is host-ABI independent). The remaining integration suite runs on ≥v6 runners — those tests fundamentally assume a v6+ host because they construct defaultSandbox::builder()whoseProtectionPolicy::strict_all()requires every Protection to resolve toActive. Refactoring them to adapt to whatever the host can provide is a separate task; the v3/v4 path is exercised here through the newlandlock::mask_contract_tests(which run on every cell) plus the out-of-band VM matrix above.workflow_dispatchis added to the triggers so future manual reruns don't need a push commit.Scope discipline — what is NOT in this PR
Reference