diff --git a/.cargo/config.toml b/.cargo/config.toml index fcd04d69..0e5f0739 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -48,6 +48,10 @@ RAR_CHSR_EDITOR_PATH = "/usr/bin/vim" RAR_TIMEOUT_TYPE = "ppid" RAR_TIMEOUT_DURATION = "00:05:00" RAR_TIMEOUT_MAX_USAGE = "" +RAR_PASSWORD_TRIES = "3" +RAR_PASSWORD_DELAY = "00:00:05" +# Who owns the terminal of a command run as another user: caller, or target like sudo. +RAR_TTY_OWNER = "caller" RAR_PATH_DEFAULT = "delete" RAR_PATH_ADD_LIST = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin" RAR_PATH_REMOVE_LIST = "" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f69055a0..b6475ee0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,8 +28,12 @@ jobs: session [success=1 default=ignore] pam_permit.so session requisite pam_permit.so session required pam_permit.so" | tee /etc/pam.d/dosr' + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang pkg-config libpam0g-dev libseccomp-dev pandoc - name: Install RootAsRole - run: cargo xtask install -bip sudo + run: cargo xtask install -bp sudo - name: print config run: dosr cat /etc/security/rootasrole.json - name: getenv diff --git a/.github/workflows/install-tests.yml b/.github/workflows/install-tests.yml index 3c61e1c3..3700f658 100644 --- a/.github/workflows/install-tests.yml +++ b/.github/workflows/install-tests.yml @@ -20,7 +20,7 @@ jobs: - name: Install git and build dependencies run: | apt update -y - apt install -y git curl build-essential pkg-config libpam0g-dev + apt install -y git curl build-essential clang pkg-config libpam0g-dev libseccomp-dev pandoc apt install -y sudo ca-certificates - name: Checkout code @@ -47,7 +47,7 @@ jobs: - name: Install RootAsRole run: | - cargo xtask install --debug -bip sudo + cargo xtask install --debug -bp sudo - name: Verify installation run: | @@ -71,8 +71,7 @@ jobs: dnf config-manager --set-enabled crb || true dnf install -y epel-release || true dnf makecache -y - dnf install -y pandoc || true - dnf install -y git gcc pkg-config pam-devel + dnf install -y git clang pkg-config pam-devel libseccomp-devel pandoc dnf install -y sudo ca-certificates - name: Checkout code @@ -99,7 +98,7 @@ jobs: - name: Install RootAsRole run: | - cargo xtask install -bip sudo + cargo xtask install -bp sudo - name: Verify installation run: | @@ -126,7 +125,7 @@ jobs: - name: Install git and build dependencies run: | zypper refresh - zypper install -y bash rustup git curl gcc pkg-config pam-devel + zypper install -y bash rustup git curl clang pkg-config pam-devel libseccomp-devel pandoc zypper install -y sudo rustup toolchain install stable rustup update @@ -152,7 +151,7 @@ jobs: - name: Install RootAsRole run: | - cargo xtask install -bip sudo + cargo xtask install -bp sudo - name: Verify installation run: | diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index abf7e534..e4d28b61 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -40,8 +40,10 @@ jobs: components: clippy override: false - - name: Install RootAsRole - run: cargo xtask dependencies -dip sudo + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libpam0g-dev libseccomp-dev pandoc # if pull request review only - uses: mbrobbel/rustfmt-check@master diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6c9f4c46..1a58c727 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: session required pam_permit.so" | tee /etc/pam.d/dosr' - name: Install Dependencies - run: cargo xtask dependencies -dip sudo + run: apt install -y pkg-config libpam0g-dev libseccomp-dev pandoc - name: run integration coverage run: cargo +nightly coverage diff --git a/.gitignore b/.gitignore index e58a642d..9a00e125 100644 --- a/.gitignore +++ b/.gitignore @@ -61,9 +61,10 @@ obj/ bin/ # Hidden folders (for Linux IDE) -/.idea/ *target/ -*/src/xml_version.rs +/.*/ +!/.cargo/ +!/.github/ # Rust crates */Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index 44049680..c8b79c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -800,7 +800,7 @@ dependencies = [ [[package]] name = "rootasrole" -version = "4.1.0" +version = "4.2.0" dependencies = [ "bitflags 2.13.2", "bon", @@ -834,7 +834,7 @@ dependencies = [ [[package]] name = "rootasrole-core" -version = "4.1.0" +version = "4.2.0" dependencies = [ "bitflags 2.13.2", "bon", @@ -856,7 +856,7 @@ dependencies = [ [[package]] name = "rootasrole-exec" -version = "4.1.0" +version = "4.2.0" dependencies = [ "libc", "log", @@ -1065,7 +1065,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "xtask" -version = "4.1.0" +version = "4.2.0" dependencies = [ "anyhow", "capctl", diff --git a/Cargo.toml b/Cargo.toml index 2394bacb..a88ff23e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["fuzz", "book", "sudoers-reader"] [workspace.package] edition = "2024" rust-version = "1.93.0" -version = "4.1.0" +version = "4.2.0" license = "LGPL-3.0-or-later" homepage = "https://lechatp.github.io/RootAsRole/" repository = "https://github.com/LeChatP/RootAsRole" @@ -97,8 +97,8 @@ undocumented_unsafe_blocks = { level = "warn", priority = 2 } allow-unwrap-in-tests = true [workspace.dependencies] -rootasrole-core = { version = "4.1.0", path = "core" } -rootasrole-exec = { version = "4.1.0", path = "exec" } +rootasrole-core = { version = "4.2.0", path = "core" } +rootasrole-exec = { version = "4.2.0", path = "exec" } [dependencies] rootasrole-core = { workspace = true } diff --git a/book/src/background/performance.md b/book/src/background/performance.md index 7bbfed19..7b0e936b 100644 --- a/book/src/background/performance.md +++ b/book/src/background/performance.md @@ -4,21 +4,34 @@ Infrastructure-as-code environments can generate large and complex privilege pol With RaR policies, every role and task can introduce additional access control rules. In large infrastructures, this can result in thousands of policy entries being evaluated during production operations. Therefore, the privilege evaluation engine must scale efficiently. -The initial implementation of dosr revealed a scalability challenge. The first JSON-based policy engine was faster than sudo for small policies, but its execution time increased significantly as the number of tasks grew. +> Update for 4.2.0 of the performance evaluation is co-written with Claude for correctness. -![Performance degradation of the initial JSON-based dosr implementation as policy size increases from 1 to 100 tasks.](https://github.com/LeChatP/RaR-perf/raw/main/before_json_result.png) +## How it is measured + +The measures are performed with the dedicated [RaR-perf](https://github.com/LeChatP/RaR-perf) repository. Each tool runs `/usr/bin/true` as root, without a password, with a policy of N rules where only the last rule allows the command. [hyperfine](https://github.com/sharkdp/hyperfine) measures the time of each run. Since a remark from reddit, 4.2.0 evaluations verifies the `/usr/bin/true` performance too for completeness. + +The tools do not do the same work for the same rule: sudo and dosr (since 4.0) run the PAM account and session stacks, even when they ask no password, while doas and `please` do not call PAM when they ask no password. A comparison shows how much each design costs. + +After some thoughts, run0 is not in the figures. Its rules aren't read at each run: polkitd loads them once, and keep it loaded in memory, while the other tools read their policy each time. Its numbers would not measure the same work. That said, the time to make a dbus call is significantly slower than reading a policy of 10000 tasks. +Another point is : polkit's run0 rules has no idea about what command is being executed as input. So that makes the comparison not even feasible. + +## The first implementation + +The initial implementation of the policy engine revealed a scalability challenge. It was measured in April 2025, while 3.1.0 was in development, when dosr was still named `sr`. The first JSON-based policy engine was faster than sudo for small policies, but its execution time increased significantly as the number of tasks grew. + +![Performance degradation of the initial JSON-based policy engine (sr, 3.1.0 in development) as policy size increases from 1 to 90 tasks.](https://github.com/LeChatP/RaR-perf/raw/main/figures/25-04-16_sudo-sr-json/result.png) As shown above, the initial implementation suffered from poor scalability. While it performed well with small policies, the execution time quickly surpassed sudo as policy size increased, making it unsuitable for large-scale deployments. -## Moving from JSON to CBOR +### Moving from JSON to CBOR The first optimization focused on the policy storage format. JSON is human-readable and convenient, but parsing large JSON documents introduces unnecessary overhead. To reduce this cost, dosr introduced support for Compact Binary Object Representation (CBOR), a binary format designed for efficient serialization and parsing, and completely compatible with the JSON structure. -![Limited performance gain after switching to CBOR, showing the underlying scalability issue persists as policy size increases to 400 tasks.](https://github.com/LeChatP/RaR-perf/raw/main/before_cbor_result.png) +![Limited performance gain after switching to CBOR, showing the underlying scalability issue persists as policy size increases to 400 tasks (time in seconds).](https://github.com/LeChatP/RaR-perf/raw/main/figures/25-04-16_sudo-sr-cbor/result.png) Although CBOR reduced part of the parsing overhead, the improvement remained limited. The performance curve continued to increase significantly with policy size, demonstrating that file parsing was only one part of the problem. The main bottleneck was located deeper in the policy evaluation architecture. -## Architectural Optimisation +### Architectural Optimisation The next optimization phase focused on the complete execution path of dosr. Instead of only improving the policy format, the internal architecture was redesigned to remove unnecessary overhead. @@ -31,21 +44,64 @@ The main improvements included: These changes, combined with the CBOR policy format, resulted in a significant improvement in both execution time and scalability. -![Final scalability comparison showing the fully optimized dosr (with CBOR and architectural improvements) vastly outperforming sudo at scale, benchmarked up to 10,000 tasks.](https://github.com/LeChatP/RaR-perf/raw/main/result_25-07-04_15.44.png) +![Scalability of the optimized engine (sr, 3.1.0 in development, with CBOR and architectural improvements) and of sudo, benchmarked up to 10,000 tasks in July 2025.](https://github.com/LeChatP/RaR-perf/raw/main/figures/25-07-04_sudo-sr/result.png) -The optimized dosr engine now outperforms sudo by up to 77% for a single-rule policy. More importantly, its execution time scales linearly and grows approximately 40% slower than sudo, maintaining a performance advantage even with policies containing tens of thousands of tasks. +In July 2025, just before the release of 3.1.0, this engine ran 77% faster than sudo for a single-rule policy, and its execution time grew approximately 40% slower than sudo's as the policy grew.
Do you see these very straight lines compared to the previous ones? This is because we changed the way to way to plot the data. Instead of using a some arbitrary increase (e.g., 100, 200, 300), we used a logarithmic increase.
+## From 3.1.0 to 4.1.0 + +These numbers were not measured again for the releases that followed. In October 2026, every version was built again and measured on the same virtual machine, with the same policy: + +| version | one rule | each additional rule | +|---|---|---| +| 3.1.0 | 5.4 ms | 1.9 µs | +| 4.0.0 | 13.9 ms | 2.5 µs | +| 4.0.2 | 14.4 ms | 2.5 µs | +| 4.1.0 | 11.7 ms | 13.4 µs | +| 4.2.0 | 9.4 ms | 2.3 µs | + +![The versions of RootAsRole from 3.1.0 to 4.2.0 (rar-dev), measured in October 2026 with the same policy, from 1 to 10,000 tasks.](https://github.com/LeChatP/RaR-perf/raw/main/figures/26-10-27_versions/result.png) + +In this figure, `rar-dev` is 4.2.0, just before its release. + +**The time of a single rule grew with PAM.** Since 4.0.0, dosr runs the whole PAM transaction even for a task that skips the authentication: it starts PAM, checks the account, sets the credentials, then opens and closes a session. 3.1.0 did not call PAM at all for such a task. The account check is what refuses an account that has expired, or that may not log in at this hour; sudo runs it too for a `NOPASSWD` rule. This cost is therefore kept, as a security choice. Measured commit by commit, it added about 5 ms to a run. Part of it comes from `pam_unix`, which starts its `unix_chkpwd` helper to read the account, because dosr runs as the user with file capabilities instead of being setuid root. The rest of the difference, about 1.5 ms during 3.2 and 1 ms at the end of 4.0, was not traced to a single commit. + +**The cost of each rule grew with patterns.** In 4.1.0, the command paths of the policy that contain wildcards were fixed: they never matched before (commit `1c3084f`). The fix matches them with globset patterns. But it compiled every command path of the policy as a pattern, even a path without any wildcard, which costs about 10 µs for each command of each task. In the same release, other changes made a run with a single rule about 2.7 ms faster. + +## RootAsRole 4.2.0 + +4.2.0 now implement performance indicators unit tests, ignored by default, that print the cost of one task, so that a change can be measured before and after it: + +```sh +cargo test --release --all-features --lib -- --ignored --nocapture --test-threads=1 finder::tests::perf +``` + +With 4.2.0, a run with one rule takes 9.4 ms, and each additional rule costs 2.3 µs. More than half of a run is the PAM transaction, which a timing probe measured at about 5.6 ms. + +The other tools, measured the same way on the same machine: + +![dosr 4.2.0, sudo, doas and please, from 1 to 10,000 rules, measured in October 2026.](https://github.com/LeChatP/RaR-perf/raw/main/figures/26-10-27_tools/result.png) + +| tool | one rule | 10,000 rules | each additional rule | +|---|---|---|---| +| dosr 4.2.0 | 9.4 ms | 30.1 ms | 2.1 µs | +| sudo 1.9.17 | 10.5 ms | 41.1 ms | 3.0 µs | +| doas 6.4 | 2.0 ms | 186 ms | 18.4 µs | +| please 0.5.6 | 5.3 ms | 1,675 ms | 167 µs | + +`/usr/bin/true` alone takes 0.7 ms. With one rule, most of the time of dosr and sudo is the PAM transaction, which doas and please programs do not run when they ask no password. As the policy grows, the cost of each rule depends on how the tool reads and compares its rules. The policy of this figure is written by confgen, and the one of the versions by genpol, which is why the numbers of dosr differ slightly between the two. + ## Large-Scale Automation This scalability improvement is particularly important in automation environments. Tools such as Ansible frequently invoke privilege escalation through `become: true` configuration option. With RaR, every role and task can introduce additional access control rules, potentially resulting in thousands of policy entries on a single system. -The benchmark results show that this additional security granularity does not introduce a proportional performance cost. The optimized dosr engine can reach the execution time of a single sudo rule while evaluating approximately 4,000 RaR rules. +The benchmark results show that this additional security granularity does not introduce a proportional performance cost. With 4.2.0, a policy of 10,000 tasks adds about 21 ms to a run. This enables administrators to define significantly more precise privilege policies while maintaining operational efficiency. @@ -57,5 +113,4 @@ For comparison, **sudo-rs** provides performance comparable to sudo in standard One potential direction is the integration of a SQLite-based policy backend. The RBAC model of RaR map to relational data structures. A relational database could provide additional advantages as we could do efficient indexing as an example. -The benchmarks presented above were performed in July 2025. Since then, additional performance optimisations have been implemented across the codebase, improving internal execution paths and reducing overhead further. - @@billo2025 +The figures of the first engine were measured in 2025, with the scripts of that time. @@billo2025 diff --git a/book/src/cli/chsr.md b/book/src/cli/chsr.md index 7af0d828..d8992965 100644 --- a/book/src/cli/chsr.md +++ b/book/src/cli/chsr.md @@ -1,102 +1,335 @@ # `chsr`
-This section needs rewriting. +This section is AI-generated and reviewed by human.
-`chsr` is the policy administration tool for RaR. +`chsr` (change switch role) is the tool that writes the policy that `dosr` reads. It manages roles, tasks, commands, credentials and options, and it refuses the values that `dosr` would not accept, instead of writing them. -Use it to manage roles, tasks, command rules, credentials, and options. +The policy is protected: its files belong to root, and when the file system supports it, they are immutable. So `chsr` needs privileges too, and you run it through `dosr`. At the installation, the default policy gives the installer a role for that: -## Usage +```bash +dosr chsr list +``` -```text -chsr [COMMAND] [ARGS...] +
+Whoever can run chsr can give themselves any privilege. The role that allows it is root-equivalent: grant it to as few people as possible, and keep it away from the most exposed accounts with a separation of duty. +
+ +In the rest of this chapter, we omit `dosr` before `chsr`. + +## How a `chsr` command reads + +A `chsr` command walks down the policy, like a path in a file system. Each word narrows where the change applies: + +```bash +chsr role ops task reboot options root user ``` -## Main command families +reads as: in the role `ops`, in its task `reboot`, set the option `root` to `user`. The same command without `task reboot` applies to the whole role, and without `role ops`, to the whole policy. -- `role`: create/delete roles, grant/revoke actors -- `role ... task`: create/delete tasks -- `role ... task ... cmd`: manage command allow/deny rules -- `role ... task ... cred`: manage `setuid`, `setgid`, and capabilities -- `options`: manage global/role/task execution options -- `convert`: convert policy storage between JSON and CBOR -- `editor`: open interactive editor mode (when enabled) +Most keywords have a short form. The following commands are the same: -## `chsr editor` +```bash +chsr role ops task reboot options root user +chsr r ops t reboot o root user +``` -`chsr editor` opens an interactive policy editing mode. +| Long form | Short form | +|-----------|------------| +| `role` | `r` | +| `task` | `t` | +| `options` | `o` | +| `command` | `cmd` | +| `credentials` | `cred` | +| `list`, `show` | `l` | +| `whitelist`, `blacklist` | `wl`, `bl` | +| `checklist`, `setlist` | `cl`, `sl` | +| `add` | `create` | +| `delete` | `del`, `d`, `rm`, `unset` | -When the edit session is applied, RaR validates the policy before saving. -Invalid content is rejected with explicit errors. +When you are lost, `chsr --help` shows the main commands, and `-h` after an option shows its own help, like `chsr options path -h`. When a command is wrong, `chsr` tells which word it did not expect, and shows the help of that part. The complete grammar is available at . -Use `chsr editor` for multi-field edits when you want immediate validation. +## Look before you change -For policy field reference, see [Configuration File Format](../configuration.md). -For storage migration, see [File Format Conversion](#file-format-conversion). +Before modifying anything, look at what is there. Every view is printed in JSON, exactly as it is stored: -## File Format Conversion +```bash +chsr list # the whole policy, file by file +chsr r caddy-audit l all # a role: its actors, tasks, or all +chsr r caddy-audit t read-logs l cmd # a task: its cmd, cred, or all +chsr o l env # an option of the whole policy +chsr r sys-admin o l timeout # an option of a role +``` + +The options that you can show are `all`, `path`, `env`, `root`, `bounding`, `timeout`, `password`, `authentication`, `execinfo`, `umask` and `workdir`. + +## Building the WebOps policy + +The [Configuration](../configuration.md) chapter writes the WebOps policy in JSON. Here is the same policy, built with `chsr`, one need at a time. + +### 1. Safe defaults for the whole policy + +The team starts with the options that every role inherits: a fixed `PATH`, an environment that keeps nothing by default, root as a regular user, a strict bounding set, and no disclosure of the policy to users. + +```bash +chsr o path setpolicy delete-all +chsr o path wl set /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +chsr o env setpolicy delete-all +chsr o root user +chsr o bounding strict +chsr o execinfo hide +chsr o auth perform +``` + +Most of these values are already the defaults of RaR. Setting them explicitly is a good practice anyway: a reader of the policy does not have to know the defaults of the version that was installed. + +### 2. Roles and actors + +Each responsibility becomes a role, granted to a group rather than to a person: + +```bash +chsr r webadmin add +chsr r webadmin grant -g webadmin +chsr r caddy-audit add +chsr r caddy-audit grant -g caddy-audit +chsr r caddy-admin add +chsr r caddy-admin grant -g caddy-admin +chsr r sys-admin add +chsr r sys-admin grant -g sys-admin +``` + +`grant -u alice` would grant a user instead. `-g` also accepts a combination of groups, `-g webadmin,staff`: the user must then be a member of all of them. `revoke` takes the same arguments. + +### 3. Tasks and their purpose + +A task is created inside its role. Give it a purpose: in six months, the purpose is what tells you why this task exists. + +```bash +chsr r webadmin t web-push add +chsr r webadmin t web-push purpose set Publish website content from the staging area +``` + +
+Keep your tasks narrow! One operational action per task is the golden rule. +
+ +### 4. Commands + +The `cmd` of a task is a whitelist of command lines, and a blacklist that takes precedence over it. The command is written after `add`, as you would type it, with its absolute path: + +```bash +chsr r caddy-audit t read-logs cmd wl add /usr/bin/journalctl --no-pager -u caddy +``` + +When the arguments are a regular expression, quote the whole command line, so that your shell does not interpret `(`, `|`, `[` or `$`: + +```bash +chsr r caddy-audit t read-logs cmd wl add '/usr/bin/tail ^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$' +chsr r caddy-audit t read-logs cmd wl add '/usr/bin/less ^/var/log/caddy/[^/ ]+$' +``` + +A regular expression always applies to the whole arguments: `^start|stop$` allows `start` or `stop` alone, not any arguments that start with `start`. A command written without arguments allows no argument. To allow any of them, write it explicitly with `^.*$`. `del` removes a command, `purge` empties the list, and `cmd setpolicy allow-all` or `deny-all` sets what is allowed by default.
-This section needs rewriting. +Do not forget that setting cmd setpolicy allow-all allows every command of the system to this task. You never know what a user is able to run, and what it can do with it. The default deny-all is the safe choice.
-`chsr convert` converts RootAsRole policy storage between JSON and CBOR. +### 5. Credentials -### Supported formats +The credentials say with which user, groups and capabilities the command runs. `cred set` forces them: -- `json` -- `cbor` +```bash +chsr r webadmin t web-push cred set --setuid www-data --setgid www-data +chsr r webadmin t web-push cred caps setpolicy deny-all +chsr r caddy-audit t read-logs cred set --setgid adm,systemd-journal +chsr r caddy-audit t read-logs cred caps setpolicy deny-all +``` -### Command forms +The capabilities have their own whitelist and blacklist. `caddy-probe` only needs to capture packets: -```text -chsr convert [--from ] -chsr convert -r [--from ] +```bash +chsr r caddy-admin t caddy-probe add +chsr r caddy-admin t caddy-probe cmd wl add /usr/local/bin/caddy-probe +chsr r caddy-admin t caddy-probe cred set --setgid www-data +chsr r caddy-admin t caddy-probe cred caps wl add CAP_NET_RAW ``` -- `--from`: explicitly select source format and source file. -- `-r` / `--reconfigure`: update `/etc/security/rootasrole.json` so storage points to the new file. +The `CAP_` prefix is optional, and `cred unset` removes what `cred set` wrote. `cred set` forces one user and one set of groups. To let users choose with `dosr -u` or `dosr -g` among several ones, write a [selector](../configuration.md#selector-forms) with `chsr editor`. -### Common convert examples +A task that changes the user without `--setgid` gives the command the groups of that user, as a login does. To keep the groups of the person who runs `dosr` instead, say it: -Convert current policy storage to CBOR and reconfigure runtime: +```bash +chsr r ops t backup cred set --setuid backup --setgid-from caller +``` + +`--setgid-from target` comes back to the default, and `cred unset --setgid-from target` removes it. + +### 6. Options of a role or a task + +Options are set at the level where they apply, and inherited below. `less` must not open other files or a shell, and the pagers of systemd must be disabled: ```bash -chsr convert -r cbor /etc/security/rootasrole.bin +chsr r caddy-audit t read-logs o env set LESSSECURE=1,SYSTEMD_PAGER= ``` -Convert back to JSON and reconfigure runtime: +The root-equivalent `sys-admin` role requires a fresh authentication for each command: ```bash -chsr convert -r json /etc/security/rootasrole.json +chsr r sys-admin o timeout set --type tty --duration 00:01:00 --max-usage 1 ``` -Explicit source/target conversion: +### 7. Hierarchy and separation of duty + +`caddy-admin` inherits the tasks of `caddy-audit`, and `sys-admin` must never be held together with `caddy-admin`: ```bash -chsr convert --from json /etc/security/rootasrole.json cbor /etc/security/rootasrole.bin +chsr r caddy-admin parents add caddy-audit +chsr r sys-admin ssd add caddy-admin ``` -## Operational notes +Both take a list of roles separated by commas, with `add`, `del`, `set` or `purge`. + +### 8. Check the result + +```bash +chsr r caddy-audit l all +``` + +```json +{ + "name": "caddy-audit", + "actors": [ + { + "type": "group", + "groups": "caddy-audit" + } + ], + "tasks": [ + { + "name": "read-logs", + "options": { + "env": { + "set": { + "LESSSECURE": "1", + "SYSTEMD_PAGER": "" + } + } + }, + "purpose": "Read Caddy access logs and journal", + "cred": { + "setgid": [ + "adm", + "systemd-journal" + ], + "capabilities": "none" + }, + "commands": [ … ] + } + ] +} +``` + +`chsr` writes the long form of the actors, `{"type": "group", …}`, and `"none"` for an empty set of capabilities. `dosr` reads both forms, so you can keep the short ones in the files that you write by hand. + +## Options reference + +Each option can be set on the whole policy (`chsr o …`), on a role (`chsr r o …`) or on a task (`chsr r t o …`). `unset` (or `del`) removes the value of this level, so that the level above applies again. + +| Option | Commands | | +|--------|----------|---| +| `path` | `setpolicy delete-all\|keep-safe\|keep-unsafe\|inherit`, `set`, `wl`/`bl` `add\|del\|set\|purge` | The `PATH` of the command. Entries are separated by `:`. | +| `env` | `setpolicy delete-all\|keep-all\|inherit`, `wl`/`bl`/`cl` `add\|del\|set\|purge`, `keep-only`, `delete-only`, `set`, `sl`, `override allow\|deny\|unset` | The environment of the command. `override` decides whether `dosr -E` is accepted. | +| `workdir` | `setpolicy all\|none\|inherit`, `set`, `wl`/`bl` | The working directory. | +| `root` | `user\|privileged\|unset` | Whether uid 0 keeps its special privileges. | +| `bounding` | `strict\|ignore\|unset` | Whether the capabilities that are not granted are removed from the bounding set. | +| `authentication` | `perform\|skip\|unset` | Whether the user must authenticate. | +| `timeout` | `set\|unset` `--type tty\|ppid\|uid` `--duration HH:MM:SS` `--max-usage N` | How long, and how many times, an authentication is reused. | +| `password` | `set\|unset` `--tries 1-255` `--delay HH:MM:SS` | How many times the password is asked, and the delay after a wrong one. | +| `execinfo` | `show\|hide\|unset` | Whether `dosr -i` may show the execution context. | +| `umask` | `022`, `del` | The file creation mask, in octal. | + +For the meaning of each value, see [Options and inheritance](../configuration.md#options-and-inheritance). + +## Policy directories: `chsr file` + +When the policy is a directory, like `/etc/security/rootasrole.d/`, a command edits one file of it: `default.json`, or the only file when there is one. To work on another file of the directory, here `monitoring.json`, name it with `chsr file`, by its path or by its name. `chsr` refuses a file that is not part of the policy. It also refuses, with the reason, a file that it cannot load, like a file written by hand without its `version`: `chsr upgrade --check` lists them all. + +```bash +chsr file list +chsr file monitoring.json r prometheus add +chsr f /etc/security/rootasrole.d/monitoring.json o l +``` + +`chsr list` shows every file of the policy, with its path. When `chsr` saves, it writes only the files that changed. The files that it creates are readable and writable by their owner only. + +## `chsr editor` + +`chsr editor` opens a policy file in a text editor, for the changes that are easier to write in JSON, like the selectors: + +```bash +chsr editor # default.json, like the other commands +chsr editor monitoring.json # another file of the policy +``` + +Without a path, it opens the same file as the other commands: `default.json` of a policy directory, or the only file. With a path or a name, it opens that file of the policy, like `chsr file`. To change where the policy is stored, give the path of the root file, `/etc/security/rootasrole.json`. + +`chsr` copies the file into a private temporary folder in `/tmp` and starts the editor on the copy. When you quit the editor, `chsr` reads the result and validates it. If it is invalid, `chsr` tells why, and you continue editing or abort. If it is valid, `chsr` shows the resulting configuration and asks for a confirmation before saving it. + +The editor is one of the editors chosen when RootAsRole was built (`/usr/bin/vim` by default). The `RAR_EDITOR` variable can select another one, but only among them. `vim` is started without your `vimrc` and without plugins, since they could run anything with the privileges of `chsr`. -- Keep target files in protected system paths. -- If policy content changes during migration prep, prefer `chsr editor` to catch invalid policies before write. -- Validate policy execution after conversion with `dosr -i `. -- Use version control and backups before large migrations. +Before starting the editor, `chsr` restricts itself with Landlock: the editor can only reach the policy files, its temporary folder, and the files that it needs to run. On a kernel without Landlock, `chsr` refuses to start the editor. -For policy field details, see [Configuration File Format](../configuration.md). +
+Read the resulting configuration before you answer yes: what chsr saves is what it understood, not what you typed. +
-## Common general examples +## `chsr upgrade` + +A new version of RootAsRole may change how the policy is written. After an upgrade, `chsr upgrade` rewrites the policy files in the format of the installed version. Packages run it for you during their updates. ```bash -chsr role ops add -chsr role ops grant -g ops -chsr role ops task reboot add -chsr role ops task reboot cmd whitelist add reboot -chsr role ops task reboot cred caps whitelist add CAP_SYS_BOOT +chsr upgrade --check ``` -For complete policy fields, see [Configuration File Format](../configuration.md). +`--check` only shows what would be upgraded, and fails if any file needs it: it is the right command for a script or a monitoring check. + +## File Format Conversion + +`chsr convert` converts the policy between JSON and CBOR. JSON is easy to read and to review. CBOR is a binary format, much faster to load: when your policy is ready, you can convert it. + +```text +chsr convert [-r] [--from ] +``` + +- `` is `json` or `cbor`, and `` is the file to write. +- `--from` reads another file than the current policy, with its format. +- `-r` (or `--reconfigure`) also updates `/etc/security/rootasrole.json`, so that `dosr` uses the new file. + +Convert the current policy to CBOR, and use it: + +```bash +chsr convert -r cbor /etc/security/rootasrole.bin +``` + +Come back to JSON: + +```bash +chsr convert -r json /etc/security/rootasrole.json +``` + +Convert a file without changing what `dosr` uses: + +```bash +chsr convert --from json /etc/security/rootasrole.json cbor /etc/security/rootasrole.bin +``` + +
+Keep the converted files in a protected system path, owned by root, and make them immutable. Also if you keep a copy on your working directory, do not make it readable by anyone else. Letting those files allows an attacker to discover your own policy. +
+ +## Operational notes +- Keep the policy under version control, or at least keep a backup, before large changes. +- Check what a user can do with `dosr -i `, on a task where `execinfo` is `show`, or read the policy with `chsr list`. +- After a conversion or an upgrade, run a command of each critical task once. diff --git a/book/src/cli/dosr.md b/book/src/cli/dosr.md index 06d3108b..8837e01a 100644 --- a/book/src/cli/dosr.md +++ b/book/src/cli/dosr.md @@ -1,32 +1,179 @@ # `dosr` -`dosr` executes commands through RaR policy checks. +
+This section is AI-generated and reviewed by human. +
-## Usage +`dosr` (do switch role) runs a command with the privileges of one task of the policy, and nothing more. You do not become root: you get the user, the groups and the Linux capabilities that this task gives to this command. -
-Execute privileged commands with a role-based access control system
+This chapter explains what `dosr` does when you run it, how it chooses a task, and what you can control from the command line. It follows the WebOps team of the [Configuration](../configuration.md) chapter: alice, bob and charly now use `dosr` every day.
 
-Usage: dosr [OPTIONS] [COMMAND]...
+## Usage
 
-Arguments:
-  [COMMAND]...  Command to execute
+
+Usage: dosr [OPTIONS] [--] [COMMAND]...
 
 Options:
-  -r, --role <ROLE>  Role to select
-  -t, --task <TASK>  Task to select (--role required)
-  -u, --user <USER>  User to execute the command as
-  -g, --group <GROUP<,GROUP...>> Group(s) to execute the command as
-  -E, --preserve-env          Keep environment variables from the current process
-  -D, --chdir <DIR>  Change working directory before executing the command
-  -p, --prompt <PROMPT> Prompt to display
-  -K                 Remove timestamp file
-  -i, --info         Print the execution context of a command if allowed by a matching task
-  -h, --help         Print help (see more with '--help')
-  -V, --version      Print version
+  -r, --role <ROLE>             Role to use
+  -t, --task <TASK>             Task to use in the selected role (--role required)
+  -u, --user <USER>             User to execute the command as
+  -g, --group <GROUP>(,<GROUP>...) Group(s) to execute the command as
+  -E, --preserve-env            Keep your environment variables, if a matching task allows it
+  -D, --chdir <DIR>             Working directory of the command
+  -p, --prompt <PROMPT>         Prompt to display instead of "Password: "
+  -S, --stdin                   Read the password from the standard input
+  -K, --remove-timestamp        Remove your timestamp cookies before authentication
+  -i, --info                    Print the execution context of a command, if a matching task allows it
+  -h, --help                    Print help
+  -v, --version                 Print version
 
-If you're accustomed to utilizing the sudo tool and find it difficult to break that habit, consider creating an alias : +## What happens when you run `dosr` + +Let's follow bob, who wants to read the last lines of the Caddy access log: + +```bash +dosr tail -n 50 /var/log/caddy/access.log +``` + +1. **Options first.** `dosr` reads its own options until the first word that is not an option: this is the command. Everything after it belongs to the command, even when it starts with `-`. Here, `-n 50` is given to `tail`, not to `dosr`. Like with sudo, `--` also ends the options of `dosr`. +2. **Finding the tasks.** `dosr` reads the policy, keeps the roles where bob is an actor, and looks for the tasks whose commands match `tail -n 50 /var/log/caddy/access.log`. `tail` is resolved with the `path` option of the policy, never with the current directory. +3. **Authentication.** `dosr` asks for bob's password through PAM. It asks it even when no task matches: before bob is authenticated, `dosr` reveals nothing about the policy. Someone who stole bob's account cannot list what the policy allows without his password. +4. **The decision.** If no task matches, `dosr` answers "You are not allowed to execute this command, this incident will be reported." and logs the attempt. +5. **The execution context.** `dosr` prepares the user, the groups and the capabilities of the task, filters the environment, sets the working directory and the umask. If the task needs a capability that this session can never obtain, `dosr` stops here and tells which ones. +6. **The execution.** `dosr` logs the execution, runs the command and waits for it. When the command ends, `dosr` ends the same way. + +
+dosr does not run a shell, like sudo does as well. When you are doing dosr echo hello > /etc/file, the redirection is done by your own shell, with your own privileges, before dosr even starts. So, you can use dosr sh -c 'echo hello > /etc/file' to run the redirection with the privileges of the task. +
+ +## How `dosr` chooses a task + +A user may hold several roles, and several tasks may match the same command. In that case, `dosr` selects the most precise and least privileged task. + +The precision comes first. It is about how closely the policy matches you and your command: a command line written in full is more precise than a regular expression on the arguments, which is more precise than a task that accepts any arguments. Then, only when two tasks are equally precise, the one with fewer privileges wins: fewer capabilities, no change to root, and so on. + +Despite this selection, confusion can still arise. When two tasks are entirely equivalent but configure the command differently (for example with different environment variables), `dosr` cannot decide for you. It answers "Permission denied", and logs a warning saying that the policy must be fixed. + +When you know which task you want, say it: + +```bash +dosr -r caddy-audit -t read-logs tail -n 50 /var/log/caddy/access.log +``` + +This is the best practice in automation (Ansible, scripts): the selection is then deterministic, and a change in the policy cannot make your script use another task without you noticing. + +## Choosing the user and the groups: `-u` and `-g` + +By default, the task decides who the command runs as. With `-u` and `-g`, you ask for a specific user or group, but only among those that the task allows (see the [selector forms](../configuration.md#selector-forms)). A task that does not allow your request is not selected: + +```bash +# Only accepted by a task whose setgid selector allows www-data +dosr -g www-data /usr/local/bin/caddy-probe +``` + +`-u` and `-g` do not give you a user: they choose one within the policy. + +When the task changes the user and gives no groups, the command gets the groups of that user, its primary group and its supplementary groups, as a login gives them. It never mixes the new user with your own groups, unless the task asks for it with `"from": "caller"` in its `setgid`. A task that keeps your user keeps your groups. + +## The environment of the command + +The command does not inherit your environment. Only the variables allowed by the `env` option of the task cross the privilege boundary, and the variables that PAM modules set (`pam_env`, `pam_systemd`) go through the same filter. This is what stopped the `APT_CONFIG` hook of the WebOps scenario. + +`dosr` then sets a few variables itself: + +- `USER`, `LOGNAME`, `HOME` and `SHELL`, of the user the command runs as; +- `PATH`, from the `path` option of the task; +- `RAR_USER`, `RAR_UID`, `RAR_GID` and `RAR_COMMAND`, who asked for what; +- `SUDO_USER`, `SUDO_UID`, `SUDO_GID` and `SUDO_COMMAND`, the same values, for the scripts written for sudo; +- `TERM`, set to `unknown` if you had none. + +With `-E`, you ask to keep your own variables. It is only accepted by a task that allows it, and even then, the `delete` and `check` lists still apply. See [Preserving the environment with `dosr -E`](../configuration.md#preserving-the-environment-with-dosr--e). + +## The working directory: `-D` + +The command runs in your current directory, entered as the user of the command, or in the directory that the `workdir` option forces. With `-D`, you ask for another directory, among those that the `workdir` option allows. + +## Authentication + +`dosr` authenticates you through PAM, with the `dosr` service: `/etc/pam.d/dosr`. A task may skip it with the `authentication` option, but think twice: this is the only step that proves the person typing is really the owner of the account. + +A wrong password is asked again, after "Sorry, try again.", up to 3 times by default, with a delay of 5 seconds after each failure. The delay makes guessing slow. Any other PAM error stops at once. The `password` option changes both values, at any level of the policy: + +```bash +chsr options password set --tries 3 --delay 00:00:05 +``` + +`-p` changes the prompt, and `-S` reads the password from the standard input, when there is no terminal. When two `dosr` of the same pipeline need a password, like in `dosr cat a | dosr tee b`, the second one waits until the first is authenticated, and then reuses its cookie when the timeout allows it. They do not fight for your keystrokes anymore. + +### Timestamps + +Typing your password for every command is tiring, so `dosr` remembers a successful authentication for a while, according to the `timeout` option: + +- `type` is the scope of the cookie. `ppid` reuses it for the same parent process (your shell) in the same session, `tty` for the same terminal, and `uid` for any process of your user, which is the widest; +- `duration` is how long it stays valid; +- `max_usage` is how many commands it can serve. + +For their `sys-admin` role, the WebOps team chose a cookie per terminal, valid for one minute and one command only: this role is root-equivalent, so its authentication must stay fresh. + +`dosr -K` removes your cookies, so the next command asks for your password again. Without a command, it only removes them. + +
+If you are using cookies for uid, be warned about this feature. If anyone has access to your user account, they can then use your cookie, performing privileged operations without needing your password. This all matters of your own security policy. +
+ +## While the command runs + +When you run `dosr` from a terminal, the command gets its own pseudo-terminal: it cannot push characters into your terminal to make your shell run them. `dosr` forwards the signals it receives to the command. + +- **Ctrl+Z** suspends the command like any shell job, and `fg` or `bg` resumes it. Your unsaved work in an editor is not lost. +- **When `dosr` has to stop the command itself**, it sends SIGHUP and SIGTERM first, then SIGKILL two seconds later. A package manager or a database has time to remove its lock or to finish a write. +- **The exit status** is the one of the command. If the command was killed by a signal, `dosr` ends with the same signal, so your shell sees the usual 128 + N: 130 for Ctrl+C, 143 for SIGTERM. The errors of `dosr` itself end with status 1. + +## Logs + +Every execution, every refusal and every authentication failure is written to syslog, with the `auth` facility. An execution looks like this: + +```text +bob : TTY=/dev/pts/0 ; PWD=/home/bob ; ROLE=caddy-audit ; TASK=read-logs ; USER=bob ; GROUPS=adm,systemd-journal ; CAPS=none ; COMMAND=/usr/bin/tail -n 50 /var/log/caddy/access.log +``` + +With systemd, you can read them with: + +```bash +journalctl -t dosr +``` + +## Checking your rights: `-i` + +`-i` shows what `dosr` would do, without running the command: + +```text +$ dosr -i tail -n 50 /var/log/caddy/access.log +Role: caddy-audit +Task: read-logs +Execute as user: Your current user and group(s): adm (4), systemd-journal (999) +With capabilities: None +Command: /usr/bin/tail ["-n", "50", "/var/log/caddy/access.log"] +``` + +It only works with tasks whose `execinfo` option is `show`. Listing what the policy allows is valuable information for an attacker, so the WebOps team hides it everywhere: for them, `dosr -i` always answers that it is not allowed. An administrator can still read the whole policy with `chsr`. + +## Coming from sudo + +We kept most of the sudo command line, so that your habits and your scripts keep working. However, a few options differ: + +| sudo | dosr | Note | +|------|------|------| +| `-u`, `-g`, `-E`, `-D`, `-p`, `-S`, `-K` | same | Only within what the task allows. | +| `-r` (SELinux role) | `-r` | The RootAsRole role. | +| `-i` (login shell) | `-i` | Prints the execution context instead. | +| `-l` | `-i` | For one command, if `execinfo` is `show`. | +| `-k` | `-K` | Removes the cookies. | +| `-s`, `-b`, `-n`, `-A`, `sudoedit` | none | Not available. | + +If you're accustomed to utilizing the sudo tool and find it difficult to break that habit, consider creating an alias: + ```sh alias sudo="dosr" alias sr="dosr" @@ -34,13 +181,18 @@ alias sr="dosr" ### Future Design Considerations -Today, we copied completely the CLI of sudo, thus ensuring old scripts compatibility. +Today, the CLI of `dosr` follows the one of sudo, to keep old scripts working. We might follow a new design in the future, with retro-compatibility enabled. For example, we could detect when the command name is `sudo` and then use the sudo CLI design, otherwise fall back to the new design. -We might following a new design in the future but with retro-compatibility enabled. For example, we could detect when the command name is sudo then using the sudo CLI design, otherwise fallback to a new design. +## When something goes wrong -## Examples +`You are not allowed to execute this command, this incident will be reported.` --> No task of your roles matches this command, with these options. -```bash -dosr reboot -dosr -r ops -t reboot reboot -``` \ No newline at end of file +`Permission denied` --> Two equivalent tasks match: the policy must be fixed. The log has the details. + +`dosr: the task needs capabilities that this session cannot obtain anymore: …` --> The task needs capabilities that are out of the bounding set of your session, for example in a container. + +`This incident is reported.` --> The authentication failed. + +`You need at least dac_read_search or dac_override, setpcap and setuid capabilities to run dosr` --> `dosr` itself lacks its file capabilities: check the installation. + +In every case, the log says more than the terminal, on purpose. diff --git a/book/src/configuration.md b/book/src/configuration.md index 0c2136a3..fe3c24a8 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -88,7 +88,7 @@ The data is split into two parts: the **root file**, which tells RaR where the p ```json { - "version": "4.1.0", + "version": "4.2.0", "storage": { "method": "json", "settings": { @@ -109,7 +109,7 @@ For the WebOps team, the policy will be stored in `/etc/security/rootasrole.d/de ```json { - "version": "4.1.0", + "version": "4.2.0", "options": { }, "roles": [ ] } @@ -230,11 +230,21 @@ Arguments can be matched in two ways: /usr/bin/install ^-d -m 0?[0-7]{3} /srv/www/[^ ]+$ ``` -Regular expressions follow the syntax of the Rust [`regex`](https://docs.rs/regex/latest/regex/#syntax) crate. Note that this syntax does not support look-around nor backreferences. +Regular expressions follow the syntax of the Rust [`regex`](https://docs.rs/regex/latest/regex/#syntax) crate. Note that this syntax does not support look-around nor backreferences. For a safer implementation of regex, we encapsulated the regex inside a non-capturing group, so the regex looks like `^(?:your_regex_here)$`. -
-We are still working on the way to express a regular expression safely on the arguments. -
+Note that when arguments hold special characters, the regex must take into account the quotes needed by the shell to express them. Before matching, `dosr` joins the arguments the way a shell would quote them, with single quotes only: + +- an argument that contains a space, a tab, a newline, or one of `'`, `"`, `\`, `|`, `&`, `;`, `<`, `>`, `(`, `)`, `$`, `` ` ``, `*`, `?`, `[`, `#`, `~`, `=` or `%` is wrapped in single quotes; +- an empty argument becomes `''`; +- a single quote inside an argument is written `'\''`. + +So `ls -l "My Documents"` is matched by `/usr/bin/ls ^-l 'My Documents'$`, and never by `^-l "My Documents"$`. The `=` of an option is the most common trap: bob's `journalctl --unit=caddy --no-pager` is seen as `'--unit=caddy' --no-pager`, so the regex must be `^'--unit=caddy' --no-pager$`. For `ls -l "My Doc'uments"`, the backslash of `'\''` must be escaped in the regex, `^-l 'My Doc'\\''uments'$`, and once more in the JSON file (`\\\\`). + +Exact matches are not concerned: the arguments written in the policy are quoted the same way before the comparison. + +For example, the following command `ls -l "My Doc'uments"`, must be translated in the policy in a single quote form: `/usr/bin/ls ^-l 'My Doc'\\\\''uments'$`. This amount of escaping is needed because the policy is a JSON file, and the regex is a string inside it. The shell will remove the quotes and the backslashes, and the regex engine will see `My Doc'uments` as expected. + +An argument is enclosed in single quotes whenever it contains any of the following characters: space, tab, newline, ‘ " \ | & ; < > ( ) $ ` * ? [ # ~ = %. An empty argument becomes ''. Non-regex arguments does not have this escaping requirement. ### Applying it to WebOps @@ -277,10 +287,18 @@ The `setuid` field sets the user the command runs as. In security terms, this is Most administrators want to be a single user: root, because the system's discretionary access control (DAC) is built around it. RaR suggests another approach: when impersonating root is needed, grant it **without capabilities**. Root then only has the rights given by file permissions, like a regular user. Going further, system configuration that administrators need should have a segregated DAC, owned by a dedicated user, so that bypassing it requires an explicit Linux capability. +One note is : If you don't specify a `setgid`, the command is taking groups from the `setuid` user, aligning with DAC policy. If you want to reduce/edit the groups, you must specify a `setgid` to override the default. + +Also, if the user or group in the task does not exist, the task is left out, like a task that does not match: another task may still be chosen. The missing user or group is written in the log. + ### `setgid` The `setgid` field makes DAC configuration easier. You can define a single group, or a list of groups. If a list is defined, the first group is the primary group, and the others are supplementary groups. +Now, let's discuss about subtleties. the `setgid` field can start from the caller groups, or from the `setuid` (target) user groups, with the `from` field of its [selector form](#setgid-1). If you don't specify a `setgid`, the command is taking groups from the target user, aligning with DAC policy. If you want to reduce/edit the groups, you must specify a `setgid` to override the default. + +Same as `setuid`, if a group of the task does not exist, the task is left out, like a task that does not match. The missing group is written in the log. + ### `capabilities` Linux divides the privileges of the superuser into distinct units called capabilities. Unlike DAC permissions tied to files, capabilities are assigned to processes. They allow granting a specific privilege to a task without running it as root. @@ -377,6 +395,22 @@ Gives no capability, except `CAP_NET_BIND_SERVICE`. This is a common use case fo } ``` +`fallback` is the group set used when the caller does not specify `-g`. Without `fallback`, the `from` field says whose groups the command gets: + +```json +"setgid": { + "default": "none", + "from": "caller" +} +``` + +- `target` (the default): the groups of the user the command runs as, its primary group and the groups that list it as a member, as a login gives them; +- `caller`: the groups of the user who runs `dosr`. + +Without `fallback` nor `from`, the `none` default requires `-g`, and the `all` default gives the groups of the target user. With chsr, `--setgid-from` sets it, for example `chsr role admin task backup credentials set --setuid backup --setgid-from caller`. + +Keep `caller` for the tasks that really need it: the command then runs as the target user with every group of the caller (`adm`, `docker`…), which neither of them has alone. + `setuid` and `setgid` can also be written in their compact mandatory form (a single user, or a group/group list), as in the WebOps tasks. ## Options and inheritance @@ -474,6 +508,10 @@ For bob's `read-logs` task, the `set` directive disables the shell escape of `le Since no `default` is declared at the task level, the global mode and lists are inherited, and these values are added on top. +#### PAM management of environment variables + +In the case where PAM is used to set environment variables through `pam_env` or `pam_env.so`. The `env` option of RaR will still filter these variables according to the policy, ensuring that only allowed variables are passed to the command. + ### The `path` option The `PATH` variable is a colon-separated list of directories searched for executables. The `path` option controls it and must be carefully configured to avoid privilege escalation. @@ -484,9 +522,11 @@ This option works, again, like mathematical sets, with `add` and `sub` (alias `d - `delete`: start from an empty list and add directories. This is the recommended mode. - `keep-safe`: start from the caller's `PATH`, and remove relative paths (such as `.` or `bin`). Absolute directories are kept as is, even if they are writable by the caller. -- `keep-unsafe`: start from the caller's `PATH` as is. Avoid it. +- `keep-unsafe`: start from the caller's `PATH` as is. Avoid it. We use it only for development purposes. - `inherit`: use the mode of the level above. +Some details: in `delete` mode, the caller's `PATH` is not used at all. A relative directory is the working directory of the command, which the caller chooses, so except for `keep-unsafe`, relative directories are removed, from the caller's `PATH` as from the `add` lists of the policy (the ones of the policy are written in the log). The directories of the policy come first, then the ones of the caller; the `sub` list is removed, and each directory appears once. Whether a directory exists is not checked. If the resulting list is empty, then the PATH variable is not set at all, meaning that the user must type the full path of the command to run it. + For WebOps, the global level only allows system directories, including `/usr/local/bin` where `caddy-probe` lives: ```json @@ -503,17 +543,21 @@ These directories must only be writable by root; otherwise, anyone able to write | Option | Values | Description | |------------------|---------------------------------|-------------------------------------------------------------------------------------------------------------------| | `authentication` | `perform` \| `skip` | Whether the user must authenticate before running the command. | -| `timeout` | `type`, `duration`, `max_usage` | How long, and how many times, an authentication remains valid, and its scope (`ppid`, `tty` or `uid`). | +| `timeout` | `type`, `duration` (hh:mm:ss format), `max_usage` | How long, and how many times, an authentication remains valid, and its scope (`ppid`, `tty` or `uid`). | | `root` | `privileged` \| `user` | `user` sets the `SECBIT_*` securebits that disable the special privileges of uid 0: root becomes a regular user, and gets only the capabilities granted by the task. `privileged` keeps the legacy root behavior. | | `bounding` | `strict` \| `ignore` | `strict` enforces the granted capabilities on the bounding set: the command and its children can never regain any other capability. | | `execinfo` | `show` \| `hide` | Whether `dosr --info` may disclose the existence of the matching policy element to the user. | | `workdir` | path \| selector | Working directory of the command, or the set of directories the user may choose. | | `umask` | string | File creation mask of the command. | +| `password` | `tries`, `delay` (hh:mm:ss format) | Number of attempts and delay between them for password authentication. | +| `tty` | `owner`: `caller` \| `target` | Who owns the terminal of a command run as another user, when `dosr` runs from a terminal: the caller (the default) or the target user. Generally, the `caller` option is safer, but sudo uses the `target` option behavior. The security of this option depends on the context of use. | The `root` option deserves attention. On Linux, a process running as uid 0 that executes a binary automatically obtains all capabilities. Without `"root": "user"`, a task with `"setuid": "root"` and `"capabilities": []` would get them back as soon as the command runs another program. This is what makes the WebOps `caddy-service` and `reboot` tasks really unprivileged, so the team sets `root` to `user` and `bounding` to `strict` globally. `execinfo` also has a security impact: listing what the policy allows is valuable information for an attacker who stole an account. The team sets it to `hide` globally. +`tty` is written `"tty": { "owner": "target" }`. With `caller`, the user the command runs as cannot reopen its terminal by its name: a program that does, like pinentry through `GPG_TTY`, fails. With `target`, the other processes of that user can open the terminal while the command runs, and read what is typed into it. The default comes from the build (`RAR_TTY_OWNER`, see [Build](install/build.md)). + For `sys-admin`, the most critical role, the team requires authentication on every use, with a short and non-reusable timeout: ```json @@ -535,6 +579,15 @@ This utility addresses differences in pattern syntax and list inheritance: Tasks still using unmigrated regular expressions are rejected by `dosr` at runtime until the upgrade has been completed. +### Migrating configurations from version 4.1 + +No migration is needed from version 4.1 to 4.2.0. However, downgrading from 4.2.0 to 4.1: + +* removes the new `password` and `tty` options, and the `from` field of `setgid`; +* writes a regular expression with a top-level alternation, like `^start|stop$`, as `^(?:start|stop)$`, so that 4.1 does not allow more. + +Note that 4.1 gives the caller's groups to a task that changes the user, whatever the policy says. + ## Hierarchy and separation of duty Hierarchy and separation of duty (SoD) are designed as plugins on top of the core RBAC-0 model. They are not part of the core model, but they are implemented in the RaR project. @@ -542,6 +595,10 @@ Hierarchy and separation of duty (SoD) are designed as plugins on top of the cor - The **hierarchy** plugin lets a role declare its parent roles, through the `parents` field. The role inherits the tasks of its parents. - The **static separation of duty** plugin declares conflicting roles, through the `ssd` field: a user should never be an actor of two conflicting roles. +When the lookup of an actor fails, for example a directory service that does not answer, `dosr` cannot know whether the user holds the conflicting role: it then disables every role with an `ssd` field in that policy file, and writes the failure in the log. + +Note that A role that is in conflict with one of its parents is invalid, because it would inherit the parent's tasks and thus be in conflict with itself. + ### Applying it to WebOps Bob operates Caddy, so he also needs to read its logs. Instead of duplicating the `read-logs` task, `caddy-admin` declares `caddy-audit` as its parent, and inherits its tasks: @@ -605,7 +662,7 @@ Here is the resulting `/etc/security/rootasrole.d/default.json`: ```json { - "version": "4.1.0", + "version": "4.2.0", "options": { "path": { "default": "delete", diff --git a/book/src/install/build.md b/book/src/install/build.md index c61f773a..2ba6e913 100644 --- a/book/src/install/build.md +++ b/book/src/install/build.md @@ -18,13 +18,17 @@ RAR_CHSR_EDITOR_PATH = "/usr/bin/vim" RAR_TIMEOUT_TYPE = "ppid" RAR_TIMEOUT_DURATION = "00:05:00" RAR_TIMEOUT_MAX_USAGE = "" +RAR_PASSWORD_TRIES = "3" +RAR_PASSWORD_DELAY = "00:00:05" +# Who owns the terminal of a command run as another user: caller, or target like sudo. +RAR_TTY_OWNER = "caller" RAR_PATH_DEFAULT = "delete" RAR_PATH_ADD_LIST = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin" RAR_PATH_REMOVE_LIST = "" RAR_ENV_DEFAULT = "delete" -RAR_ENV_KEEP_LIST = "HOME,USER,LOGNAME,COLORS,DISPLAY,HOSTNAME,KRB5CCNAME,LS_COLORS,PS1,PS2,XAUTHORY,XAUTHORIZATION,XDG_CURRENT_DESKTOP" -RAR_ENV_CHECK_LIST = "COLORTERM,LANG,LANGUAGE,LC_.*,LINGUAS,TERM,TZ" -RAR_ENV_DELETE_LIST = "PS4,SHELLOPTS,PERLLIB,PERL5LIB,PERL5OPT,PYTHONINSPECT" +RAR_ENV_KEEP_LIST = "HOME,USER,LOGNAME,COLORS,DISPLAY,HOSTNAME,KRB5CCNAME,LS_COLORS,PS1,PS2,XAUTHORITY,XAUTHORIZATION,XDG_CURRENT_DESKTOP" +RAR_ENV_CHECK_LIST = "COLORTERM,LANG,LANGUAGE,LC_*,LINGUAS,TERM,TZ" +RAR_ENV_DELETE_LIST = "IFS,CDPATH,LOCALDOMAIN,RES_OPTIONS,HOSTALIASES,NLSPATH,PATH_LOCALE,LD_*,_RLD*,TERMINFO,TERMINFO_DIRS,TERMPATH,TERMCAP,ENV,BASH_ENV,PS4,GLOBIGNORE,BASHOPTS,SHELLOPTS,JAVA_TOOL_OPTIONS,PERLIO_DEBUG,PERLLIB,PERL5LIB,PERL5OPT,PERL5DB,FPATH,NULLCMD,READNULLCMD,ZDOTDIR,TMPPREFIX,PYTHONHOME,PYTHONPATH,PYTHONINSPECT,PYTHONUSERBASE,RUBYLIB,RUBYOPT" RAR_ENV_SET_LIST = "" RAR_ENV_OVERRIDE_BEHAVIOR = "false" RAR_AUTHENTICATION = "perform" @@ -83,6 +87,7 @@ Malformed values strings create compile-time errors. | `RAR_WORKDIR_ADD_LIST`| Comma-separated (`,`) | Allowed system directories where users are explicitly authorized to execute commands. | | `RAR_WORKDIR_REMOVE_LIST`| Comma-separated (`,`) | Denylist of forbidden working directories where execution requests are rejected. | | `RAR_WORKDIR_FALLBACK`| Optional File Path | Forces a static fallback working directory for the executed command if not defined. Warning! This will *always* set the working directory for *every* commands. | +| `RAR_TTY_OWNER` | String (`"caller"` \| `"target"`) | Determines whether the controlling terminal of the executed command is owned by the calling user or the target user. Note that sudo uses the "target" option by default, but a safer approach is to use "caller", so RaR uses "caller" by default. |
Be very careful with PATH management, "keep-unsafe" is insecure. And I strongly not reccommend it.

Working directory management is somewhat useful for some cases, such as allowing restricted commands only in a specific restricted directory. Of course that means that allowed commands shouldn't change internally the working directory. For example, if you set "/bin/ls" command, and setting fallback workdir to the "/tmp", then all "dosr ls" command will only show "/tmp" content, no matter where the user is located. Otherwise, if you set "/bin/ls .*" for the command, then the user still be able to retrieve folder contents everywhere on the system. @@ -101,6 +106,8 @@ Be very careful with PATH management, "keep-unsafe" is insecure. And I strongly | `RAR_TIMEOUT_MAX_USAGE`| Integer (`u64`) | Absolute limits on how many times a given token context can be reused. | | `RAR_TIMEOUT_STORAGE` | Folder Path | Filesystem path used by the timeout engine to persist execution timestamp tokens. | | `RAR_EXEC_INFO_DISPLAY`| String (`"hide"` \| `"show"`) | Give the ability to users to know their allowed commands and configuration applied. | +| `RAR_PASSWORD_TRIES` | Integer (`u8`) | Maximum number of password attempts allowed before aborting the authentication sequence. | +| `RAR_PASSWORD_DELAY` | Duration `"hh:mm:ss"` | The time to wait between password attempts. |
Showing to a user their policy can help attackers in their enumeration phase. A good policy knows what command a user is allowed to do in a such way that the user don't know that there is a strict policy applied. diff --git a/book/src/install/installation.md b/book/src/install/installation.md index 1fecb555..89eb2915 100644 --- a/book/src/install/installation.md +++ b/book/src/install/installation.md @@ -32,6 +32,25 @@ This is a complete process for compiling and installing `dosr` and `chsr` binari - Rust toolchain - Administrative rights (`sudo` or equivalent) +## Dependencies installation + +Since 4.2.0, we removed the auto dependencies installation through `cargo xtask` because it takes responsibilities that are not the purpose of the project. So we now require you to install dependencies yourself: + +| Distribution | Dependencies | +|---|---| +| Debian, Ubuntu | `apt install clang pkg-config libpam0g-dev libseccomp-dev pandoc` | +| Fedora, RHEL family | `dnf install clang pkgconfig pam-devel libseccomp-devel pandoc` | +| openSUSE | `zypper install clang pkg-config pam-devel libseccomp-devel pandoc` | +| Arch Linux | `pacman -S clang pkgconf pam libseccomp pandoc-cli` | + +Note that `clang` is selected in the case you want to compile the capable tool. If you prefer using `gcc`, feel free to use it instead. + +Also, `pandoc` is only required if you want to build the man pages from source. + +If you are using another distribution, you can find their names on these websites [pkgs.org](https://pkgs.org/) or [repology.org](https://repology.org/). + +If you miss any dependency, you can use the `cargo xtask doctor` command. + ### Retrieve the source code ```bash @@ -39,7 +58,7 @@ git clone https://github.com/LeChatP/RootAsRole cd RootAsRole ``` -## Dependencies installation, compiling and installing +## Compiling and installing Before installing the tool, you might want to define some default behaviors. You'll find them in this page [Build-time default configuration](build.md). @@ -48,12 +67,11 @@ It might be trivial, but in order to install RootAsRole, you needs full administ To manage that, we developed an installation helper tool ```bash -cargo xtask install -bip sudo +cargo xtask install -bp sudo ``` This command performs: -- dependency installation when required (privileged) - project build - deployment of `dosr` and `chsr` in `RAR_BIN_PATH` (privileged) - capability/ownership setup for `dosr` (privileged) diff --git a/book/src/security.md b/book/src/security.md index 8b4b7696..6aae875b 100644 --- a/book/src/security.md +++ b/book/src/security.md @@ -14,7 +14,11 @@ In the RaR context, it describes the security mechanisms that secures the progra - Capability-oriented privilege model - Optional timestamp/timeout behavior - Immutable policy file workflow -- Hardened policy editor path in `chsr editor` (landlock/seccomp) +- Hardened policy editor path in `chsr editor` (landlock) + +
+The hardening of the policy editor is complex to do with a generic editor tool. For now, only landlock is enforced. We need to look at seccomp protection in the editor mode. +
## Operational safeguards diff --git a/core/src/database/actor.rs b/core/src/database/actor.rs index 9c9205ca..65795e5d 100644 --- a/core/src/database/actor.rs +++ b/core/src/database/actor.rs @@ -1,6 +1,9 @@ use std::{ borrow::Cow, + cell::RefCell, + collections::HashMap, fmt::{self, Display, Formatter, Write}, + thread::LocalKey, }; use bon::bon; @@ -77,17 +80,49 @@ impl SUserType { } } +thread_local! { + /// The ids of the names that this thread looked up. A policy names the same users and groups + /// in many of its tasks (root above all), and each lookup goes through NSS, which may query + /// systemd-userdb or a directory service: about 20 µs, for each task of a policy. A failed + /// lookup is not kept, so that it is seen each time. + static USER_IDS: RefCell>> = RefCell::new(HashMap::new()); + static GROUP_IDS: RefCell>> = RefCell::new(HashMap::new()); +} + +/// The id of `name` in `cache`, else from `lookup`, kept when the lookup succeeds. +fn cached_id( + cache: &'static LocalKey>>>, + name: &str, + lookup: impl FnOnce() -> nix::Result>, +) -> nix::Result> { + if let Some(id) = cache.with(|ids| ids.borrow().get(name).copied()) { + return Ok(id); + } + let id = lookup()?; + cache.with(|ids| ids.borrow_mut().insert(name.to_string(), id)); + Ok(id) +} + impl DUserType<'_> { - #[must_use] - pub fn fetch_id(&self) -> Option { + /// The uid of the actor, a name being looked up once per thread: `Ok(None)` when no user has + /// that name, an error when the lookup itself fails (a directory service that does not + /// answer...). + /// + /// # Errors + /// The error of `getpwnam_r(3)`. + pub fn try_fetch_id(&self) -> nix::Result> { match &self.0 { - DGenericActorType::Id(id) => Some(*id), - DGenericActorType::Name(name) => match User::from_name(name) { - Ok(Some(user)) => Some(user.uid.as_raw()), - _ => None, - }, + DGenericActorType::Id(id) => Ok(Some(*id)), + DGenericActorType::Name(name) => cached_id(&USER_IDS, name, || { + Ok(User::from_name(name)?.map(|user| user.uid.as_raw())) + }), } } + /// [`Self::try_fetch_id`], a failed lookup being no user. + #[must_use] + pub fn fetch_id(&self) -> Option { + self.try_fetch_id().ok().flatten() + } #[must_use] pub fn fetch_user(&self) -> Option { match &self.0 { @@ -154,16 +189,26 @@ impl SGroupType { } impl DGroupType<'_> { - #[must_use] - pub fn fetch_id(&self) -> Option { + /// The gid of the actor, a name being looked up once per thread: `Ok(None)` when no group has + /// that name, an error when the lookup itself fails (a directory service that does not + /// answer, a group whose members take more than the 1 MiB that nix allows for + /// `getgrnam_r(3)`...). + /// + /// # Errors + /// The error of `getgrnam_r(3)`. + pub fn try_fetch_id(&self) -> nix::Result> { match &self.0 { - DGenericActorType::Id(id) => Some(*id), - DGenericActorType::Name(name) => match Group::from_name(name) { - Ok(Some(group)) => Some(group.gid.as_raw()), - _ => None, - }, + DGenericActorType::Id(id) => Ok(Some(*id)), + DGenericActorType::Name(name) => cached_id(&GROUP_IDS, name, || { + Ok(Group::from_name(name)?.map(|group| group.gid.as_raw())) + }), } } + /// [`Self::try_fetch_id`], a failed lookup being no group. + #[must_use] + pub fn fetch_id(&self) -> Option { + self.try_fetch_id().ok().flatten() + } #[must_use] pub fn fetch_group(&self) -> Option { match &self.0 { @@ -1325,4 +1370,28 @@ mod tests { DActor::Unknown(_) )); } + + // a name is looked up once per thread, and a name that nobody has is kept too + #[test] + fn test_name_lookups_are_kept() { + let root = DUserType::from("root"); + assert_eq!(root.try_fetch_id().unwrap(), Some(0)); + assert_eq!( + USER_IDS.with(|ids| ids.borrow().get("root").copied()), + Some(Some(0)) + ); + assert_eq!(root.try_fetch_id().unwrap(), Some(0)); + assert_eq!(DUserType::from("rar-no-such-user").fetch_id(), None); + assert_eq!( + USER_IDS.with(|ids| ids.borrow().get("rar-no-such-user").copied()), + Some(None) + ); + assert_eq!(DGroupType::from("root").try_fetch_id().unwrap(), Some(0)); + assert_eq!( + GROUP_IDS.with(|ids| ids.borrow().get("root").copied()), + Some(Some(0)) + ); + // an id is never looked up + assert_eq!(DUserType::from(42).try_fetch_id().unwrap(), Some(42)); + } } diff --git a/core/src/database/command.rs b/core/src/database/command.rs new file mode 100644 index 00000000..64540af5 --- /dev/null +++ b/core/src/database/command.rs @@ -0,0 +1,160 @@ +//! How a command of the policy is written: its path, then its arguments, which may be a +//! regular expression. dosr matches the commands and chsr writes them, both read them here. + +/// The path of a command of the policy and its arguments, both as written. +/// +/// The path keeps its quotes, and a regular expression must not go through shell splitting, +/// which removes its backslashes and splits it on its spaces. +#[must_use] +pub fn split_command(line: &str) -> Option<(&str, &str)> { + let line = line.trim_start(); + let first = line.chars().next()?; + let path_end = if first == '\'' || first == '"' { + line[1..].find(first)? + 2 + } else { + line.find(char::is_whitespace).unwrap_or(line.len()) + }; + Some((&line[..path_end], &line[path_end..])) +} + +/// The regular expression of the arguments of a command of the policy, as written after its +/// path: arguments starting with `^` and ending with `$`, single-quoted or not. +#[must_use] +pub fn regex_arguments(args: &str) -> Option<&str> { + let args = args.trim(); + let args = args + .strip_prefix('\'') + .and_then(|quoted| quoted.strip_suffix('\'')) + .filter(|quoted| quoted.starts_with('^') && quoted.ends_with('$')) + .unwrap_or(args); + (args.len() >= 2 && args.starts_with('^') && args.ends_with('$')).then_some(args) +} + +/// The body of the regular expression of the arguments, between its `^` and its final `$`. A +/// final `\$` is a dollar sign, not the end of the arguments, and stays in the body. +#[must_use] +pub fn regex_body(regex: &str) -> &str { + let body = regex.strip_prefix('^').unwrap_or(regex); + match body.strip_suffix('$') { + Some(stripped) if !ends_with_an_escape(stripped) => stripped, + _ => body, + } +} + +/// The regular expression of the arguments, anchored at both ends whatever its alternations. +/// +/// `^start|stop$` alone is `^start` or `stop$`, which allows any arguments that start with +/// `start` or end with `stop`. +#[must_use] +pub fn anchored_regex(regex: &str) -> String { + format!("^(?:{})$", regex_body(regex)) +} + +/// Whether `body` has a `|` outside of any group and character class. +/// +/// It is the alternation that `^…$` does not anchor on both sides. +#[must_use] +pub fn has_top_level_alternation(body: &str) -> bool { + let (mut groups, mut classes) = (0usize, 0usize); + let mut chars = body.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => { + chars.next(); + } + '[' => { + classes += 1; + // a `]` right after `[` or `[^` is a bracket, not the end of the class + chars.next_if_eq(&'^'); + chars.next_if_eq(&']'); + } + ']' if classes > 0 => classes -= 1, + '(' if classes == 0 => groups += 1, + ')' if classes == 0 => groups = groups.saturating_sub(1), + '|' if classes == 0 && groups == 0 => return true, + _ => {} + } + } + false +} + +fn ends_with_an_escape(text: &str) -> bool { + text.chars().rev().take_while(|&c| c == '\\').count() % 2 == 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_command() { + assert_eq!( + split_command("/usr/bin/tail ^-n [0-9]+$"), + Some(("/usr/bin/tail", " ^-n [0-9]+$")) + ); + assert_eq!( + split_command(" '/opt/my tool' -v"), + Some(("'/opt/my tool'", " -v")) + ); + assert_eq!( + split_command("\"/opt/my tool\""), + Some(("\"/opt/my tool\"", "")) + ); + assert_eq!(split_command("/usr/bin/id"), Some(("/usr/bin/id", ""))); + assert_eq!(split_command(""), None); + // an unterminated quote is not a path + assert_eq!(split_command("'/usr/bin/id"), None); + } + + #[test] + fn test_anchored_regex() { + assert_eq!(anchored_regex("^start|stop$"), "^(?:start|stop)$"); + assert_eq!(anchored_regex("^-n [0-9]+$"), "^(?:-n [0-9]+)$"); + assert_eq!(anchored_regex("^$"), "^(?:)$"); + // an escaped dollar sign is part of the body + assert_eq!(regex_body("^price \\$"), "price \\$"); + assert_eq!(anchored_regex("^price \\$"), "^(?:price \\$)$"); + // an escaped backslash before the final $ does not escape it + assert_eq!(regex_body("^a\\\\$"), "a\\\\"); + } + + #[test] + fn test_has_top_level_alternation() { + for body in [ + "start|stop", + "(?i)start|stop", + "a(b|c)|d", + "[a-z]|x", + "\\(|x", + ] { + assert!(has_top_level_alternation(body), "{body}"); + } + for body in [ + "(start|stop)", + "(?:start|stop) now", + "[|]", + "[]|]", + "[^]|]", + "[[:alpha:]|]+", + "a\\|b", + "-n [0-9]+", + "", + ] { + assert!(!has_top_level_alternation(body), "{body}"); + } + } + + #[test] + fn test_regex_arguments() { + assert_eq!( + regex_arguments(" ^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$"), + Some("^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$") + ); + assert_eq!(regex_arguments(" '^-f a\\.log$' "), Some("^-f a\\.log$")); + assert_eq!(regex_arguments("^$"), Some("^$")); + assert_eq!(regex_arguments(" --no-pager -u caddy"), None); + assert_eq!(regex_arguments("^"), None); + // what chsr wrote before 4.2.0: every word quoted on its own + assert_eq!(regex_arguments(" '^(-n' '[0-9]+|-f)'"), None); + } +} diff --git a/core/src/database/de.rs b/core/src/database/de.rs index 960ad3c2..f2336527 100644 --- a/core/src/database/de.rs +++ b/core/src/database/de.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, de::DeserializeSeed}; use crate::database::{ actor::SGroups, - structs::{SCommand, SSetgidSet, SetBehavior}, + structs::{GroupsFrom, SCommand, SSetgidSet, SetBehavior}, }; use super::{ @@ -294,6 +294,8 @@ impl<'de> Deserialize<'de> for SSetgidSet { Add, #[serde(alias = "del", alias = "s")] Sub, + #[serde(alias = "o")] + From, } impl<'de> Visitor<'de> for SSetgidSetVisitor { @@ -311,12 +313,16 @@ impl<'de> Deserialize<'de> for SSetgidSet { let mut add = Vec::new(); let mut sub = Vec::new(); let mut fallback = None; + let mut from = None; while let Some(key) = map.next_key::()? { match key { Field::Default => { default_behavior = map.next_value()?; } + Field::From => { + from = Some(map.next_value::()?); + } Field::Fallback => { fallback.replace(map.next_value::()?); } @@ -335,6 +341,7 @@ impl<'de> Deserialize<'de> for SSetgidSet { fallback, add, sub, + from, }) } } @@ -639,6 +646,41 @@ mod tests { } } + #[test] + fn test_s_setgid_set_from() { + // alone, from says whose groups the command gets + let set: SSetgidSet = serde_json::from_value(json!({"from": "caller"})).unwrap(); + assert_eq!(set.default_behavior, SetBehavior::None); + assert_eq!(set.from, Some(GroupsFrom::Caller)); + assert_eq!( + serde_json::to_value(&set).unwrap(), + json!({"default": "none", "from": "caller"}) + ); + for json_data in [ + json!({"from": "target"}), + json!({"default": "none", "add": [["adm"]], "from": "caller"}), + // a fallback with from is not written as a mandatory group + json!({"default": "none", "fallback": ["adm"], "from": "caller"}), + ] { + let set: SSetgidSet = serde_json::from_value(json_data.clone()).unwrap(); + assert!(set.from.is_some(), "{json_data}"); + let again: SSetgidSet = + serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap(); + assert_eq!(again, set, "{json_data}"); + let cbor = cbor4ii::serde::to_vec(Vec::new(), &set).unwrap(); + let again: SSetgidSet = cbor4ii::serde::from_slice(&cbor).unwrap(); + assert_eq!(again, set, "{json_data}"); + } + // without from, nothing is written + let set: SSetgidSet = serde_json::from_value(json!({"default": "all"})).unwrap(); + assert_eq!(set.from, None); + assert_eq!( + serde_json::to_value(&set).unwrap(), + json!({"default": "all"}) + ); + assert!(serde_json::from_value::(json!({"from": "someone"})).is_err()); + } + #[test] fn test_invalid_selector_entries_are_errors() { // the policy comes from a file: a mistake is an error, never a panic diff --git a/core/src/database/env.rs b/core/src/database/env.rs index 22ca8eea..879d8065 100644 --- a/core/src/database/env.rs +++ b/core/src/database/env.rs @@ -1,10 +1,15 @@ //! How the environment of a command is decided, for dosr and chsr alike. //! //! The env options of the default, global, role and task levels are merged into one set of -//! lists ([`ResolvedEnv`]), then the variables of the user are filtered with them. +//! lists ([`ResolvedEnv`]), then the variables of the user are filtered with them. The same +//! filter ([`EnvFilter`]) applies to the variables that PAM modules set, such as `pam_env`. //! See [`SEnvOptions`](super::options::SEnvOptions) for the meaning of each list. -use std::collections::HashMap; +use std::{ + borrow::Borrow, + collections::{HashMap, hash_map::Entry}, + hash::Hash, +}; use indexmap::{IndexMap, IndexSet}; use libc::PATH_MAX; @@ -112,22 +117,25 @@ impl<'a> ResolvedEnv<'a> { /// - in both modes, a variable of the check list is passed only if its value is safe. #[must_use] pub fn keep_user_var(&self, key: &str, value: &str) -> bool { - if is_shell_function(value) { - return false; - } - let checked = env_matches(&self.check, key); - match self.behavior { - EnvBehavior::Delete => { - if checked { - check_env(key, value) - } else { - env_matches(&self.keep, key) - } - } - EnvBehavior::Keep => { - !env_matches(&self.delete, key) && (!checked || check_env(key, value)) - } - EnvBehavior::Inherit => false, + keeps_var( + self.behavior, + &self.keep, + &self.check, + &self.delete, + key, + value, + ) + } + + /// The same filter as [`ResolvedEnv::keep_user_var`], owned: it outlives the policy. + #[must_use] + pub fn filter(&self) -> EnvFilter { + let owned = |list: &IndexSet<&str>| list.iter().map(|entry| (*entry).to_string()).collect(); + EnvFilter { + behavior: self.behavior, + keep: owned(&self.keep), + check: owned(&self.check), + delete: owned(&self.delete), } } @@ -154,13 +162,19 @@ impl<'a> ResolvedEnv<'a> { }) .collect::>(); let path: Vec<_> = path.into_iter().collect(); - final_env.insert( - "PATH".into(), - path.iter() - .map(AsRef::as_ref) - .collect::>() - .join(":"), - ); + if path.is_empty() { + // An empty PATH is the working directory for shells and execvp(3): without any + // directory, the command has no PATH, and its own default applies. + final_env.remove("PATH"); + } else { + final_env.insert( + "PATH".into(), + path.iter() + .map(AsRef::as_ref) + .collect::>() + .join(":"), + ); + } let target_user = target.unwrap_or(¤t_user.user); final_env.insert("LOGNAME".into(), target_user.name.clone()); final_env.insert("USER".into(), target_user.name.clone()); @@ -189,6 +203,89 @@ impl<'a> ResolvedEnv<'a> { } } +/// The filter of the policy on the variables passed to the command, once the policy is gone. +/// +/// dosr decides the environment of the command before the PAM session is open; the variables +/// that PAM modules set then (`pam_env`, `pam_systemd`, `pam_getenvlist(3)`) go through this +/// filter, like the variables of the user: they may come from files that the user writes, such +/// as `~/.pam_environment` with `pam_env user_readenv=1`. The default filter keeps nothing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EnvFilter { + behavior: EnvBehavior, + keep: IndexSet, + check: IndexSet, + delete: IndexSet, +} + +impl EnvFilter { + /// Whether a variable is passed to the command, see [`ResolvedEnv::keep_user_var`]. + #[must_use] + pub fn keeps(&self, key: &str, value: &str) -> bool { + keeps_var( + self.behavior, + &self.keep, + &self.check, + &self.delete, + key, + value, + ) + } + + /// Add to the environment of the command the variables that the filter keeps, unless the + /// environment already has them: the kept variables of the user, the variables set by dosr + /// and the `set` list of the policy take precedence. `PATH` is never added: the path options + /// decide it, and leave it out when no directory remains. + pub fn add_vars( + &self, + env: &mut HashMap, + vars: impl IntoIterator, + ) { + for (key, value) in vars { + // the path options decide the PATH, which may be absent on purpose + if key == "PATH" { + debug!("PATH is decided by the path options, the one of PAM is left out"); + continue; + } + match env.entry(key) { + Entry::Occupied(var) => { + debug!("{} is already set, its other value is left out", var.key()); + } + Entry::Vacant(var) if self.keeps(var.key(), &value) => { + var.insert(value); + } + Entry::Vacant(var) => debug!("{} is left out by the policy", var.key()), + } + } + } +} + +/// The rule of [`ResolvedEnv::keep_user_var`] and [`EnvFilter::keeps`], whether the lists are +/// borrowed from the policy or owned. +fn keeps_var + Hash + Eq>( + behavior: EnvBehavior, + keep: &IndexSet, + check: &IndexSet, + delete: &IndexSet, + key: &str, + value: &str, +) -> bool { + if is_shell_function(value) { + return false; + } + let checked = env_matches(check, key); + match behavior { + EnvBehavior::Delete => { + if checked { + check_env(key, value) + } else { + env_matches(keep, key) + } + } + EnvBehavior::Keep => !env_matches(delete, key) && (!checked || check_env(key, value)), + EnvBehavior::Inherit => false, + } +} + /// Whether the value of a variable of the check list is safe: no `/` nor `%`, except for `TZ` /// which must be a relative time zone name. #[must_use] @@ -201,8 +298,11 @@ pub fn check_env(key: &str, value: &str) -> bool { } /// The policy entries are the patterns, never the user's variable name. -fn env_matches(list: &IndexSet<&str>, name: &str) -> bool { - list.contains(name) || list.iter().any(|pattern| wildcard_match(pattern, name)) +fn env_matches + Hash + Eq>(list: &IndexSet, name: &str) -> bool { + list.contains(name) + || list + .iter() + .any(|pattern| wildcard_match(pattern.borrow(), name)) } fn tz_is_safe(tzval: &str) -> bool { @@ -530,4 +630,95 @@ mod tests { .is_none() ); } + + #[test] + fn test_env_filter_is_the_policy_filter() { + let vars = [ + ("LC_ALL", "C"), + ("KEPT", "value"), + ("LD_PRELOAD", "/tmp/evil.so"), + ("CHECKED", "/unsafe"), + ("CHECKED", "safe"), + ("TZ", "../../etc/x"), + ("TZ", "Europe/Paris"), + ("BASH_FUNC_ls%%", "() { evil; }"), + ("OTHER", "value"), + ]; + for behavior in [EnvBehavior::Delete, EnvBehavior::Keep, EnvBehavior::Inherit] { + let env = resolved(behavior, &["LC_*", "KEPT"], &["CHECKED", "TZ"], &["LD_*"]); + let filter = env.filter(); + for (key, value) in vars { + assert_eq!( + filter.keeps(key, value), + env.keep_user_var(key, value), + "{behavior:?} {key}={value}" + ); + } + } + // a filter that no policy decided keeps nothing + assert!(!EnvFilter::default().keeps("LC_ALL", "C")); + } + + #[test] + fn test_env_filter_add_vars() { + let target = cred(); + let mut env = resolved(EnvBehavior::Delete, &["KEPT", "PAM_*"], &["CHECKED"], &[]); + env.set = IndexMap::from([("SET", "policy")]); + let mut final_env = env + .final_env( + [("KEPT", "user")], + ["/usr/bin"], + &target, + None, + String::new(), + ) + .unwrap(); + let pam_vars = [ + ("PATH", "/tmp/evil"), + ("HOME", "/tmp"), + ("SET", "pam"), + ("KEPT", "pam"), + ("PAM_NEW", "pam"), + ("CHECKED", "/unsafe"), + ("LD_PRELOAD", "/tmp/evil.so"), + ("PAM_FUNC", "() { evil; }"), + ] + .map(|(key, value)| (key.to_string(), value.to_string())); + env.filter().add_vars(&mut final_env, pam_vars); + // the variables of dosr, of the set list and the kept ones of the user take precedence + assert_eq!(final_env.get("PATH").unwrap(), "/usr/bin"); + assert_eq!( + *final_env.get("HOME").unwrap(), + target.user.dir.to_string_lossy() + ); + assert_eq!(final_env.get("SET").unwrap(), "policy"); + assert_eq!(final_env.get("KEPT").unwrap(), "user"); + // a new variable goes through the filter of the policy + assert_eq!(final_env.get("PAM_NEW").unwrap(), "pam"); + assert!(!final_env.contains_key("CHECKED")); + assert!(!final_env.contains_key("LD_PRELOAD")); + assert!(!final_env.contains_key("PAM_FUNC")); + } + + #[test] + fn test_final_env_without_any_directory_has_no_path() { + let target = cred(); + // even a PATH of the user that the policy keeps by name, or one set by PAM + let env = resolved(EnvBehavior::Delete, &["PATH"], &[], &[]); + let no_dir: [&str; 0] = []; + let mut final_env = env + .final_env([("PATH", ".:/tmp")], no_dir, &target, None, String::new()) + .unwrap(); + assert!(!final_env.contains_key("PATH")); + env.filter() + .add_vars(&mut final_env, [("PATH".into(), ".".into())]); + assert!(!final_env.contains_key("PATH")); + // the set list of the policy can still give one + let mut env = env; + env.set = IndexMap::from([("PATH", "/usr/bin")]); + let final_env = env + .final_env([("PATH", ".:/tmp")], no_dir, &target, None, String::new()) + .unwrap(); + assert_eq!(final_env.get("PATH").unwrap(), "/usr/bin"); + } } diff --git a/core/src/database/migrations.rs b/core/src/database/migrations.rs index 15211d1d..4d3b8c79 100644 --- a/core/src/database/migrations.rs +++ b/core/src/database/migrations.rs @@ -11,7 +11,12 @@ use serde_json::Value; use crate::{ PACKAGE_VERSION, - database::migration::{Migration, MigrationError}, + database::{ + command::{ + anchored_regex, has_top_level_alternation, regex_arguments, regex_body, split_command, + }, + migration::{Migration, MigrationError}, + }, util::{ENV_DELETE_LIST_SLICE, is_valid_env_pattern}, }; @@ -69,6 +74,17 @@ pub const DOCUMENT_MIGRATIONS: &[Migration] = &[ Ok(()) }, }, + Migration { + from: || Version::new(4, 1, 0), + to: || Version::new(4, 2, 0), + up: unchanged, + down: |_, document| { + remove_options_of_4_2(document); + remove_groups_from(document); + rewrite_commands(document, group_top_level_alternation); + Ok(()) + }, + }, ]; /// `LC_.*` -> `LC_*`: the simple regexes used before 4.1.0 become wildcards. @@ -165,6 +181,84 @@ fn rewrite_path_behaviors(document: &mut Value, rewrite: fn(&str) -> Option<&'st } } +/// 4.1.0 has neither the `password` nor the `tty` option: they are removed from the options of +/// every level. Without `tty`, 4.1.0 leaves the terminal to the caller, as `"owner": "caller"`. +fn remove_options_of_4_2(document: &mut Value) { + match document { + Value::Object(map) => { + if let Some(Value::Object(options)) = map.get_mut("options") { + for name in ["password", "tty"] { + if options.remove(name).is_some() { + info!("Removing the {name} option, unknown before 4.2.0"); + } + } + } + map.values_mut().for_each(remove_options_of_4_2); + } + Value::Array(values) => values.iter_mut().for_each(remove_options_of_4_2), + _ => {} + } +} + +/// `from` of a `setgid` selector is unknown before 4.2.0, whatever its key (the CBOR format writes +/// `o`). A selector with nothing else than `"default": "none"` is removed with it: 4.1.0 then +/// keeps the groups of the caller, as `from: caller` did, instead of requiring `-g`. 4.1.0 cannot +/// give the groups of the target user, which `from: target` and a task without `setgid` do. +fn remove_groups_from(document: &mut Value) { + let Value::Object(document) = document else { + return; + }; + for roles in ["roles", "r"] { + let Some(Value::Array(roles)) = document.get_mut(roles) else { + continue; + }; + for role in roles { + for tasks in ["tasks", "t"] { + let Some(Value::Array(tasks)) = role.get_mut(tasks) else { + continue; + }; + for task in tasks { + for cred in ["cred", "credentials", "i"] { + if let Some(Value::Object(cred)) = task.get_mut(cred) { + remove_cred_groups_from(cred); + } + } + } + } + } + } +} + +fn remove_cred_groups_from(cred: &mut serde_json::Map) { + for setgid in ["setgid", "g"] { + let Some(Value::Object(selector)) = cred.get_mut(setgid) else { + continue; + }; + let Some(from) = selector.remove("from").or_else(|| selector.remove("o")) else { + continue; + }; + info!("Removing from: {from} of a setgid selector, unknown before 4.2.0"); + let nothing_else = selector.iter().all(|(key, value)| { + matches!(key.as_str(), "default" | "d") && (value == "none" || value == 0) + }); + if nothing_else { + cred.remove(setgid); + } + } +} + +/// A top-level alternation grouped for 4.1.0, which allows no more than 4.2.0. +/// +/// Since 4.2.0, a regular expression on the arguments matches the whole arguments whatever its +/// alternations, where 4.1.0 read `^start|stop$` as `^start` or `stop$`. The upgrade leaves it as +/// written, since it now allows less; the downgrade writes `^(?:start|stop)$`. +fn group_top_level_alternation(command: &str) -> Option { + let (path, args) = split_command(command)?; + let regex = regex_arguments(args)?; + has_top_level_alternation(regex_body(regex)) + .then(|| format!("{path} {}", anchored_regex(regex))) +} + /// Whether a command of the policy is only a path, without arguments. fn path_only(command: &str) -> bool { let command = command.trim(); @@ -537,7 +631,7 @@ mod tests { fn test_commands_without_arguments_up_and_down() { // the upgrade leaves them as written: since 4.1.0 they allow no argument let commands = json!(["/usr/bin/id", "**", "/usr/bin/ls ^.*$"]); - let mut document = json!({"roles": [{"tasks": [{"commands": commands.clone()}]}]}); + let mut document = json!({"roles": [{"tasks": [{"commands": commands}]}]}); Migration::migrate_from( &Version::new(4, 0, 3), &PACKAGE_VERSION, @@ -587,6 +681,225 @@ mod tests { assert_eq!(document["r"][0]["t"][0]["c"], json!(["/usr/bin/id ^$"])); } + #[test] + fn test_password_option_up_and_down() { + let mut document = json!({ + "options": {"password": {"tries": 5}, "timeout": {"type": "tty"}}, + "roles": [{ + "name": "r", + "options": {"password": {"delay": "00:00:01"}}, + "tasks": [{"name": "t", "options": {"password": {"tries": 1}}}] + }] + }); + let before = document.clone(); + Migration::migrate_from( + &Version::new(4, 1, 0), + &PACKAGE_VERSION, + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document, before, "the upgrade keeps the option"); + + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document["options"], json!({"timeout": {"type": "tty"}})); + assert_eq!(document["roles"][0]["options"], json!({})); + assert_eq!(document["roles"][0]["tasks"][0]["options"], json!({})); + } + + #[test] + fn test_tty_option_up_and_down() { + let mut document = json!({ + "options": {"tty": {"owner": "target"}, "timeout": {"type": "tty"}}, + "roles": [{ + "name": "r", + "options": {"tty": {"owner": "caller"}}, + "tasks": [{"name": "t", "options": {"tty": {"owner": "target"}, "umask": "022"}}] + }] + }); + let before = document.clone(); + Migration::migrate_from( + &Version::new(4, 1, 0), + &PACKAGE_VERSION, + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document, before, "the upgrade keeps the option"); + + // 4.1.0 always leaves the terminal to the caller + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document["options"], json!({"timeout": {"type": "tty"}})); + assert_eq!(document["roles"][0]["options"], json!({})); + assert_eq!( + document["roles"][0]["tasks"][0]["options"], + json!({"umask": "022"}) + ); + } + + #[test] + fn test_groups_from_is_removed_down() { + let task = |cred: Value| json!({"name": "t", "cred": cred}); + let mut document = json!({ + "roles": [{ + "name": "r", + "tasks": [ + task(json!({"setuid": "nobody", "setgid": {"from": "caller"}})), + task(json!({"setgid": {"default": "none", "add": [["adm"]], "from": "caller"}})), + task(json!({"setgid": {"default": "all", "from": "target"}})), + task(json!({"setgid": {"default": "none", "fallback": ["adm"], "from": "target"}})), + task(json!({"setgid": "adm"})) + ] + }, { + // the short keys of the CBOR format + "name": "c", + "t": [{"name": "t", "i": {"g": {"d": 0, "o": "caller"}}}] + }] + }); + let before = document.clone(); + Migration::migrate_from( + &Version::new(4, 1, 0), + &PACKAGE_VERSION, + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document, before, "the upgrade writes no from"); + + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + let creds: Vec<&Value> = document["roles"][0]["tasks"] + .as_array() + .unwrap() + .iter() + .map(|task| &task["cred"]) + .collect(); + // nothing else than from: no setgid, 4.1.0 keeps the groups of the caller + assert_eq!(creds[0], &json!({"setuid": "nobody"})); + assert_eq!( + creds[1], + &json!({"setgid": {"default": "none", "add": [["adm"]]}}) + ); + assert_eq!(creds[2], &json!({"setgid": {"default": "all"}})); + assert_eq!( + creds[3], + &json!({"setgid": {"default": "none", "fallback": ["adm"]}}) + ); + assert_eq!(creds[4], &json!({"setgid": "adm"})); + assert_eq!(document["roles"][1]["t"][0]["i"], json!({})); + } + + #[test] + fn test_top_level_alternation_is_grouped_down() { + let mut document = json!({ + "roles": [{ + "name": "r", + "tasks": [{ + "name": "t", + "commands": [ + "/usr/bin/systemctl ^start|stop$", + "/usr/bin/systemctl '^start|restart$'", + "/usr/bin/systemctl ^(start|stop) caddy$", + "/usr/bin/tail ^-n [0-9]+$", + "/usr/bin/apt update" + ] + }, { + "name": "selector", + "commands": {"default": "none", "del": ["/usr/bin/kill ^-9|-KILL$"]} + }] + }, { + // the short keys of the CBOR format + "name": "c", + "t": [{"name": "t", "c": ["/usr/bin/systemctl ^a|b$"]}] + }] + }); + let before = document.clone(); + Migration::migrate_from( + &Version::new(4, 1, 0), + &PACKAGE_VERSION, + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!( + document, before, + "the upgrade leaves the commands as written" + ); + + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!( + document["roles"][0]["tasks"][0]["commands"], + json!([ + "/usr/bin/systemctl ^(?:start|stop)$", + "/usr/bin/systemctl ^(?:start|restart)$", + "/usr/bin/systemctl ^(start|stop) caddy$", + "/usr/bin/tail ^-n [0-9]+$", + "/usr/bin/apt update" + ]) + ); + assert_eq!( + document["roles"][0]["tasks"][1]["commands"]["del"], + json!(["/usr/bin/kill ^(?:-9|-KILL)$"]) + ); + assert_eq!( + document["roles"][1]["t"][0]["c"], + json!(["/usr/bin/systemctl ^(?:a|b)$"]) + ); + } + + #[test] + fn test_4_1_0_documents_up_and_down() { + let original = json!({ + "version": "4.1.0", + "options": {"env": {"default": "delete", "check": ["LC_*"]}, "path": {"default": "keep-safe"}}, + "roles": [{"name": "r", "tasks": [{"name": "t", "commands": ["/usr/bin/ls ^.*$"]}]}] + }); + let mut document = original.clone(); + let migration = migrate_document(&mut document).unwrap().unwrap(); + assert_eq!(migration.from, Version::new(4, 1, 0)); + assert_eq!(document["version"], PACKAGE_VERSION.to_string()); + let mut unchanged = original.clone(); + unchanged["version"] = document["version"].clone(); + assert_eq!( + document, unchanged, + "4.1.0 to 4.2.0 changes nothing but the version" + ); + + Migration::migrate_from( + &PACKAGE_VERSION, + &Version::new(4, 1, 0), + &mut document, + DOCUMENT_MIGRATIONS, + ) + .unwrap(); + assert_eq!(document["version"], PACKAGE_VERSION.to_string()); + document["version"] = original["version"].clone(); + assert_eq!(document, original); + } + #[test] fn test_path_behaviors_up_and_down() { let mut document = json!({ diff --git a/core/src/database/mod.rs b/core/src/database/mod.rs index eb04fb7a..9adfadbb 100644 --- a/core/src/database/mod.rs +++ b/core/src/database/mod.rs @@ -12,6 +12,7 @@ use self::options::EnvKey; pub mod score; pub mod actor; +pub mod command; pub mod de; pub mod env; pub mod migration; @@ -126,25 +127,26 @@ where } fn convert_string_to_duration(s: &str) -> Result, Box> { - let mut parts = s.split(':'); - //unwrap or error - if let (Some(hours), Some(minutes), Some(seconds)) = (parts.next(), parts.next(), parts.next()) - { - let hours: i64 = hours.parse()?; - let minutes: i64 = minutes.parse()?; - let seconds: i64 = seconds.parse()?; - if hours < 0 || minutes < 0 || seconds < 0 { - return Err("A duration cannot be negative".into()); + // hh:mm:ss, exactly three fields of digits: a sign is never valid, and would be lost on a + // zero field ("-00" reads as 0), and nothing may follow the seconds + let fields: Vec<&str> = s.split(':').collect(); + let [hours, minutes, seconds] = fields.as_slice() else { + return Err("Invalid duration format, expected hh:mm:ss".into()); + }; + let field = |field: &str| -> Result> { + if field.is_empty() || !field.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(format!("Invalid duration field `{field}`, expected digits").into()); } - // checked: a value of the policy must be an error, never a panic of the parser - let total = hours - .checked_mul(3600) - .and_then(|total| total.checked_add(minutes.checked_mul(60)?)) - .and_then(|total| total.checked_add(seconds)) - .ok_or("The duration is too long")?; - return Ok(Some(jiff::SignedDuration::from_secs(total))); - } - Err("Invalid duration format".into()) + Ok(field.parse()?) + }; + let (hours, minutes, seconds) = (field(hours)?, field(minutes)?, field(seconds)?); + // checked: a value of the policy must be an error, never a panic of the parser + let total = hours + .checked_mul(3600) + .and_then(|total| total.checked_add(minutes.checked_mul(60)?)) + .and_then(|total| total.checked_add(seconds)) + .ok_or("The duration is too long")?; + Ok(Some(jiff::SignedDuration::from_secs(total))) } #[cfg(test)] @@ -167,6 +169,20 @@ mod tests { ] { assert!(convert_string_to_duration(duration).is_err(), "{duration}"); } + // a sign is never valid, not even on a zero field where it would be lost, nor anything + // after the seconds + for duration in [ + "-00:00:01", + "+00:00:01", + "00:-0:01", + "00:05:00:00", + "00:05:00:junk", + "00::05", + "00:05", + "", + ] { + assert!(convert_string_to_duration(duration).is_err(), "{duration}"); + } } struct IndexSetTester(pub Option>); diff --git a/core/src/database/options.rs b/core/src/database/options.rs index 122c5d60..01021c6a 100644 --- a/core/src/database/options.rs +++ b/core/src/database/options.rs @@ -1,7 +1,7 @@ use core::panic; use std::borrow::Cow; use std::collections::HashMap; -use std::num::ParseIntError; +use std::num::{NonZeroU8, ParseIntError}; use std::result::Result; use std::str::FromStr; use std::{cell::RefCell, rc::Rc}; @@ -22,8 +22,8 @@ use crate::util::{ AUTHENTICATION, BOUNDING, ENV_CHECK_LIST, ENV_DEFAULT_BEHAVIOR, ENV_DELETE_LIST, ENV_KEEP_LIST, ENV_OVERRIDE_BEHAVIOR, ENV_PATH_ADD_LIST_SLICE, ENV_PATH_BEHAVIOR, ENV_PATH_REMOVE_LIST_SLICE, ENV_SET_LIST, HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_VALUE_2, - HARDENED_ENUM_VALUE_3, INFO, PRIVILEGED, TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, - UMASK, is_valid_env_name, is_valid_env_pattern, + HARDENED_ENUM_VALUE_3, INFO, PASSWORD_DELAY, PASSWORD_TRIES, PRIVILEGED, TIMEOUT_DURATION, + TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, TTY_OWNER, UMASK, is_valid_env_name, is_valid_env_pattern, }; use super::{ @@ -57,6 +57,8 @@ pub enum OptType { Root, Bounding, Timeout, + Password, + Tty, Authentication, ExecInfo, UMask, @@ -108,11 +110,95 @@ pub enum TimestampType { UID, } +/// How many times the password is asked, and how long dosr waits after a wrong one. +/// +/// A level that does not set a field inherits it from the upper levels, down to the build-time +/// defaults (`RAR_PASSWORD_TRIES`, `RAR_PASSWORD_DELAY`). `tries` is at least 1: 0 would never +/// let anyone in, or never stop asking. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default, Builder)] +pub struct SPassword { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tries: Option, + #[serde( + default, + serialize_with = "serialize_duration", + deserialize_with = "deserialize_duration", + skip_serializing_if = "Option::is_none" + )] + pub delay: Option, + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, +} + +/// Who owns the pseudo-terminal that dosr gives to the command. +/// +/// Its default is the build-time one (`RAR_TTY_OWNER`). +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy)] +#[serde(rename_all = "kebab-case")] +#[repr(u32)] +pub enum TtyOwner { + /// The user who runs dosr. The processes of the user that the command runs as cannot open + /// the terminal by its name: a program that does, such as pinentry through `GPG_TTY`, fails. + Caller = HARDENED_ENUM_VALUE_0, + /// The user that the command runs as, like sudo. Its other processes can then open the + /// terminal while the command runs, and read what is typed into it. + Target = HARDENED_ENUM_VALUE_1, +} + +impl Default for TtyOwner { + fn default() -> Self { + TTY_OWNER + } +} + +impl TtyOwner { + #[must_use] + /// # Panics + /// Panics if the input string does not match any of the valid ``TtyOwner`` variants. + pub const fn const_parse(input: &str) -> Self { + match input { + _ if eq_str(input, "caller") => Self::Caller, + _ if eq_str(input, "target") => Self::Target, + _ => panic!("fail to parse TtyOwner"), + } + } +} + +impl FromStr for TtyOwner { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "caller" => Ok(Self::Caller), + "target" => Ok(Self::Target), + _ => Err(format!( + "The owner of the terminal is caller or target: {s}" + )), + } + } +} + +/// The pseudo-terminal of the command, when dosr runs from a terminal. +/// +/// A level that does not set `owner` inherits it from the upper levels, down to the build-time +/// default (`RAR_TTY_OWNER`). +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default, Builder)] +pub struct STty { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, +} + #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default, Builder)] pub struct STimeout { #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] pub type_field: Option, + // `default`: with `deserialize_with`, serde requires the field, even an `Option` #[serde( + default, serialize_with = "serialize_duration", deserialize_with = "deserialize_duration", skip_serializing_if = "Option::is_none" @@ -148,6 +234,10 @@ pub struct SPathOptions { )] #[builder(with = |v : impl IntoIterator| { v.into_iter().map(|s| s.to_string()).collect() })] pub sub: Option>, + /// The fields that this version does not know, kept when the policy is saved again + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy)] @@ -362,6 +452,10 @@ pub struct SWorkdirSet { )] #[builder(with = |v : impl IntoIterator| { v.into_iter().map(|s| s.to_string()).collect() })] pub sub: Option>, + /// The fields that this version does not know, kept when the policy is saved again + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Copy)] #[serde(rename_all = "lowercase")] @@ -581,6 +675,10 @@ pub struct Opt { #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub umask: Option, #[serde(default, flatten)] pub extra_fields: Map, @@ -599,6 +697,8 @@ impl Opt { execinfo: Option, workdir: Option, timeout: Option, + password: Option, + tty: Option, umask: Option, #[builder(default)] extra_fields: Map, ) -> Self { @@ -612,6 +712,8 @@ impl Opt { execinfo, workdir, timeout, + password, + tty, umask, extra_fields, } @@ -649,6 +751,13 @@ impl Opt { .maybe_max_usage(TIMEOUT_MAX_USAGE) .build(), ) + .password( + SPassword::builder() + .tries(PASSWORD_TRIES) + .delay(PASSWORD_DELAY) + .build(), + ) + .tty(STty::builder().owner(TTY_OWNER).build()) .build() } } @@ -1092,6 +1201,38 @@ impl OptStack { .map(|timeout| (opt.level, timeout))) .map(|(_, timeout)| timeout), ) + .password( + SPassword::builder() + .maybe_tries( + self.find_in_options(|opt| opt + .password + .as_ref() + .and_then(|password| password.tries) + .map(|tries| (opt.level, tries))) + .map(|(_, tries)| tries) + ) + .maybe_delay( + self.find_in_options(|opt| opt + .password + .as_ref() + .and_then(|password| password.delay) + .map(|delay| (opt.level, delay))) + .map(|(_, delay)| delay) + ) + .build(), + ) + .tty( + STty::builder() + .maybe_owner( + self.find_in_options(|opt| opt + .tty + .as_ref() + .and_then(|tty| tty.owner) + .map(|owner| (opt.level, owner))) + .map(|(_, owner)| owner) + ) + .build(), + ) .build() )) } @@ -1873,4 +2014,126 @@ mod tests { // This should serialize as just a string, not as a struct assert_tokens(&umask, &[Token::Str("644")]); } + + #[test] + fn test_timeout_fields_are_optional() { + // every field may be left to the upper levels, the duration as the others + let timeout: STimeout = serde_json::from_str(r#"{"type": "tty"}"#).unwrap(); + assert_eq!(timeout.type_field, Some(TimestampType::TTY)); + assert_eq!(timeout.duration, None); + let timeout: STimeout = serde_json::from_str("{}").unwrap(); + assert_eq!(timeout, STimeout::default()); + let opt: Opt = serde_json::from_str(r#"{"timeout": {"max_usage": 2}}"#).unwrap(); + assert_eq!(opt.timeout.unwrap().max_usage, Some(2)); + } + + #[test] + fn test_password_option() { + let password: SPassword = + serde_json::from_str(r#"{"tries": 2, "delay": "00:00:01"}"#).unwrap(); + assert_eq!(password.tries, std::num::NonZeroU8::new(2)); + assert_eq!(password.delay, Some(SignedDuration::from_secs(1))); + assert_eq!( + serde_json::to_value(&password).unwrap(), + serde_json::json!({"tries": 2, "delay": "00:00:01"}) + ); + // 0 tries would never let anyone in, or never stop asking; a delay is never negative + for invalid in [ + r#"{"tries": 0}"#, + r#"{"tries": 256}"#, + r#"{"tries": -1}"#, + r#"{"delay": "-00:00:01"}"#, + r#"{"delay": "5s"}"#, + ] { + assert!( + serde_json::from_str::(invalid).is_err(), + "{invalid} is refused" + ); + } + let cbor = cbor4ii::serde::to_vec(Vec::new(), &password).unwrap(); + let from_cbor: SPassword = cbor4ii::serde::from_slice(&cbor).unwrap(); + assert_eq!(from_cbor, password); + } + + #[test] + fn test_password_defaults() { + let defaults = Opt::level_default().password.unwrap(); + assert_eq!(defaults.tries, Some(crate::util::PASSWORD_TRIES)); + assert_eq!(defaults.delay, Some(crate::util::PASSWORD_DELAY)); + assert_eq!(crate::util::PASSWORD_TRIES.get(), 3); + assert_eq!(crate::util::PASSWORD_DELAY, SignedDuration::from_secs(5)); + } + + #[test] + fn test_tty_option() { + let tty: STty = serde_json::from_str(r#"{"owner": "target"}"#).unwrap(); + assert_eq!(tty.owner, Some(TtyOwner::Target)); + assert_eq!( + serde_json::to_value(&tty).unwrap(), + serde_json::json!({"owner": "target"}) + ); + let tty: STty = serde_json::from_str(r#"{"owner": "caller", "future": 1}"#).unwrap(); + assert_eq!(tty.owner, Some(TtyOwner::Caller)); + assert_eq!(tty.extra_fields.len(), 1); + for invalid in [ + r#"{"owner": "root"}"#, + r#"{"owner": "Target"}"#, + r#"{"owner": 1}"#, + ] { + assert!( + serde_json::from_str::(invalid).is_err(), + "{invalid} is refused" + ); + } + let cbor = cbor4ii::serde::to_vec(Vec::new(), &tty).unwrap(); + let from_cbor: STty = cbor4ii::serde::from_slice(&cbor).unwrap(); + assert_eq!(from_cbor, tty); + assert_eq!("target".parse(), Ok(TtyOwner::Target)); + assert_eq!("caller".parse(), Ok(TtyOwner::Caller)); + assert!("unset".parse::().is_err()); + } + + // the terminal stays with the caller unless the policy or the build says otherwise + #[test] + fn test_tty_owner_default_and_inheritance() { + assert_eq!(crate::util::TTY_OWNER, TtyOwner::Caller); + assert_eq!(TtyOwner::default(), crate::util::TTY_OWNER); + assert_eq!( + Opt::level_default().tty.unwrap().owner, + Some(crate::util::TTY_OWNER) + ); + assert_eq!(TtyOwner::const_parse("caller"), TtyOwner::Caller); + assert_eq!(TtyOwner::const_parse("target"), TtyOwner::Target); + let config = SPolicy::builder() + .role( + SRole::builder("test") + .task(STask::builder(1).build()) + .task( + STask::builder(2) + .options(|opt| { + opt.tty(STty::builder().owner(TtyOwner::Caller).build()) + .build() + }) + .build(), + ) + .build(), + ) + .options(|opt| { + opt.tty(STty::builder().owner(TtyOwner::Target).build()) + .build() + }) + .build(); + let owner = |task| { + OptStack::from_task(&config.task("test", task).unwrap()) + .to_opt() + .unwrap() + .as_ref() + .borrow() + .tty + .as_ref() + .and_then(|tty| tty.owner) + }; + assert_eq!(owner(1), Some(TtyOwner::Target)); + assert_eq!(owner(2), Some(TtyOwner::Caller)); + } } diff --git a/core/src/database/path.rs b/core/src/database/path.rs index c3201563..4c2df6c3 100644 --- a/core/src/database/path.rs +++ b/core/src/database/path.rs @@ -5,6 +5,7 @@ //! `PATH` of the user. use indexmap::IndexSet; +use log::warn; use crate::{ database::options::PathBehavior, @@ -56,8 +57,11 @@ impl<'a> ResolvedPath<'a> { /// The `PATH` of the command, from the `PATH` of the user: /// - in delete mode, the directories of the `add` list, - /// - in keep-safe mode, the directories of the `add` list then the absolute directories of - /// the user's `PATH`, in keep-unsafe mode every directory of the user's `PATH`, + /// - in keep-safe mode, the directories of the `add` list then those of the user's `PATH`, + /// in keep-unsafe mode every directory of the user's `PATH`, + /// - a relative directory (`.`, an empty one, `bin`) is the working directory of the command, + /// which the user chooses: only the keep-unsafe mode keeps it, from the policy as from the + /// user, and a relative directory of the policy that is left out is logged, /// - the directories of the `sub` list are always removed, and each directory appears once. /// /// The directories of the policy come first, so they win over those of the user. @@ -70,7 +74,13 @@ impl<'a> ResolvedPath<'a> { }; let keep_relative = self.behavior.is_keep_unsafe(); let mut path: IndexSet<&str> = IndexSet::new(); - path.extend(self.add.iter().copied()); + path.extend(self.add.iter().copied().filter(|dir| { + let kept = keep_relative || dir.starts_with('/'); + if !kept { + warn!("The directory {dir:?} of the path option is relative, it is left out"); + } + kept + })); path.extend( user_path .iter() @@ -170,6 +180,27 @@ mod tests { ); } + #[test] + fn test_relative_directories_only_in_keep_unsafe() { + let path = |behavior| ResolvedPath { + behavior, + add: IndexSet::from(["/sbin", ".", "", "bin"]), + sub: IndexSet::new(), + }; + let user_path = ["/usr/bin", "", ".", "tools"]; + // the relative directories of the policy and of the user are left out, the empty one + // (the working directory too) included + assert_eq!(path(PathBehavior::Delete).final_path(&user_path), ["/sbin"]); + assert_eq!( + path(PathBehavior::KeepSafe).final_path(&user_path), + ["/sbin", "/usr/bin"] + ); + assert_eq!( + path(PathBehavior::KeepUnsafe).final_path(&user_path), + ["/sbin", ".", "", "bin", "/usr/bin", "tools"] + ); + } + #[test] fn test_inheriting_level_extends_the_lists() { let levels = [ diff --git a/core/src/database/ser.rs b/core/src/database/ser.rs index 0c86fd33..e71ffa26 100644 --- a/core/src/database/ser.rs +++ b/core/src/database/ser.rs @@ -141,7 +141,10 @@ impl Serialize for SSetgidSet { S: serde::Serializer, { if let Some(fallback) = self.fallback.as_ref().filter(|_| { - self.default_behavior.is_none() && self.sub.is_empty() && self.add.is_empty() + self.default_behavior.is_none() + && self.sub.is_empty() + && self.add.is_empty() + && self.from.is_none() }) { serializer.serialize_some(fallback) } else if serializer.is_human_readable() { @@ -156,6 +159,9 @@ impl Serialize for SSetgidSet { if !self.sub.is_empty() { map.serialize_entry("del", &self.sub)?; } + if let Some(from) = &self.from { + map.serialize_entry("from", from)?; + } map.end() } else { let mut map = serializer.serialize_map(None)?; @@ -169,6 +175,9 @@ impl Serialize for SSetgidSet { if !self.sub.is_empty() { map.serialize_entry("s", &self.sub)?; } + if let Some(from) = &self.from { + map.serialize_entry("o", from)?; + } map.end() } } diff --git a/core/src/database/structs.rs b/core/src/database/structs.rs index 07464f84..871ed194 100644 --- a/core/src/database/structs.rs +++ b/core/src/database/structs.rs @@ -239,8 +239,10 @@ impl From for SUserEither { } } +/// Like the other credentials, a set of users refuses the fields it does not know, as dosr +/// does: a typo there must not leave a privilege granted. #[derive(Deserialize, Debug, Clone, Builder, PartialEq, Eq)] - +#[serde(deny_unknown_fields)] pub struct SSetuidSet { #[serde( alias = "d", @@ -346,18 +348,34 @@ impl From for SGroupsEither { } } +/// Whose groups the command gets when the task does not force them and the user does not choose +/// any with `-g`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GroupsFrom { + /// The groups of the user that the command runs as, as a login gives them: its primary group + /// and the groups that list it as a member + #[default] + Target, + /// The groups of the user who runs dosr + Caller, +} + #[derive(Debug, Clone, Builder, PartialEq, Eq)] pub struct SSetgidSet { #[builder(start_fn)] pub default_behavior: SetBehavior, - /// The groups when the user does not choose any with `-g`. Without it, `-g` is required, - /// or with the `all` default, the groups of the caller are kept. + /// The groups when the user does not choose any with `-g`. Without it, the groups that + /// `from` names; without `from` either, `-g` is required with the `none` default, and the + /// groups of the target user are taken with the `all` default. #[builder(into)] pub fallback: Option, #[builder(default, with = FromIterator::from_iter)] pub add: Vec, #[builder(default, with = FromIterator::from_iter)] pub sub: Vec, + /// Whose groups the command gets without `-g` and without `fallback` + pub from: Option, } #[derive(PartialEq, Eq, Debug, Builder, Default)] @@ -926,6 +944,8 @@ mod tests { let options = binding.as_ref().borrow(); let env = &options.env.as_ref().unwrap(); assert_eq!(env.extra_fields.get("unknown").unwrap(), "unknown"); + let path = options.path.as_ref().unwrap(); + assert_eq!(path.extra_fields.get("unknown").unwrap(), "unknown"); assert_eq!(options.extra_fields.get("unknown").unwrap(), "unknown"); let timeout = options.timeout.as_ref().unwrap(); assert_eq!(timeout.extra_fields.get("unknown").unwrap(), "unknown"); @@ -966,6 +986,50 @@ mod tests { assert_eq!(commands.extra_fields.get("unknown").unwrap(), "unknown"); } + #[test] + fn test_credentials_refuse_unknown_fields() { + // a typo in what grants privileges is an error, as in dosr + assert!( + serde_json::from_str::( + r#"{"default": "all", "fallback": "root", "fallbak": "x"}"# + ) + .is_err() + ); + assert!( + serde_json::from_str::( + r#"{"default": "all", "fallback": ["root"], "sbu": ["adm"]}"# + ) + .is_err() + ); + assert!( + serde_json::from_str::(r#"{"default": "all", "sbu": ["SYS_ADMIN"]}"#) + .is_err() + ); + let setuid: SSetuidSet = + serde_json::from_str(r#"{"default": "all", "fallback": "root", "add": ["daemon"]}"#) + .unwrap(); + let mut cbor = Vec::new(); + cbor4ii::serde::to_writer(&mut cbor, &setuid).unwrap(); + let back: SSetuidSet = cbor4ii::serde::from_slice(&cbor).unwrap(); + assert_eq!(back, setuid); + } + + #[test] + fn test_unknown_fields_of_sets_survive_a_round_trip() { + use crate::{RemoteStorageSettings, SettingsContent, database::options::SWorkdirSet}; + let workdir: SWorkdirSet = + serde_json::from_str(r#"{"default": "all", "future": 1}"#).unwrap(); + assert_eq!(serde_json::to_value(&workdir).unwrap()["future"], 1); + let storage: SettingsContent = serde_json::from_str( + r#"{"method": "json", "settings": {"path": "/p", "future": 2}, "future": 1}"#, + ) + .unwrap(); + let storage = serde_json::to_value(&storage).unwrap(); + assert_eq!(storage["future"], 1); + assert_eq!(storage["settings"]["future"], 2); + let _: RemoteStorageSettings = serde_json::from_value(storage["settings"].clone()).unwrap(); + } + #[allow(clippy::too_many_lines)] #[test] fn test_deserialize_alias() { diff --git a/core/src/file.rs b/core/src/file.rs index c12504bf..6ba2d287 100644 --- a/core/src/file.rs +++ b/core/src/file.rs @@ -5,6 +5,7 @@ use std::{ fmt::Debug, fs::{File, OpenOptions}, io::{self, BufReader, Seek}, + os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, rc::Rc, }; @@ -54,6 +55,19 @@ pub struct LockedSettingsFile pub data: T, /// The migration applied to the document when it was loaded, until it is saved. migration: Option, + /// The data as loaded, or as last saved: a file whose data is the same is not written + /// again. `None` when the data did not come from the file. + saved: Option, +} + +/// How a policy file is opened to be written. A new file is only readable and writable by its +/// owner (the umask can only remove more), like the files of the installers: the policy must not +/// be readable, nor writable, by other users (see `check_others_permissions`). An existing file +/// keeps its owner and mode, since saving rewrites it in place. +fn write_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).mode(0o600); + options } /// This opens, deserialize and locks a settings file, and keeps the file descriptor open to keep the lock @@ -79,12 +93,7 @@ impl LockedSettingsFile { where S: AsRef, { - Self::open( - path, - OpenOptions::new().read(true).write(true).create(true), - true, - data_loader, - ) + Self::open(path, &write_options(), true, data_loader) } /// # Errors @@ -105,9 +114,11 @@ impl LockedSettingsFile { nix::fcntl::FlockArg::LockExclusive, )?; + let data = data_loader(&path, &file)?; Ok(Self { path: path.as_ref().to_path_buf(), - data: data_loader(&path, &file)?, + saved: serde_json::to_value(&data).ok(), + data, fd: file, migration: None, }) @@ -123,6 +134,13 @@ impl LockedSettingsFile { load_data() } + /// Whether the data differs from what the file holds: changed since it was loaded or saved, + /// or not loaded from the file. + #[must_use] + pub fn changed(&self) -> bool { + self.saved.is_none() || self.saved != serde_json::to_value(&self.data).ok() + } + /// # Errors /// Returns an error if the file cannot be written /// due to a lock, permission error or writing error @@ -150,6 +168,7 @@ impl LockedSettingsFile { debug!("Wrote config file"); } self.migration = None; + self.saved = serde_json::to_value(&self.data).ok(); Ok(()) } @@ -184,10 +203,13 @@ pub struct FileSettings { ///# Errors /// Returns an error if any of the files cannot be opened or locked #[builder(with = |path: PathBuf,config : RootSettings| -> Result<_, Box> { - Ok(LockedSettingsFile::open_write(path, |_, _| { + let mut root = LockedSettingsFile::open_write(path, |_, _| { let versioned: Versioning = Versioning::new(config.clone()); Ok(versioned) - })?) + })?; + // made from these settings, not read from the file: it is written by the next save + root.saved = None; + Ok(root) })] root: LockedRootSettings, /// Policy files of a directory that could not be loaded, with the reason. @@ -228,7 +250,7 @@ impl FileSettings { rar_cfg_path, rar_cfg_data_path, rar_cfg_type, - OpenOptions::new().read(true).write(true).create(true), + &write_options(), true, ) } @@ -247,12 +269,7 @@ impl FileSettings { where P: AsRef, { - Self::load_policy_file( - cfg_type, - OpenOptions::new().read(true).write(true).create(true), - true, - cfg_path, - ) + Self::load_policy_file(cfg_type, &write_options(), true, cfg_path) } /// # Errors /// Returns an error if any of the files cannot be opened, deserialized or locked @@ -455,9 +472,11 @@ impl FileSettings { }) }) { // Open the new file with the new path - let new_root = LockedSettingsFile::open_write(path, |_, _| { + let mut new_root = LockedSettingsFile::open_write(path, |_, _| { Ok(Versioning::new(self.root.data.data.clone())) })?; + // the file at the new path does not hold these settings yet + new_root.saved = None; debug!( "Moved root settings to new path: {}", new_root.path.display() @@ -465,7 +484,11 @@ impl FileSettings { drop(std::mem::replace(&mut self.root, new_root)); } - let mut has_errors = if let Err(e) = self.root.save(RAR_CFG_TYPE, immutable) { + // only the files that changed: the others are not rewritten (in the typed form of the + // actors, without their immutable flag for a while...) + let mut has_errors = if !self.root.changed() { + false + } else if let Err(e) = self.root.save(RAR_CFG_TYPE, immutable) { error!("Failed to save root settings: {e}"); true } else { @@ -473,7 +496,7 @@ impl FileSettings { false }; - for config in &mut self.map { + for config in self.map.iter_mut().filter(|config| config.changed()) { if let Err(e) = config.save(self.root.data.data.storage.method, immutable) { error!( "Failed to save settings for {}: {}", @@ -565,6 +588,20 @@ impl FileSettings { pub fn get_policies(&self) -> Vec<&Rc>> { self.map.iter().map(|config| &config.data.data).collect() } + + /// The path of the root file. + #[must_use] + pub fn root_path(&self) -> &Path { + &self.root.path + } + + /// The policy files apart from the root file (those of a policy directory, or the one file + /// that the root file names), with their path. + pub fn policy_files(&self) -> impl Iterator>)> { + self.map + .iter() + .map(|config| (config.path.as_path(), &config.data.data)) + } } #[cfg(test)] @@ -1341,6 +1378,56 @@ mod tests { assert!(!content.contains("oldest_role")); } + /// A path in the temporary directory that does not exist yet, removed when dropped. + fn new_temp_path(name: &str) -> (PathBuf, impl Drop) { + let path = std::env::temp_dir().join(format!("{name}_{}", std::process::id())); + let _ = std::fs::remove_file(&path); + let cleanup = path.clone(); + ( + path, + defer(move || { + let _ = std::fs::remove_file(&cleanup); + }), + ) + } + + #[test] + fn test_new_policy_files_are_private() { + use std::os::unix::fs::PermissionsExt; + let (root_file, _cleanup_root) = new_temp_path("rar_new_root_file"); + let mut locked = + LockedSettingsFile::open_write(&root_file, |_, _| Ok(RootSettings::default())).unwrap(); + locked.save(StorageMethod::JSON, false).unwrap(); + drop(locked); + let (policy_file, _cleanup_policy) = new_temp_path("rar_new_policy_file"); + // the new file is empty, so reading it fails, but the file is created + assert!(FileSettings::write_policy(&policy_file, StorageMethod::JSON).is_err()); + for path in [&root_file, &policy_file] { + let mode = std::fs::metadata(path).unwrap().permissions().mode(); + // never more than 0600, whatever the umask + assert_eq!(mode & 0o077, 0, "{}: {mode:o}", path.display()); + } + } + + #[test] + fn test_saving_keeps_the_owner_and_mode() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let (path, _cleanup) = new_temp_path("rar_saved_root_file"); + std::fs::write(&path, b"{}").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + let before = std::fs::metadata(&path).unwrap(); + let mut locked = + LockedSettingsFile::open_write(&path, |_, _| Ok(RootSettings::default())).unwrap(); + locked.save(StorageMethod::JSON, false).unwrap(); + drop(locked); + let after = std::fs::metadata(&path).unwrap(); + // rewritten in place: the same file, with its owner and mode + assert_eq!(after.ino(), before.ino()); + assert_eq!((after.uid(), after.gid()), (before.uid(), before.gid())); + assert_eq!(after.permissions().mode() & 0o7777, 0o640); + assert!(after.len() > 2); + } + #[test] fn test_save_all_with_directory_storage() { let test_file = "/tmp/test_save_all_with_directory_storage.json"; diff --git a/core/src/lib.rs b/core/src/lib.rs index 7d4977b3..c36aed85 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -60,6 +60,66 @@ use konst::result; use libc::dev_t; use nix::unistd::{Gid, Group, Pid, Uid, User, getgroups}; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// [`println!`] that ignores write errors: a privileged program must not panic because its +/// output is a closed pipe (`dosr -i | true` gets `EPIPE`, since Rust ignores `SIGPIPE`). +#[macro_export] +macro_rules! println_ignore_io_error { + ($($tt:tt)*) => {{ + use ::std::io::Write as _; + let _ = ::std::writeln!(::std::io::stdout(), $($tt)*); + }}; +} + +/// [`print!`] that ignores write errors, see [`println_ignore_io_error`]. +#[macro_export] +macro_rules! print_ignore_io_error { + ($($tt:tt)*) => {{ + use ::std::io::Write as _; + let _ = ::std::write!(::std::io::stdout(), $($tt)*); + }}; +} + +/// [`eprintln!`] that ignores write errors, see [`println_ignore_io_error`]. +#[macro_export] +macro_rules! eprintln_ignore_io_error { + ($($tt:tt)*) => {{ + use ::std::io::Write as _; + let _ = ::std::writeln!(::std::io::stderr(), $($tt)*); + }}; +} + +// `println!` and the like panic when the output is a closed pipe (`EPIPE`, since Rust ignores +// `SIGPIPE`): outside the tests, the `*_ignore_io_error` macros of rootasrole-core are used. +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! println { + ($($tt:tt)*) => { + compile_error!("println! panics on EPIPE, use println_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! print { + ($($tt:tt)*) => { + compile_error!("print! panics on EPIPE, use print_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprintln { + ($($tt:tt)*) => { + compile_error!("eprintln! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprint { + ($($tt:tt)*) => { + compile_error!("eprint! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} //pub mod api; pub mod database; @@ -125,6 +185,10 @@ pub struct RemoteStorageSettings { #[cfg(feature = "sgbd")] #[serde(skip_serializing_if = "Option::is_none")] pub properties: Option, + /// The fields that this version does not know, kept when the policy is saved again + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -143,6 +207,10 @@ pub struct SettingsContent { #[cfg(feature = "ldap")] #[serde(skip_serializing_if = "Option::is_none")] pub ldap: Option, + /// The fields that this version does not know, kept when the policy is saved again + #[serde(default, flatten, skip_serializing_if = "Map::is_empty")] + #[builder(default)] + pub extra_fields: Map, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] diff --git a/core/src/log.rs b/core/src/log.rs index eb4759f5..01e50bea 100644 --- a/core/src/log.rs +++ b/core/src/log.rs @@ -2,7 +2,7 @@ #[allow(clippy::unnecessary_wraps)] // Same signature as non debug. /// # Errors /// Returns an error if the logger fails to initialize -pub fn subsribe(_: &str) -> std::io::Result<()> { +pub fn subscribe(_: &str) -> std::io::Result<()> { env_logger::Builder::from_default_env() .filter_level(log::LevelFilter::Trace) .format_module_path(true) @@ -11,7 +11,7 @@ pub fn subsribe(_: &str) -> std::io::Result<()> { } #[cfg(not(debug_assertions))] -pub fn subsribe(tool: &str) -> std::io::Result<()> { +pub fn subscribe(tool: &str) -> std::io::Result<()> { use log::LevelFilter; use std::ffi::CString; @@ -79,3 +79,12 @@ impl log::Log for CustomSysLogger { fn flush(&self) {} } + +/// The former, misspelled name of [`subscribe`], kept for the users of rootasrole-core 4.1. +/// +/// # Errors +/// As [`subscribe`]. +#[deprecated(since = "4.2.0", note = "use subscribe")] +pub fn subsribe(tool: &str) -> std::io::Result<()> { + subscribe(tool) +} diff --git a/core/src/util.rs b/core/src/util.rs index 88dc3c90..954fa59f 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -20,10 +20,11 @@ use nix::{ unistd::{Gid, Group}, }; use serde::{Deserialize, Serialize}; +use std::num::NonZeroU8; use crate::database::options::{ EnvBehavior, PathBehavior, SAuthentication, SBounding, SInfo, SPrivileged, SUMask, - TimestampType, WorkdirBehavior, + TimestampType, TtyOwner, WorkdirBehavior, }; #[cfg(feature = "finder")] @@ -58,6 +59,9 @@ pub(super) const RAR_CFG_IMMUTABLE: bool = false; #[cfg(not(debug_assertions))] pub(super) const RAR_CFG_IMMUTABLE: bool = eq_str(env!("RAR_CFG_IMMUTABLE"), "true"); +/// The policy file that chsr edits in a policy directory, when no file is named: the file that +/// the installers write there. +pub const DEFAULT_POLICY_FILE: &str = "default.json"; pub const RAR_CFG_TYPE: StorageMethod = StorageMethod::const_parse(env!("RAR_CFG_TYPE")); pub const ENV_PATH_BEHAVIOR: PathBehavior = PathBehavior::const_parse(env!("RAR_PATH_DEFAULT")); @@ -141,6 +145,26 @@ pub const TIMEOUT_MAX_USAGE: Option = match option_env!("RAR_TIMEOUT_MAX_US None => None, }; +//=== SPassword === + +/// How many times the password is asked before dosr gives up (`RAR_PASSWORD_TRIES`). A value +/// that is not a number from 1 to 255, or a missing build variable, stops the build. +pub const PASSWORD_TRIES: NonZeroU8 = match u8::from_str_radix(env!("RAR_PASSWORD_TRIES"), 10) { + Ok(n) => match NonZeroU8::new(n) { + Some(n) => n, + None => panic!("RAR_PASSWORD_TRIES must be at least 1"), + }, + Err(_) => panic!("RAR_PASSWORD_TRIES must be a number from 1 to 255"), +}; + +/// How long dosr waits after a wrong password (`RAR_PASSWORD_DELAY`, hh:mm:ss). A value that is +/// not a duration, or a missing build variable, stops the build. +pub const PASSWORD_DELAY: SignedDuration = + match convert_string_to_duration(env!("RAR_PASSWORD_DELAY")) { + Ok(delay) => delay, + Err(_) => panic!("RAR_PASSWORD_DELAY must be a duration written hh:mm:ss"), + }; + pub const WORKDIR_BEHAVIOR: WorkdirBehavior = assert_valid_workdir_behavior(WorkdirBehavior::const_parse(env!("RAR_WORKDIR_BEHAVIOR"))); @@ -183,6 +207,10 @@ pub const UMASK: SUMask = SUMask(result::unwrap_or!( pub const INFO: SInfo = SInfo::const_parse(env!("RAR_EXEC_INFO_DISPLAY")); +/// Who owns the terminal of a command run as another user, when the policy does not say it +/// (`RAR_TTY_OWNER`): `caller`, or `target` like sudo. A value that is neither stops the build. +pub const TTY_OWNER: TtyOwner = TtyOwner::const_parse(env!("RAR_TTY_OWNER")); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy)] #[serde(rename_all = "lowercase")] #[repr(u8)] @@ -714,8 +742,12 @@ pub fn match_single_path(cmd_path: &Path, role_path: &str) -> CmdMin { if cmd_path == Path::new(role_path) { match_status.set_matching(); } else { + // compiling a pattern costs about 10 µs, for each command of each task of the policy #[cfg(feature = "glob")] - if glob_matches(role_path, cmd_path) { + if is_glob_pattern(role_path) + && starts_like(role_path, cmd_path) + && glob_matches(role_path, cmd_path) + { use crate::database::score::CmdOrder; match_status.set_matching(); match_status.union_order(CmdOrder::WildcardPath); @@ -730,6 +762,24 @@ pub fn match_single_path(cmd_path: &Path, role_path: &str) -> CmdMin { match_status } +/// Whether `pattern` has a character that globset reads specially. Without any, it only matches +/// the path written the same way, which the comparison of paths already found. +#[cfg(all(feature = "finder", feature = "glob"))] +fn is_glob_pattern(pattern: &str) -> bool { + pattern.contains(['*', '?', '[', '{', '\\']) +} + +/// Whether `path` starts with the characters of `pattern` before its first special one, which +/// globset matches as they are: otherwise the pattern cannot match, and is not compiled. +#[cfg(all(feature = "finder", feature = "glob"))] +fn starts_like(pattern: &str, path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let literal = pattern + .find(['*', '?', '[', '{', '\\']) + .map_or(pattern, |special| &pattern[..special]); + path.as_os_str().as_bytes().starts_with(literal.as_bytes()) +} + #[cfg(all(feature = "finder", feature = "glob"))] fn glob_matches(pattern: &str, path: &Path) -> bool { use std::path::Component; @@ -1009,6 +1059,30 @@ mod test { assert!(!matches("/usr/bin/./ls", "/usr/bin/*")); // an invalid pattern matches nothing assert!(!matches("/usr/bin/ls", "/usr/bin/[ls")); + // the characters of globset make a pattern, `\` escapes the next one + for pattern in [ + "/usr/bin/*", + "/usr/bin/l?", + "/usr/bin/[lc]s", + "/usr/bin/{ls,cat}", + ] { + assert!(is_glob_pattern(pattern), "{pattern}"); + } + assert!(is_glob_pattern("/usr/bin/a\\*")); + assert!(matches("/usr/bin/a*", "/usr/bin/a\\*")); + assert!(!matches("/usr/bin/ab", "/usr/bin/a\\*")); + // the characters before the first special one must start the path + assert!(starts_like( + "/usr/bin/python3.*", + Path::new("/usr/bin/python3.12") + )); + assert!(!starts_like("/usr/bin/true5*", Path::new("/usr/bin/true"))); + assert!(!matches("/usr/bin/true", "/usr/bin/true5*")); + assert!(starts_like("/usr/bin/{ls,cat}", Path::new("/usr/bin/cat"))); + // without them, a path is not compiled and matches only itself + assert!(!is_glob_pattern("/usr/bin/true1")); + assert!(!matches("/usr/bin/true", "/usr/bin/true1")); + assert!(!matches("/usr/bin/true1", "/usr/bin/true")); } #[test] diff --git a/exec/src/monitor/backchannel.rs b/exec/src/monitor/backchannel.rs index 997787b4..f2151828 100644 --- a/exec/src/monitor/backchannel.rs +++ b/exec/src/monitor/backchannel.rs @@ -3,10 +3,14 @@ use log::{trace, warn}; use rkyv::{Archive, Deserialize, Serialize}; use std::io::{self, Write}; +use std::os::fd::AsRawFd; use std::os::unix::net::UnixStream; pub const MAX_BACKCHANNEL_MESSAGE_SIZE: usize = 64 * 1024; +/// A frame is the length of the message as a little-endian `u32`, then the message. +const FRAME_HEADER_SIZE: usize = size_of::(); + macro_rules! signals { (core + [$($extra:expr),* $(,)?]) => { &[ @@ -65,12 +69,16 @@ pub enum MonitorMessage { Signal(i32), /// Ready to start Edge, + /// Resume the stopped command, with the pty in its foreground or not + Resume { foreground: bool }, } #[derive(Archive, Serialize, Deserialize, Debug)] pub enum ParentMessage { /// Command PID CommandPid(i32), + /// The command was stopped by this signal: suspend dosr, like a shell job + CommandStopped(i32), /// Exit status from the command ExitStatus(i32), /// Error during execution @@ -79,6 +87,8 @@ pub enum ParentMessage { pub struct Backchannel { stream: UnixStream, + /// The bytes of the frame being read: on a non-blocking socket, a frame can arrive in parts. + received: Vec, } impl Backchannel { @@ -90,9 +100,18 @@ impl Backchannel { pub fn pair() -> io::Result<(Self, Self)> { let (a, b) = UnixStream::pair()?; // Keep both as blocking - poll() will notify when data is available - Ok((Self { stream: a }, Self { stream: b })) + Ok((Self::new(a), Self::new(b))) + } + + const fn new(stream: UnixStream) -> Self { + Self { + stream, + received: Vec::new(), + } } + /// Write a whole frame. It is written at once, and a full non-blocking socket is waited for: + /// a frame is never cut, whatever the peer reads in between. fn write_frame(&mut self, data: &[u8]) -> io::Result<()> { if data.len() > MAX_BACKCHANNEL_MESSAGE_SIZE { return Err(io::Error::new( @@ -102,29 +121,93 @@ impl Backchannel { } #[allow(clippy::cast_possible_truncation)] let len = (data.len() as u32).to_le_bytes(); + let mut frame = Vec::with_capacity(FRAME_HEADER_SIZE + data.len()); + frame.extend_from_slice(&len); + frame.extend_from_slice(data); trace!("backchannel: write frame len={}", data.len()); - self.stream.write_all(&len)?; - self.stream.write_all(data)?; + let mut written = 0; + while written < frame.len() { + match self.stream.write(&frame[written..]) { + Ok(0) => return Err(io::Error::from(io::ErrorKind::WriteZero)), + Ok(count) => written += count, + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => self.wait_writable()?, + Err(e) => return Err(e), + } + } Ok(()) } + /// Wait until the socket can be written again. + fn wait_writable(&self) -> io::Result<()> { + let mut fd = libc::pollfd { + fd: self.stream.as_raw_fd(), + events: libc::POLLOUT, + revents: 0, + }; + loop { + // SAFETY: `fd` is one valid pollfd that outlives the call, and the count is 1 + if unsafe { libc::poll(&raw mut fd, 1, -1) } >= 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + return Err(error); + } + } + } + + /// Read one frame. On a non-blocking socket, the bytes read so far are kept and `WouldBlock` + /// is returned until the frame is complete. Only the bytes of this frame are read: the next + /// one stays in the socket, so that `poll()` still reports it. fn read_frame(&mut self) -> io::Result> { use std::io::Read; - let mut len_buf = [0u8; 4]; - self.stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; + loop { + let size = self.frame_size()?; + let start = self.received.len(); + if start == size { + trace!("backchannel: read frame len={}", size - FRAME_HEADER_SIZE); + let data = self.received.split_off(FRAME_HEADER_SIZE); + self.received.clear(); + return Ok(data); + } + self.received.resize(size, 0); + match self.stream.read(&mut self.received[start..]) { + Ok(0) => { + self.received.truncate(start); + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "backchannel closed", + )); + } + Ok(count) => self.received.truncate(start + count), + Err(e) => { + self.received.truncate(start); + if e.kind() != io::ErrorKind::Interrupted { + return Err(e); + } + } + } + } + } + + /// The size of the frame being read, header included; the size of the header alone until it + /// is read. An invalid length means that the stream cannot be trusted anymore: the bytes read + /// so far are dropped. + fn frame_size(&mut self) -> io::Result { + let Some(header) = self.received.first_chunk::() else { + return Ok(FRAME_HEADER_SIZE); + }; + let len = u32::from_le_bytes(*header) as usize; if len == 0 || len > MAX_BACKCHANNEL_MESSAGE_SIZE { + self.received.clear(); warn!("backchannel: invalid frame size {len}"); return Err(io::Error::new( io::ErrorKind::InvalidData, format!("invalid backchannel message size: {len} bytes"), )); } - - let mut data = vec![0u8; len]; - trace!("backchannel: read frame len={len}"); - self.stream.read_exact(&mut data)?; - Ok(data) + Ok(FRAME_HEADER_SIZE + len) } /// # Errors @@ -206,7 +289,9 @@ mod tests { .expect("receive monitor message") { MonitorMessage::Signal(signal) => assert_eq!(signal, libc::SIGUSR1), - MonitorMessage::Edge => panic!("unexpected monitor message"), + MonitorMessage::Edge | MonitorMessage::Resume { .. } => { + panic!("unexpected monitor message") + } } monitor @@ -217,7 +302,9 @@ mod tests { .expect("receive parent message") { ParentMessage::CommandPid(pid) => assert_eq!(pid, 4242), - ParentMessage::ExitStatus(_) | ParentMessage::Error(_) => { + ParentMessage::ExitStatus(_) + | ParentMessage::Error(_) + | ParentMessage::CommandStopped(_) => { panic!("unexpected parent message") } } @@ -227,6 +314,33 @@ mod tests { .expect("keep nonblocking mode configurable"); } + #[test] + fn backchannel_roundtrips_the_suspend_messages() { + let (mut parent, mut monitor) = Backchannel::pair().expect("create backchannel pair"); + + monitor + .send_parent_message(&ParentMessage::CommandStopped(libc::SIGTSTP)) + .expect("send the stop"); + assert!(matches!( + parent.recv_parent_message(), + Ok(ParentMessage::CommandStopped(libc::SIGTSTP)) + )); + + for foreground in [true, false] { + parent + .send_monitor_message(&MonitorMessage::Resume { foreground }) + .expect("send the resume"); + match monitor.recv_monitor_message() { + Ok(MonitorMessage::Resume { + foreground: received, + }) => { + assert_eq!(received, foreground); + } + other => panic!("unexpected result {other:?}"), + } + } + } + #[test] fn backchannel_rejects_oversized_frames() { let (mut parent, _) = Backchannel::pair().expect("create backchannel pair"); @@ -279,4 +393,148 @@ mod tests { .expect_err("truncated frame must fail gracefully"); assert_eq!(error.kind(), io::ErrorKind::InvalidData); } + + /// The bytes of one frame, as the sender writes them. + fn frame_bytes(msg: &ParentMessage) -> Vec { + use std::io::Read; + let (mut sender, mut receiver) = Backchannel::pair().expect("create backchannel pair"); + sender + .send_parent_message(msg) + .expect("send parent message"); + drop(sender); + let mut frame = Vec::new(); + receiver + .get_mut() + .read_to_end(&mut frame) + .expect("read the frame"); + frame + } + + // The monitor can be preempted between the length and the payload of a frame: the runner + // reads a non-blocking socket and must keep what it already read until the frame is complete. + #[test] + fn backchannel_keeps_a_frame_split_across_reads() { + let frame = frame_bytes(&ParentMessage::ExitStatus(0)); + let (mut monitor, mut runner) = Backchannel::pair().expect("create backchannel pair"); + runner.set_nonblocking(true).expect("set nonblocking"); + + for split in 1..frame.len() { + monitor + .get_mut() + .write_all(&frame[..split]) + .expect("write the first part"); + let error = runner + .recv_parent_message() + .expect_err("a part of a frame is not a message"); + assert_eq!(error.kind(), io::ErrorKind::WouldBlock, "split at {split}"); + + monitor + .get_mut() + .write_all(&frame[split..]) + .expect("write the rest"); + match runner.recv_parent_message() { + Ok(ParentMessage::ExitStatus(status)) => assert_eq!(status, 0), + other => panic!("split at {split}: unexpected result {other:?}"), + } + } + } + + #[test] + fn backchannel_reads_a_frame_written_byte_by_byte() { + let frame = frame_bytes(&ParentMessage::CommandPid(4242)); + let (mut monitor, mut runner) = Backchannel::pair().expect("create backchannel pair"); + runner.set_nonblocking(true).expect("set nonblocking"); + + let (last, first) = frame.split_last().expect("a frame is not empty"); + for byte in first { + monitor + .get_mut() + .write_all(std::slice::from_ref(byte)) + .expect("write one byte"); + let error = runner + .recv_parent_message() + .expect_err("a part of a frame is not a message"); + assert_eq!(error.kind(), io::ErrorKind::WouldBlock); + } + monitor + .get_mut() + .write_all(std::slice::from_ref(last)) + .expect("write the last byte"); + match runner.recv_parent_message() { + Ok(ParentMessage::CommandPid(pid)) => assert_eq!(pid, 4242), + other => panic!("unexpected result {other:?}"), + } + } + + // The monitor handles one message per readable event: reading a frame must not take the + // bytes of the next one, or poll would not report them anymore. + #[test] + fn backchannel_leaves_the_next_frame_in_the_socket() { + use std::os::fd::AsRawFd; + let (mut runner, mut monitor) = Backchannel::pair().expect("create backchannel pair"); + monitor.set_nonblocking(true).expect("set nonblocking"); + runner + .send_monitor_message(&MonitorMessage::Signal(libc::SIGUSR1)) + .expect("send a signal"); + runner + .send_monitor_message(&MonitorMessage::Edge) + .expect("send the edge"); + + match monitor.recv_monitor_message() { + Ok(MonitorMessage::Signal(signal)) => assert_eq!(signal, libc::SIGUSR1), + other => panic!("unexpected result {other:?}"), + } + let mut fd = libc::pollfd { + fd: monitor.get_stream().as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: `fd` is one valid pollfd that outlives the call, and the timeout is zero + let ready = unsafe { libc::poll(&raw mut fd, 1, 0) }; + assert_eq!(ready, 1, "the second frame must still be readable"); + assert!(matches!( + monitor.recv_monitor_message(), + Ok(MonitorMessage::Edge) + )); + } + + // A frame sent on a full non-blocking socket waits for room instead of being cut. + #[test] + fn backchannel_waits_for_room_when_the_socket_is_full() { + use std::io::Read; + let (mut monitor, mut runner) = Backchannel::pair().expect("create backchannel pair"); + monitor.set_nonblocking(true).expect("set nonblocking"); + runner + .get_mut() + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .expect("set a read timeout"); + + let chunk = [0xffu8; 4096]; + let mut filled = 0; + loop { + match monitor.get_mut().write(&chunk) { + Ok(written) => filled += written, + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => panic!("fill the socket: {e}"), + } + } + // the reader drains the socket only once the frame is being sent on a full socket + let reader = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(200)); + let mut filler = vec![0u8; filled]; + runner + .get_mut() + .read_exact(&mut filler) + .expect("drain the filler"); + runner.recv_parent_message() + }); + + monitor + .send_parent_message(&ParentMessage::ExitStatus(7)) + .expect("the frame is sent whole"); + match reader.join().expect("the reader thread ends") { + Ok(ParentMessage::ExitStatus(status)) => assert_eq!(status, 7), + other => panic!("unexpected result {other:?}"), + } + } } diff --git a/exec/src/monitor/mod.rs b/exec/src/monitor/mod.rs index 6e550094..42d913f1 100644 --- a/exec/src/monitor/mod.rs +++ b/exec/src/monitor/mod.rs @@ -6,9 +6,9 @@ use std::process::Command; use crate::event::{EventRegistry, PollEvent, Process}; use crate::orchestrator::{Orchestrator, PreExecContext}; use crate::pty::PtyFollower; -use crate::signal::{SignalInfo, SignalStream, register_signal_handler}; -use crate::terminal::TerminalExt; -use libc::{SIGCHLD, SIGKILL}; +use crate::signal::{SignalInfo, SignalStream, register_signal_handler, terminate_process}; +use crate::terminal::{ProcessId, TerminalExt}; +use libc::SIGCHLD; use log::{debug, error, trace, warn}; pub mod backchannel; @@ -97,8 +97,7 @@ impl Process for MonitorClosure { if is_forward_signal_allowed(sig) { if let Some(pid) = self.command_pid { if sig == libc::SIGALRM { - // SAFETY: kill does not access memory, it only sends a signal to the command - unsafe { libc::kill(pid, libc::SIGKILL) }; + terminate_process(pid, false); } else { // SAFETY: kill does not access memory, it only sends a signal to the command unsafe { libc::kill(pid, sig) }; @@ -116,7 +115,9 @@ impl Process for MonitorClosure { registry.set_break(io::Error::from_raw_os_error(0)); } } - //Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { }, + Ok(MonitorMessage::Resume { foreground }) => self.resume_command(foreground), + // A frame arrived in parts: the rest comes with the next readable event. + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {} Err(e) => { registry.set_break(e); } @@ -127,6 +128,29 @@ impl Process for MonitorClosure { } impl MonitorClosure { + /// Resume the stopped command once dosr is continued: in the foreground, the pty goes back to + /// the process group of the command; in the background, the monitor keeps it. + fn resume_command(&self, foreground: bool) { + let Some(pid) = self.command_pid else { + return; + }; + let pgrp = if foreground { + self.command_pgrp + } else { + self.monitor_pgrp + }; + debug!("monitor: resuming the command (foreground={foreground})"); + if let Err(e) = self + .pty_follower + .as_tty() + .and_then(|tty| tty.tcsetpgrp(ProcessId::new(pgrp))) + { + warn!("monitor: cannot give the pty to process group {pgrp}: {e}"); + } + // SAFETY: killpg does not access memory, it only sends a signal to the command's group + unsafe { libc::killpg(pid, libc::SIGCONT) }; + } + fn handle_signal(&mut self, info: SignalInfo, registry: &mut EventRegistry) { debug!("monitor: got signal {} from pid {}", info.signal, info.pid); if info.signal == SIGCHLD { @@ -137,13 +161,18 @@ impl MonitorClosure { unsafe { libc::waitpid(pid, &raw mut status, libc::WNOHANG | libc::WUNTRACED) }; if res > 0 { if libc::WIFSTOPPED(status) { - warn!("monitor: command stopped with {}", libc::WSTOPSIG(status)); - let _ = - self.backchannel - .send_parent_message(&ParentMessage::Error(format!( - "Command stopped with signal {}", - libc::WSTOPSIG(status) - ))); + let signal = libc::WSTOPSIG(status); + debug!("monitor: command stopped with {signal}"); + // The command may have given the pty to another of its process groups: + // that group gets the pty back when the command is resumed. + if let Ok(pgrp) = self.pty_follower.as_tty().and_then(|tty| tty.tcgetpgrp()) + && pgrp.inner() != self.monitor_pgrp + { + self.command_pgrp = pgrp.inner(); + } + let _ = self + .backchannel + .send_parent_message(&ParentMessage::CommandStopped(signal)); return; } @@ -167,8 +196,7 @@ impl MonitorClosure { if is_forward_signal_allowed(info.signal) { debug!("monitor: forwarding signal {}", info.signal); if info.signal == libc::SIGALRM { - // SAFETY: kill does not access memory, it only sends a signal to the command - unsafe { libc::kill(pid, libc::SIGKILL) }; + terminate_process(pid, false); } else { // SAFETY: kill does not access memory, it only sends a signal to the command unsafe { libc::kill(pid, info.signal) }; @@ -401,10 +429,9 @@ pub fn exec_monitor_process( monitor.monitor_pgrp ); - // Cleanup + // Cleanup: the command still runs although the monitor is done (the runner is gone) if let Some(pid) = monitor.command_pid { - // SAFETY: kill does not access memory, it only sends a signal to the command - unsafe { libc::kill(pid, SIGKILL) }; + terminate_process(pid, true); let mut status = 0; // SAFETY: waitpid only writes the exit status through a pointer to a local integer unsafe { libc::waitpid(pid, &raw mut status, 0) }; diff --git a/exec/src/pipe.rs b/exec/src/pipe.rs index 00f6d3f9..946a499b 100644 --- a/exec/src/pipe.rs +++ b/exec/src/pipe.rs @@ -90,6 +90,11 @@ impl Pipe { self.background = true; } + pub fn enable_input(&mut self, registry: &mut EventRegistry) { + self.buffer_lr.read_handle.resume(registry); + self.background = false; + } + pub fn resume_events(&mut self, registry: &mut EventRegistry) { if !self.background { self.buffer_lr.read_handle.resume(registry); diff --git a/exec/src/pty.rs b/exec/src/pty.rs index 119aca91..9686236f 100644 --- a/exec/src/pty.rs +++ b/exec/src/pty.rs @@ -139,6 +139,19 @@ impl PtyFollower { pub fn try_clone(&self) -> io::Result { self.file.try_clone().map(|file| Self { file }) } + + /// Give the terminal to `uid`, keeping its group (`tty` when devpts is mounted with + /// `gid=5`) and its mode. Unlike sudo-rs, which changes it by its path + /// (`src/exec/use_pty/parent.rs`, `get_pty`), it is changed through the open descriptor. + /// + /// # Errors + /// Returns an error if the caller may not change the owner: it needs `CAP_CHOWN`, unless + /// `uid` already owns the terminal. + pub fn set_owner(&self, uid: libc::uid_t) -> io::Result<()> { + // a gid of -1 keeps the group + // SAFETY: fchown does not access memory, the descriptor is owned by `self.file` + cerr(unsafe { libc::fchown(self.file.as_raw_fd(), uid, libc::gid_t::MAX) }).map(|_| ()) + } } impl AsFd for PtyFollower { @@ -163,6 +176,7 @@ mod tests { use std::fs::File; use std::io::{Read, Write}; use std::os::fd::{AsFd, AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; #[test] fn test_pty_open_basic() { @@ -175,6 +189,33 @@ mod tests { assert!(terminal::safe_isatty(&pty.follower)); } + #[test] + fn test_pty_set_owner() { + use std::os::unix::fs::MetadataExt; + + let pty = Pty::open().expect("Failed to open PTY"); + let path = std::ffi::OsStr::from_bytes(pty.path.as_bytes()); + let before = std::fs::metadata(path).expect("the pty has a path"); + // the owner it already has needs no privilege + pty.follower + .set_owner(before.uid()) + .expect("the owner of the pty may give it to itself"); + let after = std::fs::metadata(path).expect("the pty has a path"); + assert_eq!( + (after.uid(), after.gid(), after.mode()), + (before.uid(), before.gid(), before.mode()), + "the group and the mode are kept" + ); + // SAFETY: geteuid does not access memory + if unsafe { libc::geteuid() } != 0 { + let err = pty + .follower + .set_owner(before.uid() + 1) + .expect_err("another owner needs CAP_CHOWN"); + assert_eq!(err.raw_os_error(), Some(libc::EPERM)); + } + } + #[test] fn test_pty_follower_clone() { let pty = Pty::open().expect("Failed to open PTY"); diff --git a/exec/src/runner.rs b/exec/src/runner.rs index 9273f5d1..a8aa1b7e 100644 --- a/exec/src/runner.rs +++ b/exec/src/runner.rs @@ -13,7 +13,7 @@ use crate::orchestrator::{Orchestrator, PreExecContext}; use super::event::{EventRegistry, PollEvent, Process, StopReason}; use super::pipe::{IoLogger, Pipe, io_logger_sealed}; use super::pty::{Pty, PtyLeader}; -use super::signal::{SignalStream, register_signal_handler}; +use super::signal::{SignalStream, register_signal_handler, terminate_process}; use super::terminal::{ProcessId, TermSize, TerminalExt, UserTerm}; pub struct SimpleFileLogger { @@ -188,6 +188,19 @@ impl ExecRunner { debug!("runner: command pid set to {pid}"); self.command_pid = pid; } + Ok(ParentMessage::CommandStopped(signal)) => { + debug!("runner: command stopped by signal {signal}, suspending"); + if let Some(foreground) = self.suspend(signal, registry) + && self + .backchannel + .send_monitor_message(&MonitorMessage::Resume { foreground }) + .is_err() + { + warn!("runner: failed to resume the command, checking monitor exit"); + self.check_monitor_exit(registry); + } + self.pipe.resume_events(registry); + } Ok(ParentMessage::ExitStatus(status)) => { debug!("runner: exit status received: {status}"); registry.set_exit(std::process::ExitStatus::from_raw(status)); @@ -232,6 +245,100 @@ impl ExecRunner { Ok(()) } + /// Suspend dosr with the stopped command, like a shell job, and tell how to resume the + /// command once dosr is continued: `Some(true)` with the pty in its foreground, `Some(false)` + /// in the background, `None` to leave it stopped. + fn suspend(&mut self, signal: libc::c_int, registry: &mut EventRegistry) -> Option { + // A SIGCONT received while suspending would resume the terminal a second time. + let _ignore_sigcont = SignalDisposition::set(libc::SIGCONT, libc::SIG_IGN) + .inspect_err(|e| warn!("runner: cannot ignore SIGCONT: {e}")) + .ok(); + + if matches!(signal, libc::SIGTTOU | libc::SIGTTIN) { + // The command wants the terminal: when dosr is in the foreground, the command is + // resumed at once, whether the terminal was already raw or not (sudo-rs #1598). + if !self.foreground && self.check_foreground().is_err() { + // the user's terminal was revoked + return None; + } + if self.foreground { + if !self.term_raw + && self + .pipe + .left_mut() + .set_raw_mode(true, self.preserve_oflag) + .is_ok() + { + self.term_raw = true; + } + self.pipe.enable_input(registry); + return Some(true); + } + } + + self.pipe.ignore_events(registry); + if self.term_raw { + match self.pipe.left_mut().restore(false) { + Ok(()) => self.term_raw = false, + Err(e) => warn!("runner: cannot restore the terminal settings: {e}"), + } + } + + // Stop the job of dosr with the same signal, with its default action. + let default_action = if signal == libc::SIGSTOP { + None + } else { + SignalDisposition::set(signal, libc::SIG_DFL) + .inspect_err(|e| warn!("runner: cannot set the default action of {signal}: {e}")) + .ok() + }; + let pgrp = self.parent_pgrp.inner(); + // SAFETY: getpid, kill and killpg do not access memory + let suspended = unsafe { + (pgrp == libc::getpid() || libc::kill(pgrp, 0) == 0) && libc::killpg(pgrp, signal) == 0 + }; + drop(default_action); + + if !suspended { + error!("runner: no process group to suspend, terminating the command"); + terminate_process(self.command_pid, true); + return None; + } + // dosr was continued + if self.command_pid <= 0 || self.resume_after_suspend().is_err() { + return None; + } + Some(self.term_raw) + } + + /// Set the terminals up again when dosr is continued after a suspension. + fn resume_after_suspend(&mut self) -> io::Result<()> { + self.check_foreground()?; + // the user may have changed the terminal settings while dosr was stopped + self.pipe.left().copy_to(self.pipe.right())?; + if self.foreground { + if self + .pipe + .left_mut() + .set_raw_mode(true, self.preserve_oflag) + .is_ok() + { + self.term_raw = true; + } + } else { + // in the background, dosr cannot use the terminal + self.term_raw = false; + } + Ok(()) + } + + /// Check whether dosr is in the foreground of the user's terminal. + fn check_foreground(&mut self) -> io::Result<()> { + let pgrp = self.pipe.left().as_tty()?.tcgetpgrp()?; + self.foreground = pgrp == self.parent_pgrp; + Ok(()) + } + fn resume_terminal(&mut self) -> io::Result<()> { if self.term_raw && self.foreground { debug!( @@ -349,10 +456,7 @@ pub fn run_no_pty( return Ok(status); } } - libc::SIGALRM => { - // SAFETY: kill does not access memory, it only sends a signal to the command - unsafe { libc::kill(child_pid, libc::SIGKILL) }; - } + libc::SIGALRM => terminate_process(child_pid, false), libc::SIGWINCH => { // SAFETY: kill does not access memory, it only sends a signal to the command unsafe { libc::kill(child_pid, libc::SIGWINCH) }; @@ -387,9 +491,23 @@ pub fn run( command: Command, orchestrator: Orchestrator, logger: Option>, +) -> io::Result { + run_with_pty_setup(command, orchestrator, logger, |_| Ok(())) +} + +/// Like [`run`], and when the command gets a pty, `pty_setup` prepares it before the command +/// starts, for example to give it to the user that the command runs as. +/// +/// # Errors +/// Returns the errors of [`run`], and the error of `pty_setup`, before the command starts. +pub fn run_with_pty_setup( + command: Command, + orchestrator: Orchestrator, + logger: Option>, + pty_setup: impl FnOnce(&Pty) -> io::Result<()>, ) -> io::Result { match UserTerm::open() { - Ok(term) => run_with_pty(command, orchestrator, logger, term), + Ok(term) => run_in_pty(command, orchestrator, logger, term, pty_setup), Err(_) => run_no_pty(command, orchestrator), } } @@ -416,12 +534,22 @@ pub fn run( /// - Failure in the backchannel communication /// - Failure in the orchestrator's pre-exec function /// - Failure in spawning the command execution -#[allow(clippy::too_many_lines)] pub fn run_with_pty( + command: Command, + orchestrator: Orchestrator, + logger: Option>, + user_term: UserTerm, +) -> io::Result { + run_in_pty(command, orchestrator, logger, user_term, |_| Ok(())) +} + +#[allow(clippy::too_many_lines)] +fn run_in_pty( command: Command, orchestrator: Orchestrator, logger: Option>, mut user_term: UserTerm, + pty_setup: impl FnOnce(&Pty) -> io::Result<()>, ) -> io::Result { let original_set = block_all_signals(); @@ -444,6 +572,11 @@ pub fn run_with_pty( // Create Pty let pty = Pty::open()?; + if let Err(e) = pty_setup(&pty) { + // the caller goes on without the command: its signals are delivered again + restore_signals(original_set); + return Err(e); + } let tty_size = user_term.get_size().unwrap_or_else(|_| TermSize::new(0, 0)); pty.leader.set_size(&tty_size)?; // set window size by default, resizing comes with features debug!("runner(pty): initial tty size {tty_size}"); @@ -645,6 +778,42 @@ pub fn run_with_pty( } } +/// Changes the action of a signal until it is dropped, then puts the previous action back. +struct SignalDisposition { + signal: libc::c_int, + previous: libc::sigaction, +} + +impl SignalDisposition { + fn set(signal: libc::c_int, action: libc::sighandler_t) -> io::Result { + // SAFETY: sigaction is a plain C struct, for which all zeroes is a valid value + let mut new: libc::sigaction = unsafe { std::mem::zeroed() }; + new.sa_sigaction = action; + // SAFETY: sigaction is a plain C struct, for which all zeroes is a valid value + let mut previous: libc::sigaction = unsafe { std::mem::zeroed() }; + // SAFETY: both pointers reference valid sigaction structs that outlive the call + if unsafe { libc::sigaction(signal, &raw const new, &raw mut previous) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(Self { signal, previous }) + } +} + +impl Drop for SignalDisposition { + fn drop(&mut self) { + // SAFETY: `previous` was filled by sigaction for this signal, and outlives the call + if unsafe { libc::sigaction(self.signal, &raw const self.previous, std::ptr::null_mut()) } + == -1 + { + warn!( + "runner: cannot restore the action of signal {}: {}", + self.signal, + io::Error::last_os_error() + ); + } + } +} + fn block_all_signals() -> Option { // SAFETY: sigset_t is a plain bitmask, for which all zeroes is a valid value let mut set = unsafe { std::mem::zeroed::() }; @@ -672,7 +841,7 @@ fn restore_signals(original: Option) { #[allow(clippy::undocumented_unsafe_blocks)] #[cfg(test)] mod tests { - use super::{SimpleFileLogger, run_no_pty}; + use super::{SimpleFileLogger, run_no_pty, run_with_pty_setup}; use crate::MUTEX; use crate::orchestrator::{Orchestrator, PreExecContext, PreExecStep, Stage}; use crate::pipe::io_logger_sealed::Sealed; @@ -791,6 +960,29 @@ mod tests { let _ = fs::remove_file(path); } + // suspending dosr sets SIGCONT to ignored and the stop signal to its default action, and must + // put the previous actions back once dosr is continued + #[test] + fn signal_disposition_restores_the_previous_action() { + fn action(signal: libc::c_int) -> libc::sighandler_t { + let mut current: libc::sigaction = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::sigaction(signal, std::ptr::null(), &raw mut current) }, + 0 + ); + current.sa_sigaction + } + + let _guard = MUTEX.lock().unwrap(); + let before = action(libc::SIGUSR2); + { + let _ignored = super::SignalDisposition::set(libc::SIGUSR2, libc::SIG_IGN) + .expect("ignore SIGUSR2"); + assert_eq!(action(libc::SIGUSR2), libc::SIG_IGN); + } + assert_eq!(action(libc::SIGUSR2), before); + } + #[test] fn run_no_pty_returns_command_exit_code() { let _guard = MUTEX.lock().unwrap(); @@ -813,6 +1005,51 @@ mod tests { assert!(status.success()); } + /// Whether this thread blocks each of the standard signals. + fn signal_mask() -> Vec { + let mut set: libc::sigset_t = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::pthread_sigmask(libc::SIG_BLOCK, std::ptr::null(), &raw mut set) }, + 0 + ); + (1..libc::SIGRTMIN()) + .map(|signal| unsafe { libc::sigismember(&raw const set, signal) } == 1) + .collect() + } + + // with a terminal, a failed setup of the pty stops before the command starts; without one, + // there is no pty to set up and the command runs + #[test] + fn run_with_pty_setup_stops_on_a_failed_setup() { + let _guard = MUTEX.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("rar-pty-setup-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create the test directory"); + let marker = dir.join("ran"); + let mut command = Command::new("/usr/bin/touch"); + command.arg(&marker); + let mask_before = signal_mask(); + let mut called = false; + let result = run_with_pty_setup(command, Orchestrator::new(NOOP_STEPS), None, |pty| { + called = true; + assert!(!pty.path.as_bytes().is_empty()); + Err(io::Error::other("setup refused")) + }); + if called { + let error = result.expect_err("the setup error is returned"); + assert_eq!(error.to_string(), "setup refused"); + assert!(!marker.exists(), "the command did not start"); + assert_eq!( + signal_mask(), + mask_before, + "the signals are delivered again" + ); + } else { + assert!(result.expect("run without a pty").success()); + assert!(marker.exists(), "the command ran without a pty"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn run_no_pty_propagates_pre_exec_failure() { let _guard = MUTEX.lock().unwrap(); diff --git a/exec/src/signal.rs b/exec/src/signal.rs index 9c582ae7..bf03dd34 100644 --- a/exec/src/signal.rs +++ b/exec/src/signal.rs @@ -229,6 +229,35 @@ pub fn register_signal_handler(signal: SignalNumber) -> io::Result<()> { Ok(()) } +/// How long a command is given to end after `SIGHUP` and `SIGTERM`, before `SIGKILL`, like sudo. +pub const TERMINATION_DELAY: std::time::Duration = std::time::Duration::from_secs(2); + +/// Stop a command with increasing urgency, like sudo's `terminate_command`. +/// +/// `SIGHUP`, `SIGTERM`, then `SIGKILL` once [`TERMINATION_DELAY`] has passed, so that the command +/// can clean up first. With `process_group`, the signals go to the process group `pid` instead of +/// the process. +pub fn terminate_process(pid: libc::pid_t, process_group: bool) { + // kill(0) and killpg(0) would signal dosr's own group, kill(-1) every process it may signal + if pid <= 0 { + return; + } + let send = |signal| { + // SAFETY: kill and killpg do not access memory, they only send a signal + unsafe { + if process_group { + libc::killpg(pid, signal) + } else { + libc::kill(pid, signal) + } + }; + }; + send(libc::SIGHUP); + send(libc::SIGTERM); + std::thread::sleep(TERMINATION_DELAY); + send(libc::SIGKILL); +} + #[allow(clippy::undocumented_unsafe_blocks)] #[cfg(test)] mod tests { @@ -236,6 +265,59 @@ mod tests { MUTEX, signal::{self, SignalStream}, }; + use std::os::unix::process::{CommandExt, ExitStatusExt}; + use std::process::Command; + use std::time::{Duration, Instant}; + + /// A shell in its own process group, given time to set its traps up. + fn spawn_shell(script: &str) -> (std::process::Child, libc::pid_t) { + let child = Command::new("sh") + .arg("-c") + .arg(script) + .process_group(0) + .spawn() + .expect("spawn the shell"); + std::thread::sleep(Duration::from_millis(300)); + let pid = libc::pid_t::try_from(child.id()).expect("pid in range"); + (child, pid) + } + + // A command that handles SIGHUP or SIGTERM cleans up and ends before SIGKILL. + #[test] + fn terminate_process_lets_the_command_clean_up() { + let _guard = MUTEX.lock().unwrap(); + let marker = + std::env::temp_dir().join(format!("rar-terminate-cleanup-{}", std::process::id())); + let _ = std::fs::remove_file(&marker); + let (mut child, pid) = spawn_shell(&format!( + "trap 'echo done > {}; exit 3' HUP TERM; while :; do sleep 0.1; done", + marker.display() + )); + + signal::terminate_process(pid, true); + let status = child.wait().expect("wait for the shell"); + let cleaned = marker.exists(); + let _ = std::fs::remove_file(&marker); + assert_eq!( + status.code(), + Some(3), + "the shell ended on its own: {status:?}" + ); + assert!(cleaned, "the shell cleaned up"); + } + + // A command that ignores SIGHUP and SIGTERM is killed once the delay has passed. + #[test] + fn terminate_process_kills_a_command_that_ignores_the_signals() { + let _guard = MUTEX.lock().unwrap(); + let (mut child, pid) = spawn_shell("trap '' HUP TERM; while :; do sleep 0.1; done"); + + let start = Instant::now(); + signal::terminate_process(pid, true); + let status = child.wait().expect("wait for the shell"); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert!(start.elapsed() >= signal::TERMINATION_DELAY); + } #[test] fn test_signals_combined() { diff --git a/exec/src/terminal.rs b/exec/src/terminal.rs index 186b710a..c5dd6b4a 100644 --- a/exec/src/terminal.rs +++ b/exec/src/terminal.rs @@ -561,4 +561,15 @@ mod tests { .expect("ttyname should work on pty follower"); assert_eq!(ttyname.as_os_str().as_bytes(), pty.path.as_bytes()); } + + // A stdio terminal that is not the controlling terminal of dosr is not the user's terminal: + // it is given to the command as it is, never proxied through the pty nor set to raw mode. + #[test] + fn test_a_terminal_that_is_not_ours_is_not_the_user_terminal() { + let pty = Pty::open().expect("Failed to open PTY"); + assert!(terminal::safe_isatty(&pty.follower)); + // SAFETY: getpgrp does not access memory + let pgrp = ProcessId::new(unsafe { libc::getpgrp() }); + assert!(!pty.follower.is_terminal_for_pgrp(pgrp)); + } } diff --git a/resources/arch/PKGBUILD b/resources/arch/PKGBUILD deleted file mode 100644 index 78772780..00000000 --- a/resources/arch/PKGBUILD +++ /dev/null @@ -1,62 +0,0 @@ -# Maintainer: Eddie Billoir - -pkgname=rootasrole -pkgver=4.1.0 -pkgrel=1 -pkgdesc='Alternative to sudo to run some administrative commands that uses Linux capabilities and RBAC for scalability.' -url='https://lechatp.github.io/RootAsRole/' -license=('LGPL-3.0-or-later') -arch=('x86_64') -depends=('libcap' 'e2fsprogs' 'pam') -makedepends=('cargo') -backup=('etc/pam.d/dosr') -validpgpkeys=('74F43C5774BE1F3527DEFA4835C155EA0525104D') -source=("$pkgname-$pkgver.tar.gz::https://static.crates.io/crates/$pkgname/$pkgname-$pkgver.crate") -sha256sums=('SKIP') -install=rootasrole.install - -prepare() { - cd "$pkgname-$pkgver" - export RUSTUP_TOOLCHAIN=stable - cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" -} - -build() { - cd "$pkgname-$pkgver" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo build --frozen --release --all-features -} - -check() { - cd "$pkgname-$pkgver" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo test --frozen --all-features -} - -package() { - cd "$pkgname-$pkgver" - install -Dm755 'target/release/dosr' -t "$pkgdir/usr/bin" - install -Dm755 'target/release/chsr' -t "$pkgdir/usr/bin" - install -Dm644 'resources/arch/arch_sr_pam.conf' "$pkgdir/etc/pam.d/dosr" - # the root file and the default policy, installed by rootasrole.install, - # which also sets the administrator of the default policy - install -dm755 "$pkgdir/usr/share/rootasrole" - sed "s/PACKAGE_VERSION/$pkgver/" 'resources/rootasrole.json' \ - > "$pkgdir/usr/share/rootasrole/default.json" - cat > "$pkgdir/usr/share/rootasrole/rootasrole.json" < "$POLICY_FILE" - chmod 600 "$POLICY_FILE" - log "The default policy lets $admin run every command with dosr, and edit the policy with chsr." - - # dosr refuses a policy file without the immutable flag when the policy is immutable - FS_TYPE=$(df -T "$POLICY_DIR" | awk 'NR==2 {print $2}') - case "$FS_TYPE" in - ext2 | ext3 | ext4 | xfs | btrfs | ocfs2 | jfs | reiserfs) - if chattr +i "$ROOT_FILE" "$POLICY_FILE" && chattr +i "$POLICY_DIR"; then - return 0 - fi - log "Failed to set the immutable flag on the policy" - chattr -i "$POLICY_DIR" "$POLICY_FILE" "$ROOT_FILE" 2>/dev/null - ;; - *) - log "The file system $FS_TYPE does not support the immutable flag." - ;; - esac - # without the flag, the policy is not declared immutable and no task needs to change it - sed -i 's/"immutable": true/"immutable": false/' "$ROOT_FILE" - sed -i 's;"CAP_LINUX_IMMUTABLE";;g' "$POLICY_FILE" -} - -post_upgrade() { - set_capabilities - if [ -f "$ROOT_FILE" ] && ! chsr upgrade; then - log "Some policy files could not be upgraded, check them with 'chsr upgrade --check'" - fi -} diff --git a/resources/man/en_US.md b/resources/man/en_US.md index f4362237..be938aa2 100644 --- a/resources/man/en_US.md +++ b/resources/man/en_US.md @@ -7,6 +7,7 @@ RootAsRole - An alternative to sudo/su commands that adheres to the principle of # SYNOPSIS - **dosr** [__OPTIONS__] [__COMMAND__]... +- **dosr** [__OPTIONS__] -- [__COMMAND__]... - **chsr** [__ARGUMENTS__] **chsr**'s arguments follow a grammar available at @@ -28,10 +29,12 @@ The **chsr** command is used to configure RootAsRole and its access control poli The storage mode of the access control policy can be configured. By default, RootAsRole uses a JSON file. It is possible to change the storage mode by manually modifying the **/etc/security/rootasrole.json** file. -Regarding authentication, RootAsRole uses Pluggable Authentication Module (PAM). The **/etc/pam.d/dosr** file can be configured to change authentication behavior. +Regarding authentication, RootAsRole uses Pluggable Authentication Module (PAM). The **/etc/pam.d/dosr** file can be configured to change authentication behavior. Also it is possible to configure the number of password attempts and the delay between them in the RootAsRole policy. The core of RootAsRole implements RBAC-0, a simplified version of RBAC. By default, it adds features in the form of plugins to implement certain RBAC-1 functionalities. RBAC-0 simply implements roles, tasks, and permissions. Plugins add role hierarchy and separation of duties. Plugins can only be implemented directly in the project. Another plugin allows testing the checksum of executed files. +Finally, for observing the usage of RaR tool on your system, you can use the `journalctl -t dosr` command to view the logs of the `dosr` service. This will provide information about the execution of commands and any errors that may have occurred. + # OPTIONS **\-r, --role** <ROLE> @@ -84,7 +87,7 @@ You can find the history of RootAsRole on the website to determine the required capabilities for a command. However, this tool might give too many capabilities. It is recommended to verify if the capabilities are truly necessary, as in most cases, they are not. It is discouraged to use "capable" in production, as it is only for testing purposes. diff --git a/resources/man/fr_FR.md b/resources/man/fr_FR.md index ce6b3d57..0690c028 100644 --- a/resources/man/fr_FR.md +++ b/resources/man/fr_FR.md @@ -7,6 +7,7 @@ RootAsRole - Une alternative pour les commandes sudo/su respectant le principe d # SYNOPSIS - **dosr** [__OPTIONS__] [__COMMAND__]... +- **dosr** [__OPTIONS__] -- [__COMMAND__]... - **chsr** [__ARGUMENTS__] Les arguments suivent une grammaire disponible dans le code source à l'adresse @@ -27,10 +28,12 @@ La commande **chsr** sert à configurer RootAsRole et sa politique de contrôle Il est possible de configurer le mode de stockage de la politique de contrôle d'accès. Par défaut, RootAsRole utilise un fichier JSON. Il est possible de changer le mode de stockage en modifiant manuellement le fichier **/etc/security/rootasrole.json**. -Concernant l'authentification, RootAsRole utilise PAM. Il est possible de configurer le fichier **/etc/pam.d/dosr** pour changer le comportement de l'authentification. +Concernant l'authentification, RootAsRole utilise PAM. Il est possible de configurer le fichier **/etc/pam.d/dosr** pour changer le comportement de l'authentification. Il est également possible de configurer le nombre de tentatives de mot de passe et le délai entre elles dans la politique de RootAsRole. Le coeur de RootAsRole implémente RBAC-0, une version simplifiée de RBAC. Par défaut il ajoute des fonctionnalités sous forme de plugins pour implémenter certaines fonctionnalités de RBAC-1. RBAC-0 implémente simplement les rôles, les tâches et les permissions. Les plugins ajoutent la hiérarchie de rôles et séparation des devoirs. Les plugins sont uniquement implémentable directement dans le projet. Il y a également un autre plugin qui permet de tester la somme de contrôle des fichiers exécutés. +Enfin, pour observer l'utilisation de l'outil RaR sur votre système, vous pouvez utiliser la commande `journalctl -t dosr` pour voir les logs du service `dosr`. Cela vous donnera des informations sur l'exécution des commandes et les éventuelles erreurs qui ont pu se produire. + # OPTIONS **\-r, --role** <ROLE> @@ -83,7 +86,7 @@ Vous pouvez trouver l'historique de RootAsRole sur le site web pour déterminer les capabilities nécessaires pour une commande. Cependant, il est également possible que cette commande donne trop de capabilities. Il est donc recommandé de vérifier si les capabilities sont bien nécessaires car dans la plupart des cas, les capabilities ne sont pas nécessaires. Il est fortement déconseillé d'utiliser cet outil en production. diff --git a/resources/opensuse/opensuse.conf b/resources/opensuse/opensuse.conf index b5ebcb11..bc1e317f 100644 --- a/resources/opensuse/opensuse.conf +++ b/resources/opensuse/opensuse.conf @@ -3,4 +3,4 @@ auth include common-auth account include common-account password include common-password session optional pam_keyinit.so revoke -session include common-session-nonlogin \ No newline at end of file +session include common-session-nonlogin diff --git a/resources/rh/postinst.sh b/resources/rh/postinst.sh deleted file mode 100644 index 1267b1ed..00000000 --- a/resources/rh/postinst.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -if [ -z ${SUDO_USER+x} ]; then INSTALL_USER=$(id -urn); else INSTALL_USER=$SUDO_USER; fi - -filesystem() { - df -T "$1" | awk 'NR==2 {print $2}' -} - -configure() { - echo "Configuring rootasrole.json" - sed -i "s/ROOTADMINISTRATOR/$INSTALL_USER/g" /etc/security/rootasrole.json - FS=$(filesystem /etc/security/rootasrole.json) - case $FS in - "ext2" | "ext3" | "ext4" | "xfs" | "btrfs" | "ocfs2" | "jfs" | "reiserfs") - echo "Setting immutable attribute on /etc/security/rootasrole.json" - chattr +i /etc/security/rootasrole.json - ;; - *) - echo "filesystem $FS does not support immutable attribute" - echo "Removing immutable parameter from /etc/security/rootasrole.json" - sed -i "s/\"immutable\": true/\"immutable\": false/g" /etc/security/rootasrole.json - sed -i "s;\"CAP_LINUX_IMMUTABLE\";;g" /etc/security/rootasrole.json - ;; - esac -} - -if [ -f /etc/security/rootasrole.json ]; then - if grep -q "ROOTADMINISTRATOR" /etc/security/rootasrole.json; then - configure - elif ! chsr upgrade; then - echo "Some policy files could not be upgraded, check them with 'chsr upgrade --check'" - fi -fi diff --git a/resources/rh/prerm.sh b/resources/rh/prerm.sh deleted file mode 100644 index c0a05adb..00000000 --- a/resources/rh/prerm.sh +++ /dev/null @@ -1 +0,0 @@ -chattr -i /etc/security/rootasrole.json 2>/dev/null || true \ No newline at end of file diff --git a/resources/rh/rh_sr_pam.conf b/resources/rh/rh_sr_pam.conf index 1f03d24b..fcb7a466 100644 --- a/resources/rh/rh_sr_pam.conf +++ b/resources/rh/rh_sr_pam.conf @@ -2,4 +2,4 @@ auth include system-auth account include system-auth password include system-auth -session include system-auth \ No newline at end of file +session include system-auth diff --git a/src/chsr/cli/cli.pest b/src/chsr/cli/cli.pest index 10af03b3..946fced2 100644 --- a/src/chsr/cli/cli.pest +++ b/src/chsr/cli/cli.pest @@ -72,12 +72,20 @@ policy_path = @{ name } role = { ("role" | "r") ~ roles_operations } roles_operations = { purge | role_operations } -role_operations = { role_id ~ ( add | del | role_show_purge | role_grant_revoke | tasks_operations | options_operations) } +role_operations = { role_id ~ ( add | del | role_show_purge | role_grant_revoke | role_links | tasks_operations | options_operations) } role_id = @{ name } role_grant_revoke = { (grant | revoke) ~ user_or_groups } role_show_purge = { (list|purge) ~ role_type_arg? } role_type_arg = @{ "actors" | "tasks" | all } +// chsr r r1 ssd add r2,r3 the roles that a member of r1 must not hold (ssd plugin) +// chsr r r1 parents set r2 the roles whose tasks r1 gets (hierarchy plugin) +// chsr r r1 ssd purge +role_links = { role_link_kind ~ ((add | del | set) ~ linked_roles | purge) } +role_link_kind = { "ssd" | "parents" } +linked_roles = { linked_role ~ ("," ~ linked_role)* } +linked_role = @{ (!("," | WHITESPACE) ~ ANY)+ } + user_or_groups = { (user | group)+ } user = ${ ("--user" ~ assignment | "-u" ~ WHITESPACE+) ~ actor_name } @@ -95,7 +103,13 @@ actor_name = @{ (CASED_LETTER | "_") ~ ((CASED_LETTER | ASCII_DIGIT // chsr r r1 t t1 del tasks_operations = { task_spec ~ (task_operations | options_operations) } -task_operations = { task_show_purge | add | del | command_operations | credentials_operations } +task_operations = { task_show_purge | add | del | command_operations | credentials_operations | purpose_operations } + +// chsr r r1 t t1 purpose set Back up the databases +// chsr r r1 t t1 purpose unset +purpose_operations = _{ purpose_keyword ~ (set ~ purpose_text | del) } +purpose_keyword = { "purpose" } +purpose_text = ${ ANY+ } task_show_purge = { (list | purge) ~ task_type_arg? } task_type_arg = { all | cmd_keyword | cred_keyword } @@ -116,8 +130,15 @@ command_operations = _{ cmd_keyword ~ (cmd_setpolicy | cmd_checklisting) } cmd_setpolicy = { setpolicy ~ cmd_policy } cmd_policy = { "allow-all" | "deny-all" } -cmd_checklisting = { (whitelist | blacklist) ~ ((add | del) ~ cmd | purge) } -cmd = ${ ANY+ } +cmd_checklisting = { (whitelist | blacklist) ~ ((add | del) ~ cmd_checks? ~ cmd | purge) } +// chsr r r1 t t1 cmd whitelist add --sha256 --read-only /usr/bin/ls (hashchecker plugin) +cmd_checks = { (cmd_hash | cmd_check_flag)+ } +cmd_hash = ${ cmd_hash_type ~ ("=" | WHITESPACE+) ~ cmd_hash_value } +cmd_hash_type = { "--sha224" | "--sha256" | "--sha384" | "--sha512" } +cmd_hash_value = @{ (!WHITESPACE ~ ANY)+ } +cmd_check_flag = { "--read-only" | "--immutable" } +// a command never starts with a check: a malformed check is an error, not part of the command +cmd = ${ !("--sha" | "--read-only" | "--immutable") ~ ANY+ } // ======================== // credentials @@ -130,12 +151,15 @@ cred_keyword = { ("credentials" | "cred") } credentials_operations = _{ cred_keyword ~ (cred_set_operations | cred_caps_operations | list) } cred_set_operations = _{ (set | del) ~ cred_set_args } cred_set_args = _{ - (cred_c | cred_u | cred_g)+ + (cred_c | cred_u | cred_from | cred_g)+ | help } cred_c = ${ ("--caps" ~ assignment | "-c" ~ WHITESPACE+) ~ capabilities? } cred_u = ${ ("--setuid" ~ assignment | "-u" ~ WHITESPACE+) ~ actor_name } cred_g = ${ ("--setgid" ~ assignment | "-g" ~ WHITESPACE+) ~ name_combination } +// before cred_g, which --setgid-from starts like +cred_from = ${ "--setgid-from" ~ ("=" | WHITESPACE+) ~ groups_from } +groups_from = { "caller" | "target" } capabilities = _{ capability ~ (","?) ~ capabilities | capability } capability = @{ ^"CAP_"? ~ (LETTER | "_")+ } @@ -168,11 +192,17 @@ caps_listing = { (whitelist | blacklist) ~ ((add | del | set) ~ capabilities // chsr o timeout set --type tty --duration 5:00 --max_usage 1 // chsr o t unset --type --duration --max_usage +// chsr o password set --tries 3 --delay 00:00:05 +// chsr o password unset --tries --delay + +// chsr o tty owner (target|caller|unset) + options_operations = { ("options" | "o") ~ opt_args } -opt_args = _{ opt_show | opt_path | opt_workdir | opt_env | opt_root | opt_bounding | opt_timeout | opt_skip_auth | opt_execinfo | opt_mask } +opt_args = _{ opt_show | opt_path | opt_workdir | opt_env | opt_root | opt_bounding | opt_timeout | opt_password | opt_tty | opt_skip_auth | opt_execinfo | opt_mask } opt_show = _{ list ~ opt_show_arg? } -opt_show_arg = { "all" | "cmd" | "cred" | "path" | "env" | "root" | "bounding" | "timeout" } +// a longer name before its prefix: "authentication" before "auth" +opt_show_arg = { "all" | "path" | "env" | "root" | "bounding" | "timeout" | "password" | "tty" | "authentication" | "auth" | "execinfo" | "info" | "umask" | "mask" | "workdir" } opt_path = { "path" ~ (opt_path_args | help) } opt_path_args = _{ opt_path_setpolicy | opt_path_set | opt_path_listing } @@ -188,7 +218,10 @@ opt_workdir_listing = { (whitelist | blacklist) ~ (((add | del | set) ~ path) workdir_policy = { "all" | "none" | "inherit" } opt_env = { "env" ~ (opt_env_args | help) } -opt_env_args = _{ opt_env_setpolicy | opt_env_keep | opt_env_delete | opt_env_set | opt_env_listing | opt_env_setlisting } +opt_env_args = _{ opt_env_override | opt_env_setpolicy | opt_env_keep | opt_env_delete | opt_env_set | opt_env_listing | opt_env_setlisting } +// chsr o env override allow|deny|unset whether dosr -E may change the env behavior +opt_env_override = { "override" ~ (env_override_value | del) } +env_override_value = { "allow" | "deny" } opt_env_setpolicy = { setpolicy ~ env_policy } env_policy = { "delete-all" | "keep-all" | "inherit" } opt_env_listing = { (whitelist | blacklist | checklist) ~ (((add | del | set) ~ env_key_list) | purge) } @@ -207,7 +240,8 @@ env_unquoted_value = { (!"," ~ !"\"" ~ ANY)* } env_char = _{ (!"\\" ~ !"\"") ~ ANY } env_escape = _{ "\\" ~ ("\"" | ",") } -env_key = @{ (CASED_LETTER | "_") ~ (CASED_LETTER | ASCII_DIGIT | "-" | "_")+ } +// a variable name, where `*` matches any characters (core::util::is_valid_env_pattern) +env_key = @{ (ASCII_ALPHA | "_" | "*") ~ (ASCII_ALPHANUMERIC | "_" | "*")* } opt_root = { "root" ~ (opt_root_args | help) } opt_root_args = { del | "privileged" | "user" } @@ -221,6 +255,10 @@ opt_skip_auth_args = { del | "skip" | "perform" } opt_execinfo = { ( "execinfo" | "info") ~ (opt_execinfo_args | help) } opt_execinfo_args = { del | "show" | "hide" } +opt_tty = { "tty" ~ (opt_tty_owner | help) } +opt_tty_owner = { "owner" ~ opt_tty_owner_args } +opt_tty_owner_args = { del | "target" | "caller" } + opt_mask = { ( "umask" | "mask") ~ (opt_mask_args | help) } opt_mask_args = { ASCII_DIGIT{1,4} | del } @@ -243,6 +281,13 @@ seconds = _{ ASCII_DIGIT+ } opt_timeout_m_arg = { ("--max-usage" ~ assignment | "-m" ) ~ opt_timeout_max_usage? } opt_timeout_max_usage = { ASCII_DIGIT+ } +opt_password = { "password" ~ (opt_password_operations | help) } +opt_password_operations = { (set | del) ~ opt_password_args } +opt_password_args = _{ opt_password_t_arg ~ opt_password_d_arg? | opt_password_d_arg ~ opt_password_t_arg? } +opt_password_t_arg = { ("--tries" ~ assignment | "-t") ~ opt_password_tries? } +opt_password_tries = @{ ASCII_DIGIT+ } +opt_password_d_arg = { ("--delay" ~ assignment | "-d") ~ opt_password_delay? } +opt_password_delay = @{ ASCII_DIGIT+ ~ (":" ~ ASCII_DIGIT+){0,2} } assignment = _{ "=" | WHITESPACE*} help = { "-h" | "--help" } diff --git a/src/chsr/cli/data.rs b/src/chsr/cli/data.rs index 8f9762f0..6dbc066c 100644 --- a/src/chsr/cli/data.rs +++ b/src/chsr/cli/data.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::{collections::HashMap, num::NonZeroU8, path::PathBuf}; use bon::Builder; use capctl::CapSet; @@ -11,9 +11,9 @@ use rootasrole_core::{ actor::{SActor, SGroups, SUserType}, options::{ EnvBehavior, EnvKey, OptType, PathBehavior, SAuthentication, SBounding, SInfo, - SPrivileged, SUMask, TimestampType, WorkdirBehavior, + SPrivileged, SUMask, TimestampType, TtyOwner, WorkdirBehavior, }, - structs::{IdTask, SetBehavior}, + structs::{GroupsFrom, IdTask, SetBehavior}, }, util::StorageMethod, }; @@ -58,6 +58,49 @@ pub enum SetListType { Set, } +/// The links of a role to other roles, kept in the role for the plugins. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum RoleLink { + /// The roles that a member of the role must not hold (ssd plugin) + Ssd, + /// The roles whose tasks the role gets (hierarchy plugin) + Parents, +} + +impl RoleLink { + #[must_use] + pub const fn key(self) -> &'static str { + match self { + Self::Ssd => "ssd", + Self::Parents => "parents", + } + } +} + +/// Whether `dosr -E` may change the env behavior, for `chsr o env override`. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum EnvOverride { + Allow, + Deny, + Unset, +} + +/// The checks of the hashchecker plugin on the file of a command (deprecated, removed in 5.0). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct CommandChecks { + /// The hash type (`sha256`...) and the hash in hexadecimal + pub hash: Option<(String, String)>, + pub read_only: bool, + pub immutable: bool, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[repr(usize)] +pub enum PasswordOpt { + Tries = 0, + Delay, +} + #[derive(Debug, PartialEq, Eq, Clone, Copy)] #[repr(usize)] pub enum TimeoutOpt { @@ -78,6 +121,15 @@ pub struct Inputs { pub timeout_type: Option, pub timeout_duration: Option, pub timeout_max_usage: Option, + pub role_link: Option, + pub role_links: Option>, + pub purpose: bool, + pub task_purpose: Option, + pub cmd_checks: CommandChecks, + pub options_env_override: Option, + pub password_arg: Option<[bool; 2]>, + pub password_tries: Option, + pub password_delay: Option, pub policy: bool, pub policy_path: Option, pub role_id: Option, @@ -86,10 +138,13 @@ pub struct Inputs { pub task_id: Option, pub task_type: Option, pub cmd_policy: Option, - pub cmd_id: Option>, + /// The command line to write in the policy, see `policy_command_line` + pub cmd_id: Option, pub cred_caps: Option, pub cred_setuid: Option, pub cred_setgid: Option, + /// Whose groups the command gets when the task does not force them + pub cred_groups_from: Option, pub cred_policy: Option, pub options: bool, pub options_type: Option, @@ -102,6 +157,7 @@ pub struct Inputs { pub options_bounding: Option, pub options_auth: Option, pub options_execinfo: Option, + pub options_tty_owner: Option, pub options_umask: Option, pub options_workdir_policy: Option, pub convertion: Option, diff --git a/src/chsr/cli/editor.rs b/src/chsr/cli/editor.rs index 5c1bf376..6adcb767 100644 --- a/src/chsr/cli/editor.rs +++ b/src/chsr/cli/editor.rs @@ -176,7 +176,7 @@ where new_config.warn_anomalies(&mut warn_handler); debug!("config: {new_config:#?}"); let after = serde_json::to_string_pretty(&new_config)?; - writeln!(output, "Resulting confguration: {after}")?; + writeln!(output, "Resulting configuration: {after}")?; let after = serde_json::from_str::(&after)?; debug!("re-serialised: {after:#?}"); // Yes == save, No and edit again == continue loop, abort == return false @@ -207,7 +207,7 @@ where Err(e) => { writeln!(output, "Your modifications are invalid:\n{e}")?; writeln!(output, "Do you want to continue editing?")?; - writeln!(output, " [Y]ontinue editing (Recommended)")?; + writeln!(output, " [Y]es to continue editing (Recommended)")?; writeln!(output, " [A]bort to exit without saving")?; write!(output, "Your choice [Y/a]: ")?; output.flush()?; diff --git a/src/chsr/cli/mod.rs b/src/chsr/cli/mod.rs index 76135b41..93a19a9f 100644 --- a/src/chsr/cli/mod.rs +++ b/src/chsr/cli/mod.rs @@ -11,18 +11,42 @@ pub mod usage; use std::{error::Error, path::PathBuf}; use bon::builder; -use data::{Cli, Inputs, Rule}; +use data::{Cli, InputAction, Inputs, Rule}; use landlock::RulesetStatus; -use log::debug; +use log::{debug, warn}; use pair::recurse_pair; use pest::Parser; -use process::process_input; +use process::{EditorTarget, editor_target, process_input}; use rootasrole_core::file::FileSettings; use usage::print_usage; use crate::{cli::editor::start_editing, util::escape_parser_string_vec}; +/// What chsr does without the policy: print the usage for `--help`, or when the arguments +/// cannot be parsed. `None` when the arguments need the policy, which chsr then reads. +pub fn usage_without_policy(args: I) -> Option>> +where + I: IntoIterator, + S: AsRef, +{ + let args = escape_parser_string_vec(args); + let pairs = match Cli::parse(Rule::cli, &args) { + Ok(pairs) => pairs, + Err(e) => return Some(print_usage(e)), + }; + let mut inputs = Inputs::default(); + for pair in pairs { + if let Err(e) = recurse_pair(pair, &mut inputs) { + return Some(Err(e)); + } + } + (inputs.action == InputAction::Help).then(|| { + usage::help(); + Ok(false) + }) +} + #[builder] pub fn main( #[builder(start_fn)] storage: &mut FileSettings, @@ -51,17 +75,15 @@ where if ruleset == &RulesetStatus::NotEnforced { return Err("Editor mode requires landlock to be enforced.".into()); } - if let Some(path) = inputs.editor_path { - if let Some(storage) = storage.get(&path) { - return start_editing(&path, &mut *storage.as_ref().borrow_mut()); - } - let file = FileSettings::read_policy(&path, inputs.editor_type.unwrap_or_default())?; - return start_editing(&path, &mut *file.data.data.as_ref().borrow_mut()); + // the copy that the editor opens is always in the temporary folder, which Landlock allows + let folder = folder.ok_or("the editor needs a temporary folder")?; + if inputs.editor_type.is_some() { + warn!("--type is ignored: chsr editor edits a file of the policy, in its format"); } - return start_editing( - folder.expect("implementation error"), - &mut *storage.get_root_mut(), - ); + return match editor_target(storage, inputs.editor_path.as_deref())? { + EditorTarget::Root => start_editing(folder, &mut *storage.get_root_mut()), + EditorTarget::Policy(policy) => start_editing(folder, &mut *policy.borrow_mut()), + }; } process_input(storage, inputs) } @@ -954,6 +976,921 @@ mod tests { // chsr o workdir setpolicy (all|none|inherit) // chsr o workdir (whitelist|blacklist) (add|del|set|purge) /home/user/** + #[test] + fn test_usage_without_policy() { + // printed without the policy + assert!(matches!(usage_without_policy(["--help"]), Some(Ok(false)))); + assert!(matches!(usage_without_policy(["-h"]), Some(Ok(false)))); + assert!(matches!( + usage_without_policy(["not-a-command"]), + Some(Err(_)) + )); + // these need the policy + assert!(usage_without_policy(["list"]).is_none()); + assert!(usage_without_policy(["role", "r", "add"]).is_none()); + assert!(usage_without_policy(["upgrade"]).is_none()); + } + + /// The policy as saved in the file of the test `name`, which must load again (parsed, not + /// opened: the test still holds the lock of the file). + fn saved_policy(ctx: &mut TestContext, name: &str) -> Value { + ctx.settings.save_all().unwrap(); + let content = fs::read_to_string(format!("{RAR_CFG_PATH}.{name}")).unwrap(); + serde_json::from_str::(&content).expect("the saved policy loads again"); + serde_json::from_str(&content).unwrap() + } + + fn saved_role<'v>(saved: &'v Value, role: &str) -> &'v Value { + saved["roles"] + .as_array() + .unwrap() + .iter() + .find(|r| r["name"] == role) + .unwrap() + } + + fn saved_task<'v>(saved: &'v Value, role: &str, task: &str) -> &'v Value { + saved_role(saved, role)["tasks"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == task) + .unwrap() + } + + #[test] + fn test_new_options_hold_only_what_is_set() { + let name = "new_options_hold_only_what_is_set"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.settings.get_root().config.as_ref().borrow_mut().options = None; + ctx.assert_command_success("o bounding strict"); + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh o root user"); + ctx.assert_command_success("r fresh t t1 add"); + ctx.assert_command_success("r fresh t t1 o path whitelist add /opt/bin"); + let saved = saved_policy(&mut ctx, name); + // the other options stay inherited, not written with the built-in defaults + assert_eq!(saved["options"], serde_json::json!({"bounding": "strict"})); + assert_eq!( + saved_role(&saved, "fresh")["options"], + serde_json::json!({"root": "user"}) + ); + assert_eq!( + saved_task(&saved, "fresh", "t1")["options"], + serde_json::json!({"path": {"add": ["/opt/bin"]}}) + ); + } + + #[test] + fn test_list_edits_on_a_level_without_the_option() { + let name = "list_edits_on_a_level_without_the_option"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh t t1 add"); + ctx.assert_command_success("r fresh t t1 o env whitelist add MYVAR"); + ctx.assert_command_success("r fresh t t1 o env checklist add TZ"); + ctx.assert_command_success("r fresh o path blacklist add /tmp"); + // removing from a list that the level does not define changes nothing + ctx.assert_command_success("r fresh o env blacklist del LD_PRELOAD"); + ctx.assert_command_success("r fresh t t1 o path whitelist del /usr/bin"); + let saved = saved_policy(&mut ctx, name); + assert_eq!( + saved_task(&saved, "fresh", "t1")["options"], + serde_json::json!({"env": {"keep": ["MYVAR"], "check": ["TZ"]}}) + ); + assert_eq!( + saved_role(&saved, "fresh")["options"], + serde_json::json!({"path": {"sub": ["/tmp"]}}) + ); + } + + #[test] + fn test_options_timeout() { + let name = "options_timeout"; + let (mut ctx, _defer) = TestContext::new(name); + // set changes the fields given and keeps the others + ctx.assert_command_success("o timeout set --type tty"); + ctx.assert_command_success("o timeout set -d 5:00"); + assert_eq!( + saved_policy(&mut ctx, name)["options"]["timeout"], + serde_json::json!({"type": "tty", "duration": "00:05:00", "max_usage": 1}) + ); + // unset removes the fields named, then the option once nothing is left + ctx.assert_command_success("o timeout unset --max-usage"); + assert_eq!( + saved_policy(&mut ctx, name)["options"]["timeout"], + serde_json::json!({"type": "tty", "duration": "00:05:00"}) + ); + ctx.assert_command_success("o timeout unset --type --duration"); + assert!( + saved_policy(&mut ctx, name)["options"] + .get("timeout") + .is_none() + ); + // a field that this version does not know is kept + ctx.opt(Level::Role) + .as_ref() + .borrow_mut() + .timeout + .as_mut() + .unwrap() + .extra_fields + .insert("future".into(), Value::from(1)); + ctx.assert_command_success("r complete o timeout unset --type --duration --max-usage"); + ctx.assert_command_success("r complete o timeout set --max-usage 3"); + // a level without the option + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh o timeout set --type uid"); + // refused, without a change + for command in [ + "o timeout set --type", + "o timeout set --duration", + "o timeout set --max-usage", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + ctx.assert_command_no_change("r complete t t_complete o show timeout"); + let saved = saved_policy(&mut ctx, name); + assert!(saved["options"].get("timeout").is_none()); + assert_eq!( + saved_role(&saved, "complete")["options"]["timeout"], + serde_json::json!({"max_usage": 3, "future": 1}) + ); + assert_eq!( + saved_role(&saved, "fresh")["options"], + serde_json::json!({"timeout": {"type": "uid"}}) + ); + } + + #[test] + fn test_options_env_names() { + let name = "options_env_names"; + let (mut ctx, _defer) = TestContext::new(name); + // one letter and patterns, like the policy accepts + ctx.assert_command_success("o env whitelist add A"); + ctx.assert_command_success("o env whitelist add LC_*,*_PROXY"); + ctx.assert_command_success("o env setlist add Z=1"); + // a name that the policy refuses is an error, not a panic + for command in [ + "o env whitelist add MY-VAR", + "o env whitelist add 1VAR", + "o env setlist add BAD*=1", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + let saved = saved_policy(&mut ctx, name); + assert_eq!( + saved["options"]["env"]["keep"], + serde_json::json!(["env1", "env2", "A", "LC_*", "*_PROXY"]) + ); + assert_eq!(saved["options"]["env"]["set"]["Z"], "1"); + assert!(saved["options"]["env"]["set"].get("BAD*").is_none()); + } + + #[test] + fn test_options_show_every_option_at_every_level() { + let (mut ctx, _defer) = TestContext::new("options_show_every_option_at_every_level"); + for level in ["", "r complete ", "r complete t t_complete "] { + for option in [ + "", + "all", + "path", + "env", + "root", + "bounding", + "timeout", + "password", + "authentication", + "auth", + "execinfo", + "info", + "umask", + "mask", + "workdir", + ] { + ctx.assert_command_no_change(format!("{level}o show {option}").trim_end()); + } + // not options: refused, never a panic + for option in ["cmd", "cred"] { + assert!(ctx.run_command(&format!("{level}o show {option}")).is_err()); + } + } + } + + #[test] + fn test_missing_targets_are_errors() { + let (mut ctx, _defer) = TestContext::new("missing_targets_are_errors"); + for command in [ + "r complete t nope purge", + "r complete t nope show", + "r nope t t1 purge", + "r nope o root user", + "r complete t nope o root user", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + // purges that the grammar accepts, whatever they do, never panic + for command in [ + "r complete t t_complete cmd whitelist purge", + "r complete t t_complete cred caps whitelist purge", + "o workdir blacklist purge", + "o env setlist purge", + ] { + let _ = ctx.run_command(command); + } + } + + /// The paths of the fields named `future` in `value`. + fn future_fields(value: &Value, prefix: &str, out: &mut Vec) { + match value { + Value::Object(map) => { + for (key, value) in map { + let path = format!("{prefix}/{key}"); + if key == "future" { + out.push(path.clone()); + } + future_fields(value, &path, out); + } + } + Value::Array(items) => { + for (i, value) in items.iter().enumerate() { + future_fields(value, &format!("{prefix}[{i}]"), out); + } + } + _ => {} + } + } + + #[test] + fn test_unknown_fields_survive_a_save() { + let path = format!("{RAR_CFG_PATH}.unknown_fields_survive_a_save"); + let _cleanup = defer(|| { + let _ = fs::remove_file(format!("{RAR_CFG_PATH}.unknown_fields_survive_a_save")); + }); + // the credential sets (setuid, setgid, capabilities) refuse unknown fields, as dosr does + let policy = serde_json::json!({ + "version": rootasrole_core::PACKAGE_VERSION.to_string(), + "future": 1, + "storage": {"method": "json", "settings": {"path": path, "future": 1}, "future": 1}, + "options": {"future": 1, + "path": {"default": "delete", "add": ["/usr/bin"], "future": 1}, + "env": {"default": "delete", "keep": ["A"], "future": 1}, + "timeout": {"type": "tty", "future": 1}, + "password": {"tries": 3, "future": 1}, + "workdir": {"default": "all", "fallback": "/", "future": 1}}, + "roles": [{"name": "r", "future": 1, + "actors": [{"type": "user", "id": 0, "future": 1}, + {"type": "group", "groups": ["root"], "future": 1}], + "options": {"future": 1, "path": {"default": "keep-safe", "future": 1}}, + "tasks": [{"name": "t", "future": 1, + "cred": {"future": 1, + "setuid": {"default": "all", "fallback": "root"}, + "setgid": {"default": "all", "fallback": ["root"]}, + "capabilities": {"default": "none", "add": ["CAP_NET_RAW"]}}, + "commands": {"default": "none", "add": ["/usr/bin/ls"], "future": 1}, + "options": {"future": 1}}]}] + }); + fs::write(&path, serde_json::to_string_pretty(&policy).unwrap()).unwrap(); + let mut settings = + FileSettings::write_all(path.clone(), path.clone(), StorageMethod::JSON).unwrap(); + assert!( + main(&mut settings, "o bounding strict".split(' ')) + .call() + .unwrap() + ); + settings.save_all().unwrap(); + drop(settings); + let saved: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let (mut before, mut after) = (Vec::new(), Vec::new()); + future_fields(&policy, "", &mut before); + future_fields(&saved, "", &mut after); + assert_eq!(before.len(), 18); + let lost: Vec<_> = before + .iter() + .filter(|field| !after.contains(field)) + .collect(); + assert!(lost.is_empty(), "lost: {lost:?}"); + } + + #[test] + fn test_purge_returns_to_inheritance() { + let name = "purge_returns_to_inheritance"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("o workdir whitelist add /srv"); + ctx.assert_command_success("o workdir blacklist add /tmp"); + // the level no longer defines the list, the upper ones apply again + for command in [ + "o path whitelist purge", + "o path blacklist purge", + "o workdir whitelist purge", + "o env checklist purge", + "o env blacklist purge", + ] { + ctx.assert_command_success(command); + } + let policy = saved_policy(&mut ctx, name); + let options = &policy["options"]; + assert!(options["path"].get("add").is_none(), "{options}"); + assert!(options["path"].get("sub").is_none(), "{options}"); + assert!(options["workdir"].get("add").is_none(), "{options}"); + assert_eq!(options["workdir"]["sub"], serde_json::json!(["/tmp"])); + assert!(options["env"].get("check").is_none(), "{options}"); + assert!(options["env"].get("delete").is_none(), "{options}"); + // on a level without the option, nothing changes + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh o path blacklist purge"); + ctx.assert_command_success("r fresh o workdir whitelist purge"); + let policy = saved_policy(&mut ctx, name); + assert!(saved_role(&policy, "fresh").get("options").is_none()); + } + + #[test] + fn test_env_keep_only_keeps_the_other_fields() { + let name = "env_keep_only_keeps_the_other_fields"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("o env keep-only A,B"); + let env = saved_policy(&mut ctx, name)["options"]["env"].clone(); + assert_eq!( + env, + serde_json::json!({ + "default": "delete", + "keep": ["A", "B"], + "check": ["env3", "env4"], + "delete": ["env5", "env6"], + "set": {"env7": "val7", "env8": "val8"} + }) + ); + ctx.assert_command_success("o env delete-only C"); + let env = saved_policy(&mut ctx, name)["options"]["env"].clone(); + assert_eq!(env["default"], "keep"); + assert_eq!(env["delete"], serde_json::json!(["C"])); + assert_eq!(env["keep"], serde_json::json!(["A", "B"])); + assert_eq!(env["check"], serde_json::json!(["env3", "env4"])); + } + + #[test] + fn test_simple_edits_are_saved() { + let name = "simple_edits_are_saved"; + let (mut ctx, _defer) = TestContext::new(name); + for command in [ + "o root user", + "o bounding strict", + "o auth skip", + "o info hide", + "o umask 077", + "r complete o root user", + "r complete o umask 027", + "r complete t t_complete o workdir set /srv", + "r complete t t_complete o auth perform", + "r complete grant -u alice -g staff&wheel", + "r complete t t_complete cred set --setuid root --setgid root,adm", + "r complete t t_complete cred caps whitelist add cap_net_raw", + "r complete t t_complete cmd whitelist add /usr/bin/id -u", + "r complete t t_complete cmd setpolicy deny-all", + "r complete t t2 add", + ] { + ctx.assert_command_success(command); + } + let saved = saved_policy(&mut ctx, name); + let global = &saved["options"]; + assert_eq!(global["root"], "user"); + assert_eq!(global["bounding"], "strict"); + assert_eq!(global["authentication"], "skip"); + assert_eq!(global["execinfo"], "hide"); + assert_eq!(global["umask"], "077"); + let role = saved_role(&saved, "complete"); + assert_eq!(role["options"]["root"], "user"); + assert_eq!(role["options"]["umask"], "027"); + let actors = role["actors"].as_array().unwrap(); + assert!(actors.contains(&serde_json::json!({"type": "user", "id": "alice"}))); + assert!( + actors.contains(&serde_json::json!({"type": "group", "groups": ["staff", "wheel"]})) + ); + let task = saved_task(&saved, "complete", "t_complete"); + assert_eq!(task["options"]["workdir"], "/srv"); + assert_eq!(task["options"]["authentication"], "perform"); + assert_eq!(task["cred"]["setuid"], "root"); + assert_eq!(task["cred"]["setgid"], serde_json::json!(["root", "adm"])); + assert!( + task["cred"]["capabilities"]["add"] + .as_array() + .unwrap() + .contains(&Value::from("NET_RAW")) + ); + assert_eq!(task["commands"]["default"], "none"); + assert!( + task["commands"]["add"] + .as_array() + .unwrap() + .contains(&Value::from("/usr/bin/id -u")) + ); + assert_eq!(saved_task(&saved, "complete", "t2")["name"], "t2"); + // deletions + for command in ["o root del", "o umask del", "r complete t t2 del"] { + ctx.assert_command_success(command); + } + let saved = saved_policy(&mut ctx, name); + assert!(saved["options"].get("root").is_none()); + assert!(saved["options"].get("umask").is_none()); + assert!( + saved_role(&saved, "complete")["tasks"] + .as_array() + .unwrap() + .iter() + .all(|task| task["name"] != "t2") + ); + } + + #[test] + fn test_cred_setuid_and_setgid_forms() { + let name = "cred_setuid_and_setgid_forms"; + let (mut ctx, _defer) = TestContext::new(name); + for (command, setuid, setgid) in [ + ( + "r complete t t_complete cred set -u root -g adm", + "root", + serde_json::json!("adm"), + ), + ( + "r complete t t_complete cred set --setuid=nobody --setgid=root&adm", + "nobody", + serde_json::json!(["root", "adm"]), + ), + ( + "r complete t t_complete cred set --setuid daemon --setgid root,adm,staff", + "daemon", + serde_json::json!(["root", "adm", "staff"]), + ), + ] { + ctx.assert_command_success(command); + let saved = saved_policy(&mut ctx, name); + let cred = &saved_task(&saved, "complete", "t_complete")["cred"]; + assert_eq!(cred["setuid"], setuid, "{command}"); + assert_eq!(cred["setgid"], setgid, "{command}"); + } + } + + #[test] + fn test_umask_is_octal() { + let (mut ctx, _defer) = TestContext::new("umask_is_octal"); + for command in ["o umask 999", "o umask 1000", "o umask 7777"] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + assert!(ctx.opt(Level::Global).as_ref().borrow().umask.is_none()); + } + + /// A root file pointing to a policy directory holding `files`, as the Debian package and + /// `cargo xtask install` lay them out. Returns the root file and the directory. + fn policy_directory( + name: &str, + files: &[(&str, Value)], + ) -> (String, String, impl Drop + use<>) { + let root = format!("{RAR_CFG_PATH}.{name}"); + let dir = format!("{RAR_CFG_PATH}.{name}.d"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let version = rootasrole_core::PACKAGE_VERSION.to_string(); + fs::write( + &root, + serde_json::to_string_pretty(&serde_json::json!({ + "version": version, + "storage": {"method": "json", "settings": {"path": dir, "immutable": false}} + })) + .unwrap(), + ) + .unwrap(); + for (file, policy) in files { + let mut policy = policy.clone(); + policy["version"] = Value::from(version.clone()); + fs::write( + format!("{dir}/{file}"), + serde_json::to_string_pretty(&policy).unwrap(), + ) + .unwrap(); + } + let (root_cleanup, dir_cleanup) = (root.clone(), dir.clone()); + let cleanup = defer(move || { + let _ = fs::remove_file(&root_cleanup); + let _ = fs::remove_dir_all(&dir_cleanup); + }); + (root, dir, cleanup) + } + + /// Run `command` with chsr on the policy of `root`, and save it like chsr does. + fn chsr_on(root: &str, command: &str) -> Result> { + let mut settings = + FileSettings::write_all(root.to_string(), root.to_string(), StorageMethod::JSON)?; + let changed = main(&mut settings, command.split(' ')).call()?; + if changed { + settings.save_all()?; + } + Ok(changed) + } + + fn role_names(path: &str) -> Vec { + let policy: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + policy["roles"] + .as_array() + .unwrap() + .iter() + .map(|role| role["name"].as_str().unwrap().to_string()) + .collect() + } + + #[test] + fn test_policy_directory_targets_one_file() { + let (root, dir, _cleanup) = policy_directory( + "policy_directory_targets_one_file", + &[ + ("default.json", serde_json::json!({"roles": []})), + ( + "extra.json", + serde_json::json!({"roles": [{"name": "x", "actors": [{"user": "root"}]}]}), + ), + ], + ); + let (default, extra) = (format!("{dir}/default.json"), format!("{dir}/extra.json")); + let root_before = fs::read_to_string(&root).unwrap(); + let extra_before = fs::read_to_string(&extra).unwrap(); + // without a file named, the role goes to default.json, the files that did not change + // are not written again (extra.json keeps its short form) + assert!(chsr_on(&root, "r newrole create").unwrap()); + assert_eq!(role_names(&default), ["newrole"]); + assert_eq!(fs::read_to_string(&root).unwrap(), root_before); + assert_eq!(fs::read_to_string(&extra).unwrap(), extra_before); + // a file named by its name in the directory, or by its path + let default_before = fs::read_to_string(&default).unwrap(); + assert!(chsr_on(&root, "file extra.json r other create").unwrap()); + assert!(chsr_on(&root, &format!("file {extra} r another create")).unwrap()); + assert_eq!(role_names(&extra), ["x", "other", "another"]); + assert_eq!(fs::read_to_string(&default).unwrap(), default_before); + assert!(chsr_on(&root, "file extra.json r x grant -u daemon").unwrap()); + // a file that is not one of the policy + assert!(chsr_on(&root, "file nope.json r y create").is_err()); + assert!(chsr_on(&root, "file /etc/passwd r y create").is_err()); + // listings change nothing + assert!(!chsr_on(&root, "list").unwrap()); + assert!(!chsr_on(&root, "file list").unwrap()); + assert!(!chsr_on(&root, "file extra.json list").unwrap()); + assert_eq!(fs::read_to_string(&default).unwrap(), default_before); + } + + #[test] + fn test_a_file_that_cannot_be_loaded_says_why() { + let (root, dir, _cleanup) = policy_directory( + "a_file_that_cannot_be_loaded_says_why", + &[("default.json", serde_json::json!({"roles": []}))], + ); + // written by hand without its version: dosr reads it, chsr does not edit it + fs::write(format!("{dir}/broken.json"), r#"{"roles": []}"#).unwrap(); + for name in ["broken.json".to_string(), format!("{dir}/broken.json")] { + let error = chsr_on(&root, &format!("file {name} r y create")) + .unwrap_err() + .to_string(); + assert!( + error.contains("cannot be loaded") && error.contains("version"), + "{error}" + ); + } + let error = chsr_on(&root, "file nope.json r y create") + .unwrap_err() + .to_string(); + assert!(error.contains("is not a file of the policy"), "{error}"); + } + + #[test] + fn test_editor_edits_the_file_of_the_other_commands() { + let (root, dir, _cleanup) = policy_directory( + "editor_edits_the_file_of_the_other_commands", + &[ + ("default.json", serde_json::json!({"roles": []})), + ("extra.json", serde_json::json!({"roles": []})), + ], + ); + let settings = + FileSettings::write_all(root.clone(), root.clone(), StorageMethod::JSON).unwrap(); + let file = |name: &str| { + Rc::clone( + settings + .get(std::path::Path::new(&format!("{dir}/{name}"))) + .unwrap(), + ) + }; + let edits = |path: Option<&str>, expected: &Rc>| match editor_target( + &settings, + path.map(std::path::Path::new), + ) + .unwrap() + { + EditorTarget::Policy(policy) => assert!(Rc::ptr_eq(&policy, expected), "{path:?}"), + EditorTarget::Root => panic!("{path:?} opened the root file"), + }; + // without a path, default.json of the directory, like chsr r newrole create + edits(None, &file("default.json")); + // another file, by its name or by its path + edits(Some("extra.json"), &file("extra.json")); + edits(Some(&format!("{dir}/extra.json")), &file("extra.json")); + // the root file, by its path + assert!(matches!( + editor_target(&settings, Some(std::path::Path::new(&root))).unwrap(), + EditorTarget::Root + )); + // a file that is not one of the policy is refused, instead of edited and never saved + assert!(editor_target(&settings, Some(std::path::Path::new("nope.json"))).is_err()); + assert!(editor_target(&settings, Some(std::path::Path::new("/etc/passwd"))).is_err()); + } + + #[test] + fn test_policy_directory_without_default_file() { + let (root, dir, _cleanup) = policy_directory( + "policy_directory_without_default_file", + &[ + ("a.json", serde_json::json!({"roles": []})), + ("b.json", serde_json::json!({"roles": []})), + ], + ); + // which file a role belongs to is not guessed + let error = chsr_on(&root, "r y create").unwrap_err().to_string(); + assert!(error.contains("chsr file"), "{error}"); + assert!(chsr_on(&root, "file b.json r y create").unwrap()); + assert_eq!(role_names(&format!("{dir}/b.json")), ["y"]); + assert!(role_names(&format!("{dir}/a.json")).is_empty()); + } + + #[test] + fn test_role_links() { + let name = "role_links"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("r auditor create"); + ctx.assert_command_success("r junior create"); + ctx.assert_command_success("r complete ssd add auditor"); + ctx.assert_command_success("r complete ssd add auditor,other"); + ctx.assert_command_success("r complete parents set junior"); + let saved = saved_policy(&mut ctx, name); + let role = saved_role(&saved, "complete"); + assert_eq!(role["ssd"], serde_json::json!(["auditor", "other"])); + assert_eq!(role["parents"], serde_json::json!(["junior"])); + // del and purge change the links, never the role (del also deletes a role) + ctx.assert_command_success("r complete ssd del other"); + ctx.assert_command_success("r complete parents purge"); + let saved = saved_policy(&mut ctx, name); + let role = saved_role(&saved, "complete"); + assert_eq!(role["ssd"], serde_json::json!(["auditor"])); + assert!(role.get("parents").is_none()); + // a role cannot exclude itself or be its own parent + assert!(ctx.run_command("r complete ssd add complete").is_err()); + assert!( + ctx.run_command("r complete parents add junior,complete") + .is_err() + ); + assert!(ctx.run_command("r nope ssd add auditor").is_err()); + } + + #[test] + fn test_task_purpose() { + let name = "task_purpose"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("r complete t t_complete purpose set Back up the \"databases\""); + let saved = saved_policy(&mut ctx, name); + assert_eq!( + saved_task(&saved, "complete", "t_complete")["purpose"], + "Back up the databases" + ); + // unset removes the purpose, never the task (unset also deletes a task) + ctx.assert_command_success("r complete t t_complete purpose unset"); + let saved = saved_policy(&mut ctx, name); + assert!( + saved_task(&saved, "complete", "t_complete") + .get("purpose") + .is_none() + ); + assert!(ctx.run_command("r complete t nope purpose set x").is_err()); + } + + #[test] + fn test_options_env_override() { + let name = "options_env_override"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("o env override allow"); + ctx.assert_command_success("r complete t t_complete o env override deny"); + ctx.assert_command_success("r fresh create"); + ctx.assert_command_success("r fresh o env override allow"); + let saved = saved_policy(&mut ctx, name); + assert_eq!(saved["options"]["env"]["override_behavior"], true); + assert_eq!( + saved_task(&saved, "complete", "t_complete")["options"]["env"]["override_behavior"], + false + ); + assert_eq!( + saved_role(&saved, "fresh")["options"], + serde_json::json!({"env": {"override_behavior": true}}) + ); + ctx.assert_command_success("o env override unset"); + assert!( + saved_policy(&mut ctx, name)["options"]["env"] + .get("override_behavior") + .is_none() + ); + } + + #[test] + fn test_commands_with_checks() { + let name = "commands_with_checks"; + let (mut ctx, _defer) = TestContext::new(name); + let hash = "a".repeat(64); + ctx.assert_command_success(&format!( + "r complete t t_complete cmd whitelist add --sha256 {hash} --read-only /usr/bin/id -u" + )); + ctx.assert_command_success( + "r complete t t_complete cmd blacklist add --immutable /usr/bin/rm", + ); + let saved = saved_policy(&mut ctx, name); + let commands = &saved_task(&saved, "complete", "t_complete")["commands"]; + assert!(commands["add"].as_array().unwrap().contains( + &serde_json::json!({"command": "/usr/bin/id -u", "sha256": hash, "read_only": true}) + )); + assert!( + commands["del"] + .as_array() + .unwrap() + .contains(&serde_json::json!({"command": "/usr/bin/rm", "immutable": true})) + ); + // the same command line in either form is the same command + assert!( + ctx.run_command("r complete t t_complete cmd whitelist add /usr/bin/id -u") + .is_err() + ); + ctx.assert_command_success("r complete t t_complete cmd whitelist del /usr/bin/id -u"); + let saved = saved_policy(&mut ctx, name); + let add = &saved_task(&saved, "complete", "t_complete")["commands"]["add"]; + assert!(!add.to_string().contains("/usr/bin/id -u"), "{add}"); + // a hash of the wrong length, or not hexadecimal + for command in [ + "r complete t t_complete cmd whitelist add --sha256 abcd /usr/bin/ls", + "r complete t t_complete cmd whitelist add --sha512 zz /usr/bin/ls", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + } + + /// The password option of `level`, as (tries, delay in seconds, unknown fields). + fn password(ctx: &TestContext, level: Level) -> Option<(Option, Option, usize)> { + ctx.opt(level) + .as_ref() + .borrow() + .password + .as_ref() + .map(|password| { + ( + password.tries.map(std::num::NonZeroU8::get), + password.delay.map(|delay| delay.as_secs()), + password.extra_fields.len(), + ) + }) + } + + #[test] + fn test_options_password() { + let name = "options_password"; + let (mut ctx, _defer) = TestContext::new(name); + // at each level; set changes the fields given and keeps the others + ctx.assert_command_success("o password set --tries 5 --delay 00:00:10"); + ctx.assert_command_success("r complete o password set --tries=4"); + ctx.assert_command_success("r complete t t_complete o password set -d 2"); + assert_eq!(password(&ctx, Level::Global), Some((Some(5), Some(10), 0))); + assert_eq!(password(&ctx, Level::Role), Some((Some(4), None, 0))); + assert_eq!(password(&ctx, Level::Task), Some((None, Some(2), 0))); + ctx.assert_command_success("o password set --delay 1:00"); + assert_eq!(password(&ctx, Level::Global), Some((Some(5), Some(60), 0))); + // unset removes the fields named, then the option once nothing is left + ctx.assert_command_success("o password unset --tries"); + assert_eq!(password(&ctx, Level::Global), Some((None, Some(60), 0))); + ctx.assert_command_success("o password unset -d"); + assert_eq!(password(&ctx, Level::Global), None); + // a field that this version does not know is kept + ctx.opt(Level::Role) + .as_ref() + .borrow_mut() + .password + .as_mut() + .unwrap() + .extra_fields + .insert("future".into(), Value::from(1)); + ctx.assert_command_success("r complete o password unset --tries"); + assert_eq!(password(&ctx, Level::Role), Some((None, None, 1))); + ctx.assert_command_success("r complete o password set --tries 4"); + assert_eq!(password(&ctx, Level::Role), Some((Some(4), None, 1))); + // refused, without a change + for command in [ + "o password set --tries 0", + "o password set --tries 256", + "o password set --tries", + "o password set --delay", + "o password set --delay 153722867280912930:59:59", + ] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + assert_eq!(password(&ctx, Level::Global), None); + ctx.assert_command_no_change("r complete t t_complete o show password"); + // what is saved is what dosr reads + ctx.settings.save_all().unwrap(); + let saved: Value = + serde_json::from_str(&fs::read_to_string(format!("{RAR_CFG_PATH}.{name}")).unwrap()) + .unwrap(); + let role = saved["roles"] + .as_array() + .unwrap() + .iter() + .find(|role| role["name"] == "complete") + .unwrap(); + assert_eq!( + role["options"]["password"], + serde_json::json!({"tries": 4, "future": 1}) + ); + let task = role["tasks"] + .as_array() + .unwrap() + .iter() + .find(|task| task["name"] == "t_complete") + .unwrap(); + assert_eq!( + task["options"]["password"], + serde_json::json!({"delay": "00:00:02"}) + ); + assert!(saved["options"].get("password").is_none()); + } + + /// The tty option of `level`, as (owner, unknown fields). + fn tty(ctx: &TestContext, level: Level) -> Option<(Option, usize)> { + ctx.opt(level) + .as_ref() + .borrow() + .tty + .as_ref() + .map(|tty| (tty.owner, tty.extra_fields.len())) + } + + #[test] + fn test_options_tty() { + let name = "options_tty"; + let (mut ctx, _defer) = TestContext::new(name); + ctx.assert_command_success("o tty owner target"); + ctx.assert_command_success("r complete o tty owner caller"); + ctx.assert_command_success("r complete t t_complete o tty owner target"); + assert_eq!(tty(&ctx, Level::Global), Some((Some(TtyOwner::Target), 0))); + assert_eq!(tty(&ctx, Level::Role), Some((Some(TtyOwner::Caller), 0))); + assert_eq!(tty(&ctx, Level::Task), Some((Some(TtyOwner::Target), 0))); + // unset removes the option, unless it has fields that this version does not know + ctx.assert_command_success("o tty owner unset"); + assert_eq!(tty(&ctx, Level::Global), None); + ctx.opt(Level::Role) + .as_ref() + .borrow_mut() + .tty + .as_mut() + .unwrap() + .extra_fields + .insert("future".into(), Value::from(1)); + ctx.assert_command_success("r complete o tty owner unset"); + assert_eq!(tty(&ctx, Level::Role), Some((None, 1))); + ctx.assert_command_success("r complete o tty owner target"); + assert_eq!(tty(&ctx, Level::Role), Some((Some(TtyOwner::Target), 1))); + // refused, without a change; "t" stays the alias of timeout + for command in ["o tty owner root", "o tty owner", "o tty target", "o tty"] { + assert!(ctx.run_command(command).is_err(), "{command}"); + } + assert_eq!(tty(&ctx, Level::Global), None); + ctx.assert_command_success("o t set --type tty"); + ctx.assert_command_no_change("r complete t t_complete o show tty"); + // what is saved is what dosr reads + ctx.settings.save_all().unwrap(); + let saved: Value = + serde_json::from_str(&fs::read_to_string(format!("{RAR_CFG_PATH}.{name}")).unwrap()) + .unwrap(); + let role = saved["roles"] + .as_array() + .unwrap() + .iter() + .find(|role| role["name"] == "complete") + .unwrap(); + assert_eq!( + role["options"]["tty"], + serde_json::json!({"owner": "target", "future": 1}) + ); + let task = role["tasks"] + .as_array() + .unwrap() + .iter() + .find(|task| task["name"] == "t_complete") + .unwrap(); + assert_eq!( + task["options"]["tty"], + serde_json::json!({"owner": "target"}) + ); + assert!(saved["options"].get("tty").is_none()); + assert_eq!(saved["options"]["timeout"]["type"], "tty"); + } + #[test] fn test_all_main() { let (mut ctx, _defer) = TestContext::new("all_main"); @@ -1088,6 +2025,93 @@ mod tests { ctx.assert_command_not_contains(&command); } #[test] + fn test_cred_setgid_from() { + let (mut ctx, _defer) = TestContext::new("cred_setgid_from"); + let setgid = |ctx: &TestContext| { + serde_json::to_value(&ctx.task().as_ref().borrow().cred.setgid).unwrap() + }; + ctx.assert_command_success("r complete t t_complete cred unset --setgid group1"); + // a task without setgid gets a selector with from + ctx.assert_command_success("r complete t t_complete cred set --setgid-from caller"); + assert_eq!( + setgid(&ctx), + serde_json::json!({"default": "none", "from": "caller"}) + ); + // forcing groups and taking them from someone cannot go together + assert!( + ctx.run_command( + "r complete t t_complete cred set --setgid group1 --setgid-from caller" + ) + .is_err() + ); + // a selector keeps its fields, only from changes + ctx.task().as_ref().borrow_mut().cred.setgid = Some(SGroupsEither::GroupSelector( + SSetgidSet::builder(SetBehavior::None) + .add([SGroups::from(vec![ + rootasrole_core::database::actor::SGroupType::from("adm"), + ])]) + .build(), + )); + ctx.assert_command_success("r complete t t_complete cred set --setgid-from=target"); + assert_eq!( + setgid(&ctx), + serde_json::json!({"default": "none", "add": ["adm"], "from": "target"}) + ); + ctx.assert_command_success("r complete t t_complete cred unset --setgid-from target"); + assert_eq!( + setgid(&ctx), + serde_json::json!({"default": "none", "add": ["adm"]}) + ); + // a selector with nothing else than from is removed with it + ctx.assert_command_success("r complete t t_complete cred unset --setgid group1"); + ctx.assert_command_success("r complete t t_complete cred set --setgid-from caller"); + ctx.assert_command_success("r complete t t_complete cred unset --setgid-from caller"); + ctx.assert_setgid_is_none(); + assert!( + ctx.run_command("r complete t t_complete cred unset --setgid-from caller") + .is_err() + ); + } + + #[test] + fn test_cmd_whitelist_keeps_a_regular_expression_as_typed() { + let (mut ctx, _defer) = + TestContext::new("cmd_whitelist_keeps_a_regular_expression_as_typed"); + let prefix = ["r", "complete", "t", "t_complete", "cmd", "whitelist"]; + let typed = "/usr/bin/tail ^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$"; + let command = SCommand::Simple(typed.to_string()); + + // quoted as one argument by the shell + ctx.assert_command_vec_success([&prefix[..], &["add", typed]].concat()); + ctx.assert_command_contains(&command); + // dosr reads it as a regular expression, with the same rule of rootasrole-core + let (_, args) = rootasrole_core::database::command::split_command(typed).unwrap(); + assert_eq!( + rootasrole_core::database::command::regex_arguments(args), + Some("^(-n [0-9]+|-f) /var/log/caddy/[^/ ]+$") + ); + + // del finds it, typed as several arguments + ctx.assert_command_vec_success( + [ + &prefix[..], + &[ + "del", + "/usr/bin/tail", + "^(-n [0-9]+|-f)", + "/var/log/caddy/[^/ ]+$", + ], + ] + .concat(), + ); + ctx.assert_command_not_contains(&command); + + // a backslash is kept, where shell splitting removes it + let escaped = r"/usr/bin/tail ^-f /var/log/caddy/access\.log$"; + ctx.assert_command_vec_success([&prefix[..], &["add", escaped]].concat()); + ctx.assert_command_contains(&SCommand::Simple(escaped.to_string())); + } + #[test] fn test_r_complete_t_t_complete_cmd_blacklist_del_super_command_with_spaces() { let (mut ctx, _defer) = TestContext::new("r_complete_t_t_complete_cmd_blacklist_del_super_command_with_spaces"); diff --git a/src/chsr/cli/pair.rs b/src/chsr/cli/pair.rs index 6137ed5b..40b42382 100644 --- a/src/chsr/cli/pair.rs +++ b/src/chsr/cli/pair.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, error::Error, str::FromStr}; +use std::{collections::HashMap, error::Error, num::NonZeroU8, str::FromStr}; use capctl::Cap; use indexmap::IndexSet; @@ -10,16 +10,38 @@ use crate::cli::data::{Convertion, RoleType, TaskType}; use rootasrole_core::{ database::{ actor::{SActor, SGroupType}, - options::{EnvBehavior, OptType, PathBehavior, TimestampType, WorkdirBehavior}, - structs::{IdTask, SetBehavior}, + command::{regex_arguments, split_command}, + options::{ + EnvBehavior, EnvKey, OptType, PathBehavior, SUMask, TimestampType, WorkdirBehavior, + }, + structs::{GroupsFrom, IdTask, SetBehavior}, }, - util::StorageMethod, + util::{StorageMethod, is_valid_env_name}, }; -use super::data::{InputAction, Inputs, Rule, SetListType, TimeoutOpt}; +use super::data::{ + EnvOverride, InputAction, Inputs, PasswordOpt, RoleLink, Rule, SetListType, TimeoutOpt, +}; type MatchingFunction = dyn Fn(&Pair, &mut Inputs) -> Result<(), Box>; +/// The command line to write in the policy for the command that the user typed. +/// +/// A regular expression on the arguments is kept as typed: dosr reads it as written, and shell +/// splitting would quote each of its words, so that it never matches. Any other command is +/// normalized as a shell splits it. +/// +/// # Errors +/// When the command has an unterminated quote. +pub fn policy_command_line(typed: &str) -> Result { + if let Some((path, args)) = split_command(typed) + && let Some(regex) = regex_arguments(args) + { + return Ok(format!("{path} {regex}")); + } + Ok(shell_words::join(shell_words::split(typed)?)) +} + fn recurse_pair_with_action( pair: Pair, inputs: &mut Inputs, @@ -36,6 +58,36 @@ pub fn recurse_pair(pair: Pair, inputs: &mut Inputs) -> Result<(), Box) -> Vec { + pair.clone() + .into_inner() + .flat_map(|inner| { + if inner.as_rule() == Rule::actor_name { + vec![inner.as_str().to_string()] + } else { + actor_names(&inner) + } + }) + .collect() +} + +/// A duration written `ss`, `mm:ss` or `hh:mm:ss`: a field that does not fit, or a duration +/// that does not, is an error, never 0 nor a panic. +fn parse_time(time: &str) -> Result> { + let mut seconds: i64 = 0; + for field in time.split(':') { + let value: i64 = field + .parse() + .map_err(|_| format!("Invalid duration: {time}"))?; + seconds = seconds + .checked_mul(60) + .and_then(|seconds| seconds.checked_add(value)) + .ok_or_else(|| format!("Duration too long: {time}"))?; + } + Ok(SignedDuration::from_secs(seconds)) +} + #[allow(clippy::too_many_lines)] fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box> { match pair.as_rule() { @@ -98,7 +150,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box, inputs: &mut Inputs) -> Result<(), Box { inputs.convert_reconfigure = true; @@ -129,7 +181,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -139,7 +191,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -153,7 +205,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -165,7 +217,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -174,31 +226,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { - let mut reversed = pair.as_str().split(':').rev(); - let mut duration: SignedDuration = SignedDuration::from_secs( - reversed - .next() - .ok_or("No seconds in time")? - .parse::() - .unwrap_or(0), - ); - if let Some(mins) = reversed.next() { - duration = duration - .checked_add( - SignedDuration::try_from_mins(mins.parse::().unwrap_or(0)) - .unwrap_or_default(), - ) - .expect("Invalid minutes"); - if let Some(hours) = reversed.next() { - duration = duration - .checked_add( - SignedDuration::try_from_hours(hours.parse::().unwrap_or(0)) - .unwrap_or_default(), - ) - .expect("Invalid hours"); - } - } - inputs.timeout_duration = Some(duration); + inputs.timeout_duration = Some(parse_time(pair.as_str())?); } Rule::opt_timeout_type => { if pair.as_str() == "tty" { @@ -208,7 +236,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { @@ -229,6 +257,28 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { inputs.timeout_max_usage = Some(pair.as_str().parse::()?); } + Rule::opt_password_t_arg => { + let mut password_arg = inputs.password_arg.unwrap_or_default(); + password_arg[PasswordOpt::Tries as usize] = true; + inputs.password_arg.replace(password_arg); + } + Rule::opt_password_d_arg => { + let mut password_arg = inputs.password_arg.unwrap_or_default(); + password_arg[PasswordOpt::Delay as usize] = true; + inputs.password_arg.replace(password_arg); + } + Rule::opt_password_tries => { + let tries = pair + .as_str() + .parse::() + .ok() + .and_then(NonZeroU8::new) + .ok_or_else(|| format!("The tries must be 1 to 255: {}", pair.as_str()))?; + inputs.password_tries = Some(tries); + } + Rule::opt_password_delay => { + inputs.password_delay = Some(parse_time(pair.as_str())?); + } // === file === Rule::file => { inputs.policy = true; @@ -240,6 +290,77 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { inputs.role_id = Some(pair.as_str().to_string()); } + Rule::role_link_kind => { + inputs.role_link = Some(if pair.as_str() == "ssd" { + RoleLink::Ssd + } else { + RoleLink::Parents + }); + } + Rule::linked_role => { + inputs + .role_links + .get_or_insert_default() + .push(pair.as_str().to_string()); + } + Rule::purpose_keyword => { + inputs.purpose = true; + } + Rule::purpose_text => { + // the words of the purpose, unquoted like the arguments of a command + inputs.task_purpose = Some(shell_words::split(pair.as_str())?.join(" ")); + } + Rule::cmd_hash => { + let mut inner = pair.clone().into_inner(); + let kind = inner + .next() + .ok_or("a hash type is needed")? + .as_str() + .trim_start_matches('-') + .to_string(); + let hash = inner + .next() + .ok_or("a hash is needed")? + .as_str() + .to_ascii_lowercase(); + let length = match kind.as_str() { + "sha224" => 56, + "sha256" => 64, + "sha384" => 96, + "sha512" => 128, + other => return Err(format!("Unknown hash type: {other}").into()), + }; + if !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("A {kind} hash is hexadecimal: {hash}").into()); + } + if hash.len() != length { + return Err(format!( + "A {kind} hash has {length} hexadecimal digits, this one has {}", + hash.len() + ) + .into()); + } + inputs.cmd_checks.hash = Some((kind, hash)); + } + Rule::cmd_check_flag => { + if pair.as_str() == "--read-only" { + inputs.cmd_checks.read_only = true; + } else { + inputs.cmd_checks.immutable = true; + } + } + Rule::opt_env_override => { + // unset, unless a value follows + inputs.options_env_override = Some(EnvOverride::Unset); + } + Rule::env_override_value => { + inputs.action = InputAction::Set; + inputs.options_env_override = Some(if pair.as_str() == "allow" { + EnvOverride::Allow + } else { + EnvOverride::Deny + }); + } Rule::role_type_arg => { if pair.as_str() == "all" { inputs.role_type = Some(RoleType::All); @@ -248,7 +369,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box, inputs: &mut Inputs) -> Result<(), Box { - inputs.cmd_id = Some(shell_words::split(pair.as_str())?); + inputs.cmd_id = Some(policy_command_line(pair.as_str())?); } // === credentials === Rule::capability => { @@ -322,26 +443,28 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { - inputs.cred_setuid = Some( - pair.as_str() - .chars() - .skip(9) - .collect::() - .as_str() - .into(), - ); + let user = actor_names(pair) + .into_iter() + .next() + .ok_or("--setuid needs a user")?; + inputs.cred_setuid = Some(user.as_str().into()); + } + Rule::groups_from => { + inputs.cred_groups_from = Some(if pair.as_str() == "caller" { + GroupsFrom::Caller + } else { + GroupsFrom::Target + }); } Rule::cred_g => { - let mut vec: Vec = Vec::new(); - for pair in pair.clone().into_inner() { - if pair.as_rule() == Rule::actor_name { - vec.push(pair.as_str().into()); - } + let groups: Vec = actor_names(pair) + .iter() + .map(|name| name.as_str().into()) + .collect(); + if groups.is_empty() { + return Err("--setgid needs a group".into()); } - if vec.is_empty() { - warn!("No group specified"); - } - inputs.cred_setgid = Some(vec.into()); + inputs.cred_setgid = Some(groups.into()); } // === options === Rule::options_operations => { @@ -381,27 +504,21 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { - if pair.as_str() == "all" { - inputs.options_type = None; - } else if pair.as_str() == "path" { - inputs.options_type = Some(OptType::Path); - } else if pair.as_str() == "env" { - inputs.options_type = Some(OptType::Env); - } else if pair.as_str() == "root" { - inputs.options_type = Some(OptType::Root); - } else if pair.as_str() == "bounding" { - inputs.options_type = Some(OptType::Bounding); - } else if pair.as_str() == "timeout" { - inputs.options_type = Some(OptType::Timeout); - } else if pair.as_str() == "authentication" { - inputs.options_type = Some(OptType::Authentication); - } else if pair.as_str() == "execinfo" { - inputs.options_type = Some(OptType::ExecInfo); - } else if pair.as_str() == "umask" { - inputs.options_type = Some(OptType::UMask); - } else { - unreachable!("Unknown option type: {}", pair.as_str()) - } + inputs.options_type = match pair.as_str() { + "all" => None, + "path" => Some(OptType::Path), + "env" => Some(OptType::Env), + "root" => Some(OptType::Root), + "bounding" => Some(OptType::Bounding), + "timeout" => Some(OptType::Timeout), + "password" => Some(OptType::Password), + "tty" => Some(OptType::Tty), + "authentication" | "auth" => Some(OptType::Authentication), + "execinfo" | "info" => Some(OptType::ExecInfo), + "umask" | "mask" => Some(OptType::UMask), + "workdir" => Some(OptType::Workdir), + other => return Err(format!("Unknown option: {other}").into()), + }; } Rule::path => { inputs.options_path = Some(pair.as_str().to_string()); @@ -423,12 +540,18 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { if let Some(options_env) = inputs.options_key_env.as_mut() { - options_env.insert(pair.as_str().into()); + options_env.insert(EnvKey::new(pair.as_str().to_string())?); } } Rule::opt_root_args => { @@ -447,9 +570,23 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box { + inputs.action = InputAction::Set; // If del it will be overwritten by the parse loop + inputs.options_tty_owner = Some(pair.as_str().parse().unwrap_or_default()); + } Rule::opt_mask_args => { inputs.action = InputAction::Set; // If del it will be overwritten by the parse loop - inputs.options_umask = Some(pair.as_str().parse().unwrap_or_default()); + let value = pair.as_str(); + inputs.options_umask = Some(if value.bytes().all(|b| b.is_ascii_digit()) { + u16::from_str_radix(value, 8) + .ok() + .filter(|umask| *umask <= 0o777) + .map(SUMask) + .ok_or_else(|| format!("The umask is octal, 000 to 777: {value}"))? + } else { + // del: the value is not used + SUMask::default() + }); } Rule::all => { if inputs.role_id.is_some() && inputs.task_id.is_none() { @@ -467,7 +604,7 @@ fn match_pair(pair: &Pair, inputs: &mut Inputs) -> Result<(), Box String { shell_words::join(shell_words::split(args).expect("Failed to split args")) @@ -581,6 +719,33 @@ mod test { assert_eq!(inputs.role_type, Some(RoleType::Tasks)); } + #[test] + fn test_parse_time() { + assert_eq!(parse_time("5").unwrap(), SignedDuration::from_secs(5)); + assert_eq!(parse_time("5:00").unwrap(), SignedDuration::from_secs(300)); + assert_eq!( + parse_time("01:02:03").unwrap(), + SignedDuration::from_secs(3723) + ); + // too long: an error, never 0 nor a panic + assert!(parse_time("99999999999999999999").is_err()); + assert!(parse_time("153722867280912930:59:59").is_err()); + } + + #[test] + fn test_timeout_duration_too_long_is_an_error() { + let binding = make_args("o timeout set --duration 153722867280912930:59:59"); + let pairs = Cli::parse(Rule::cli, &binding).unwrap(); + let mut inputs = Inputs::default(); + assert!( + pairs + .into_iter() + .try_for_each(|pair| recurse_pair(pair, &mut inputs)) + .is_err() + ); + assert_eq!(inputs.timeout_duration, None); + } + #[test] fn test_list_role_all() { let inputs = get_inputs("r r1 l all"); diff --git a/src/chsr/cli/process.rs b/src/chsr/cli/process.rs index f0f97588..2234217e 100644 --- a/src/chsr/cli/process.rs +++ b/src/chsr/cli/process.rs @@ -2,13 +2,14 @@ mod convert; mod json; mod upgrade; -use std::{cell::RefCell, error::Error, rc::Rc}; +use std::{cell::RefCell, error::Error, ffi::OsStr, path::Path, rc::Rc}; use json::{ cmd_setpolicy, cmd_whitelist_action, cred_caps, cred_set, cred_setpolicy, cred_unset, - env_set_policylist, env_setlist_add, env_setpolicy, env_whitelist_set, grant_revoke, - path_purge, path_set, path_setlist2, path_setpolicy, role_add_del, set_authentication, - set_bounding, set_execinfo, set_privileged, set_timeout, set_umask, task_add_del, + edit_role_links, edit_task_purpose, env_set_policylist, env_setlist_add, env_setpolicy, + env_whitelist_set, grant_revoke, path_purge, path_set, path_setlist2, path_setpolicy, + role_add_del, set_authentication, set_bounding, set_env_override, set_execinfo, set_password, + set_privileged, set_timeout, set_tty_owner, set_umask, task_add_del, unset_password, unset_timeout, }; @@ -16,10 +17,12 @@ use log::debug; use rootasrole_core::{ database::{ - options::{Opt, OptType}, - structs::{IdTask, RoleGetter}, + options::{Level, Opt, OptType}, + structs::{IdTask, RoleGetter, SPolicy}, }, file::FileSettings, + println_ignore_io_error, + util::DEFAULT_POLICY_FILE, }; use crate::cli::process::json::{ @@ -37,9 +40,7 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result Result Result edit_role_links( + rconfig, + &role_id, + link, + action, + role_links.unwrap_or_default(), + ), + Inputs { + // chsr role r1 task t1 purpose set , chsr role r1 task t1 purpose unset + action, + role_id: Some(role_id), + task_id: Some(task_id), + purpose: true, + task_purpose, + .. + } => edit_task_purpose(rconfig, &role_id, task_id, action, task_purpose), + Inputs { + // chsr o env override allow|deny|unset + role_id, + task_id, + options_env_override: Some(value), + .. + } => set_env_override(rconfig, role_id.as_ref(), task_id, value), + Inputs { // chsr role r1 add|del action, @@ -118,6 +175,7 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result Result Result Result Result Result cmd_whitelist_action(rconfig, &role_id, task_id, cmd_id, setlist_type, action), + } => cmd_whitelist_action( + rconfig, + &role_id, + task_id, + &cmd_id, + setlist_type, + action, + &cmd_checks, + ), Inputs { role_id: Some(role_id), task_id: Some(task_id), @@ -273,6 +344,24 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result set_authentication(rconfig, role_id.as_ref(), task_id, None), + Inputs { + // chsr o tty owner target|caller + action: InputAction::Set, + role_id, + task_id, + options_tty_owner: Some(owner), + .. + } => set_tty_owner(rconfig, role_id.as_ref(), task_id, Some(owner)), + + Inputs { + // chsr o tty owner unset + action: InputAction::Del, + role_id, + task_id, + options_tty_owner: Some(_), + .. + } => set_tty_owner(rconfig, role_id.as_ref(), task_id, None), + Inputs { // chsr o execinfo hide|show action: InputAction::Set, @@ -363,6 +452,36 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result unset_password(rconfig, role_id.as_ref(), task_id, password_arg), + + Inputs { + // chsr o password set --tries 3 --delay 00:00:05 + action: InputAction::Set, + role_id, + task_id, + options: true, + password_arg: Some(password_arg), + password_tries, + password_delay, + .. + } => set_password( + rconfig, + role_id.as_ref(), + task_id, + password_arg, + password_tries, + password_delay, + ), + Inputs { // chsr o timeout unset --type --duration --max-usage action: InputAction::Del, @@ -380,7 +499,7 @@ pub fn process_input(storage: &mut FileSettings, inputs: Inputs) -> Result Result Result Err("Unknown Input".into()), } } +/// The policy that a command edits: the file named with `chsr file ` (its path, or its +/// name in the policy directory), else `default.json` of the policy directory, the only policy +/// file, or the policy of the root file when there is no other file. +fn target_policy( + storage: &FileSettings, + policy_path: Option<&str>, +) -> Result>, Box> { + let files: Vec<_> = storage.policy_files().collect(); + let names = || { + files + .iter() + .map(|(path, _)| path.display().to_string()) + .collect::>() + .join(", ") + }; + if let Some(name) = policy_path { + let wanted = Path::new(name); + if let Some(policy) = storage.get(wanted) { + return Ok(policy.clone()); + } + let named = |path: &Path| { + path == wanted + || wanted.components().count() == 1 && path.file_name() == Some(wanted.as_os_str()) + }; + if let Some(policy) = files.iter().find(|(path, _)| named(path)) { + return Ok(Rc::clone(policy.1)); + } + // a file of the directory that could not be loaded: say why, it is not unknown + if let Some((path, error)) = storage.get_ignored().iter().find(|(path, _)| named(path)) { + return Err(format!("{} cannot be loaded: {error}", path.display()).into()); + } + return Err(format!("{name} is not a file of the policy: {}", names()).into()); + } + match files.as_slice() { + [] => Ok(storage.get_root().config.clone()), + [(_, policy)] => Ok(Rc::clone(policy)), + _ => files + .iter() + .find(|(path, _)| path.file_name() == Some(OsStr::new(DEFAULT_POLICY_FILE))) + .map(|(_, policy)| Rc::clone(policy)) + .ok_or_else(|| { + format!( + "The policy has several files and no {DEFAULT_POLICY_FILE}, choose one with chsr file : {}", + names() + ) + .into() + }), + } +} + +/// What `chsr editor` edits. +pub enum EditorTarget { + /// The root file, which tells where the policy is stored + Root, + /// A file of the policy + Policy(Rc>), +} + +/// What `chsr editor` edits: the root file when its path is given, else the policy file that the +/// other commands edit, see `target_policy`. +/// +/// # Errors +/// When the path is not a file of the policy. +pub fn editor_target( + storage: &FileSettings, + path: Option<&Path>, +) -> Result> { + if path.is_some_and(|path| path == storage.root_path()) { + return Ok(EditorTarget::Root); + } + let path = path + .map(|path| path.to_str().ok_or("The path is not valid UTF-8")) + .transpose()?; + target_policy(storage, path).map(EditorTarget::Policy) +} + pub fn perform_on_target_opt( rconfig: &Rc>, role_id: Option<&String>, task_id: Option, exec_on_opt: impl Fn(Rc>) -> Result<(), Box>, ) -> Result<(), Box> { - // Helper function to execute on option or create a new one + /// Execute on the options of the level, created empty when the level has none: only what + /// the command sets is written, the other options stay inherited. `Opt::default()` would + /// write every built-in default at that level, which replaces the lists of the upper levels. fn execute_or_create_option( exec_on_opt: impl Fn(Rc>) -> Result<(), Box>, options: &mut Option>>, + level: Level, ) -> Result<(), Box> { if let Some(opt) = options { exec_on_opt(opt.clone()) } else { - let new_opt = Rc::new(RefCell::new(Opt::default())); + let new_opt = Rc::new(RefCell::new(Opt::builder(level).build())); let result = exec_on_opt(new_opt.clone()); - *options = Some(new_opt); + // a command that removes from options that the level does not have changes nothing + if *new_opt.as_ref().borrow() != Opt::builder(level).build() { + *options = Some(new_opt); + } result } } @@ -522,12 +724,20 @@ pub fn perform_on_target_opt( // If task_id is provided, find the task if let Some(task_id) = task_id { let task = role_borrowed.task(&task_id).ok_or("Task not found")?; - return execute_or_create_option(exec_on_opt, &mut task.as_ref().borrow_mut().options); + return execute_or_create_option( + exec_on_opt, + &mut task.as_ref().borrow_mut().options, + Level::Task, + ); } // No task_id, use role options - return execute_or_create_option(exec_on_opt, &mut role_borrowed.options); + return execute_or_create_option(exec_on_opt, &mut role_borrowed.options, Level::Role); } // No role_id, use global config options - execute_or_create_option(exec_on_opt, &mut rconfig.as_ref().borrow_mut().options) + execute_or_create_option( + exec_on_opt, + &mut rconfig.as_ref().borrow_mut().options, + Level::Global, + ) } diff --git a/src/chsr/cli/process/convert.rs b/src/chsr/cli/process/convert.rs index 49518534..b0afa0e0 100644 --- a/src/chsr/cli/process/convert.rs +++ b/src/chsr/cli/process/convert.rs @@ -1,3 +1,4 @@ +use rootasrole_core::println_ignore_io_error; use std::{ error::Error, path::{Path, PathBuf}, @@ -36,7 +37,7 @@ pub fn convert( let config = match convertion.from { Some(ref from) => { debug!("Convert from: {}", from.display()); - let from_type = convertion.from_type.expect("Impossible state"); + let from_type = convertion.from_type.ok_or("--from needs a type")?; if from == &convertion.to { error!("The source and destination paths are the same"); return Ok(false); @@ -49,7 +50,7 @@ pub fn convert( } None => settings.get_root().config.clone(), }; - println!( + println_ignore_io_error!( "Config : {}", serde_json::to_string_pretty(&Versioning::new(config.clone()))? ); diff --git a/src/chsr/cli/process/json.rs b/src/chsr/cli/process/json.rs index 447d69ac..135e46b8 100644 --- a/src/chsr/cli/process/json.rs +++ b/src/chsr/cli/process/json.rs @@ -1,18 +1,24 @@ -use std::{cell::RefCell, collections::HashMap, error::Error, rc::Rc}; +use rootasrole_core::println_ignore_io_error; +use std::{cell::RefCell, collections::HashMap, error::Error, num::NonZeroU8, rc::Rc}; use indexmap::IndexSet; use jiff::SignedDuration; use log::{debug, warn}; -use crate::cli::data::{InputAction, RoleType, SetListType, TaskType, TimeoutOpt}; +use crate::cli::data::{ + CommandChecks, EnvOverride, InputAction, PasswordOpt, RoleLink, RoleType, SetListType, + TaskType, TimeoutOpt, +}; +use serde_json::Value; use rootasrole_core::database::{ options::{ - EnvBehavior, EnvKey, Opt, OptStack, OptType, PathBehavior, SEnvOptions, SPathOptions, - STimeout, SUMask, SWorkdirEither, SWorkdirSet, WorkdirBehavior, + EnvBehavior, EnvKey, Opt, OptStack, OptType, PathBehavior, SPassword, SPathOptions, + STimeout, STty, SUMask, SWorkdirEither, SWorkdirSet, TtyOwner, WorkdirBehavior, }, structs::{ - IdTask, RoleGetter, SCapabilities, SCommand, SGroupsEither, SRole, STask, SUserEither, + GroupsFrom, IdTask, RoleGetter, SCapabilities, SCommand, SGroupsEither, SRole, SSetgidSet, + STask, SUserEither, SetBehavior, }, }; @@ -27,23 +33,43 @@ pub fn list_json( task_type: Option, role_type: Option, ) -> Result<(), Box> { - let config = rconfig.as_ref().borrow(); - role_id.map_or_else( - || { - println!( + let Some(role_id) = role_id else { + if options { + let rcopt = OptStack::from_roles(rconfig) + .to_opt() + .map_err(std::io::Error::other)?; + println_ignore_io_error!( "{}", - serde_json::to_string_pretty(&*config) - .unwrap_or_else(|_| "Failed to serialize config".to_string()) + options_to_show(&rcopt.as_ref().borrow(), options_type)? ); - Ok(()) - }, - |role_id| { - rconfig.role(role_id).map_or_else( - || Err("Role not found".into()), - |role| list_task(task_id, &role, options, options_type, task_type, role_type), - ) - }, - ) + } else { + println_ignore_io_error!( + "{}", + serde_json::to_string_pretty(&*rconfig.as_ref().borrow())? + ); + } + return Ok(()); + }; + let role = rconfig.role(role_id).ok_or("Role not found")?; + list_task(task_id, &role, options, options_type, task_type, role_type) +} + +/// The options of a level, resolved with the upper levels, or only the one asked for. +fn options_to_show(opt: &Opt, options_type: Option) -> serde_json::Result { + match options_type { + None => serde_json::to_string_pretty(opt), + Some(OptType::Path) => serde_json::to_string_pretty(&opt.path), + Some(OptType::Env) => serde_json::to_string_pretty(&opt.env), + Some(OptType::Root) => serde_json::to_string_pretty(&opt.root), + Some(OptType::Bounding) => serde_json::to_string_pretty(&opt.bounding), + Some(OptType::Timeout) => serde_json::to_string_pretty(&opt.timeout), + Some(OptType::Password) => serde_json::to_string_pretty(&opt.password), + Some(OptType::Tty) => serde_json::to_string_pretty(&opt.tty), + Some(OptType::Authentication) => serde_json::to_string_pretty(&opt.authentication), + Some(OptType::ExecInfo) => serde_json::to_string_pretty(&opt.execinfo), + Some(OptType::UMask) => serde_json::to_string_pretty(&opt.umask), + Some(OptType::Workdir) => serde_json::to_string_pretty(&opt.workdir), + } } fn list_task( @@ -55,57 +81,32 @@ fn list_task( role_type: Option, ) -> Result<(), Box> { if let Some(task_id) = task_id { - if let Some(task) = role.as_ref().borrow().task(&task_id) { - if options { - debug!("task {task:?}"); - let rcopt = OptStack::from_task(&task.clone()) - .to_opt() - .map_err(std::io::Error::other)?; - let opt = rcopt.as_ref().borrow(); - if let Some(opttype) = options_type { - match opttype { - OptType::Env => { - println!("{}", serde_json::to_string_pretty(&opt.env)?); - } - OptType::Path => { - println!("{}", serde_json::to_string_pretty(&opt.path)?); - } - OptType::Root => { - println!("{}", serde_json::to_string_pretty(&opt.root)?); - } - OptType::Bounding => { - println!("{}", serde_json::to_string_pretty(&opt.bounding)?); - } - OptType::Timeout => { - println!("{}", serde_json::to_string_pretty(&opt.timeout)?); - } - OptType::Authentication => { - println!("{}", serde_json::to_string_pretty(&opt.authentication)?); - } - OptType::ExecInfo => { - println!("{}", serde_json::to_string_pretty(&opt.execinfo)?); - } - OptType::UMask => { - println!("{}", serde_json::to_string_pretty(&opt.umask)?); - } - OptType::Workdir => { - println!("{}", serde_json::to_string_pretty(&opt.workdir)?); - } - } - } else { - println!("{}", serde_json::to_string_pretty(&rcopt)?); - } - } else { - print_task(task, task_type.unwrap_or(TaskType::All))?; - } + let task = role + .as_ref() + .borrow() + .task(&task_id) + .cloned() + .ok_or("Task not found")?; + if options { + debug!("task {task:?}"); + let rcopt = OptStack::from_task(&task) + .to_opt() + .map_err(std::io::Error::other)?; + println_ignore_io_error!( + "{}", + options_to_show(&rcopt.as_ref().borrow(), options_type)? + ); } else { - return Err("Task not found".into()); + print_task(&task, task_type.unwrap_or(TaskType::All))?; } } else if options { let rcopt = OptStack::from_role(&role.clone()) .to_opt() .map_err(std::io::Error::other)?; - println!("{}", serde_json::to_string_pretty(&rcopt)?); + println_ignore_io_error!( + "{}", + options_to_show(&rcopt.as_ref().borrow(), options_type)? + ); } else { print_role(role, role_type.unwrap_or(RoleType::All))?; } @@ -118,16 +119,16 @@ fn print_task( ) -> Result<(), Box> { match task_type { TaskType::All => { - println!("{}", serde_json::to_string_pretty(&task)?); + println_ignore_io_error!("{}", serde_json::to_string_pretty(&task)?); } TaskType::Commands => { - println!( + println_ignore_io_error!( "{}", serde_json::to_string_pretty(&task.as_ref().borrow().commands)? ); } TaskType::Credentials => { - println!( + println_ignore_io_error!( "{}", serde_json::to_string_pretty(&task.as_ref().borrow().cred)? ); @@ -142,16 +143,16 @@ fn print_role( ) -> Result<(), Box> { match role_type { RoleType::All => { - println!("{}", serde_json::to_string_pretty(&role)?); + println_ignore_io_error!("{}", serde_json::to_string_pretty(&role)?); } RoleType::Actors => { - println!( + println_ignore_io_error!( "{}", serde_json::to_string_pretty(&role.as_ref().borrow().actors)? ); } RoleType::Tasks => { - println!( + println_ignore_io_error!( "{}", serde_json::to_string_pretty(&role.as_ref().borrow().tasks)? ); @@ -259,7 +260,7 @@ pub fn task_add_del( } InputAction::Purge => { let borrow = &role.as_ref().borrow(); - let task = borrow.task(&task_id).expect("Task do not exists"); + let task = borrow.task(&task_id).ok_or("Task not found")?; match task_type { Some(TaskType::Commands) => { task.as_ref().borrow_mut().commands.add.clear(); @@ -282,7 +283,7 @@ pub fn task_add_del( } Ok(true) } - _ => unreachable!("Invalid action"), + _ => Err("Invalid action".into()), } } @@ -300,7 +301,7 @@ pub fn grant_revoke( //remove already existing actors actors.retain(|a| { if role.as_ref().borrow().actors.contains(a) { - println!("Actor {a} already in role"); + println_ignore_io_error!("Actor {a} already in role"); false } else { true @@ -310,9 +311,11 @@ pub fn grant_revoke( Ok(true) } InputAction::Del => { - //if actor is not in role, warns - if !role.as_ref().borrow().actors.contains(&actors[0]) { - println!("Actor {} not in role", actors[0]); + //if an actor is not in role, warns + for actor in &actors { + if !role.as_ref().borrow().actors.contains(actor) { + println_ignore_io_error!("Actor {actor} not in role"); + } } role.as_ref() .borrow_mut() @@ -320,7 +323,7 @@ pub fn grant_revoke( .retain(|a| !actors.contains(a)); Ok(true) } - _ => unreachable!("Invalid action"), + _ => Err("Invalid action".into()), } } @@ -331,8 +334,12 @@ pub fn cred_set( cred_caps: Option, cred_setuid: Option, cred_setgid: Option, + groups_from: Option, ) -> Result> { debug!("chsr role r1 task t1 cred"); + if groups_from.is_some() && cred_setgid.is_some() { + return Err("--setgid-from only applies when --setgid does not force the groups".into()); + } match rconfig.task(role_id, task_id) { Ok(task) => { if let Some(caps) = cred_caps { @@ -345,6 +352,19 @@ pub fn cred_set( task.as_ref().borrow_mut().cred.setgid = Some(SGroupsEither::MandatoryGroups(setgid)); } + if let Some(from) = groups_from { + let mut task = task.as_ref().borrow_mut(); + if let Some(SGroupsEither::GroupSelector(selector)) = task.cred.setgid.as_mut() { + selector.from = Some(from); + } else { + if task.cred.setgid.is_some() { + warn!("The groups that the task forced are replaced by --setgid-from"); + } + task.cred.setgid = Some(SGroupsEither::GroupSelector( + SSetgidSet::builder(SetBehavior::None).from(from).build(), + )); + } + } Ok(true) } Err(e) => Err(e), @@ -358,6 +378,7 @@ pub fn cred_unset( cred_caps: Option, cred_setuid: Option<&rootasrole_core::database::actor::SUserType>, cred_setgid: Option<&rootasrole_core::database::actor::SGroups>, + groups_from: bool, ) -> Result> { debug!("chsr role r1 task t1 cred unset"); match rconfig.task(role_id, task_id) { @@ -377,6 +398,21 @@ pub fn cred_unset( if cred_setgid.is_some() { task.as_ref().borrow_mut().cred.setgid = None; } + if groups_from { + let mut task = task.as_ref().borrow_mut(); + let Some(SGroupsEither::GroupSelector(selector)) = task.cred.setgid.as_mut() else { + return Err("The task has no --setgid-from to unset".into()); + }; + selector.from = None; + // a selector with nothing else is no setgid + if selector.default_behavior.is_none() + && selector.fallback.is_none() + && selector.add.is_empty() + && selector.sub.is_empty() + { + task.cred.setgid = None; + } + } Ok(true) } Err(e) => Err(e), @@ -420,7 +456,7 @@ pub fn cred_caps( .get_or_insert_default() .add = cred_caps; } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, SetListType::Black => match action { InputAction::Add => { @@ -446,9 +482,9 @@ pub fn cred_caps( .get_or_insert_default() .sub = cred_caps; } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(true) } @@ -481,60 +517,173 @@ pub fn cmd_whitelist_action( rconfig: &Rc>, role_id: &str, task_id: IdTask, - cmd_id: impl IntoIterator, + line: &str, setlist_type: SetListType, action: InputAction, + checks: &CommandChecks, ) -> Result> { debug!("chsr role r1 task t1 command whitelist add c1"); let task = rconfig.task(role_id, task_id)?; - let cmd = SCommand::Simple(shell_words::join(cmd_id)); - match setlist_type { - SetListType::White => match action { - InputAction::Add => { - //verify if command exists - if task.as_ref().borrow().commands.add.contains(&cmd) { - return Err("Command already exists".into()); - } - task.as_ref().borrow_mut().commands.add.push(cmd); + let mut task = task.as_ref().borrow_mut(); + let list = match setlist_type { + SetListType::White => &mut task.commands.add, + SetListType::Black => &mut task.commands.sub, + _ => return Err("Unknown list type".into()), + }; + let listed = |command: &SCommand| command_line(command) == Some(line); + match action { + InputAction::Add => { + if list.iter().any(listed) { + return Err("Command already exists".into()); } - InputAction::Del => { - //if command is not in task, warns - if !task.as_ref().borrow().commands.add.contains(&cmd) { - println!("Command {cmd:?} not in task"); - } - task.as_ref().borrow_mut().commands.add.retain(|c| { - debug!("'{:?}' != '{:?}' : {}", c, &cmd, *c != cmd); - *c != cmd - }); + list.push(command_entry(line.to_string(), checks)); + } + InputAction::Del => { + if !list.iter().any(listed) { + println_ignore_io_error!("Command {line:?} not in task"); } - _ => unreachable!("Unknown action {:?}", action), - }, - SetListType::Black => match action { - InputAction::Add => { - //verify if command exists - if task.as_ref().borrow().commands.sub.contains(&cmd) { - return Err("Command already exists".into()); - } - task.as_ref().borrow_mut().commands.sub.push(cmd); + // in either form, with checks or not + list.retain(|command| !listed(command)); + } + _ => return Err(format!("Unknown action {action:?}").into()), + } + Ok(true) +} + +/// The command line of an entry of a command list: the string, or the `command` of an entry +/// with the checks of the hashchecker plugin. +fn command_line(command: &SCommand) -> Option<&str> { + match command { + SCommand::Simple(line) => Some(line), + SCommand::Complex(entry) => entry.get("command").and_then(Value::as_str), + } +} + +/// The entry of a command line: the string, or an object with the checks of the hashchecker +/// plugin. +fn command_entry(line: String, checks: &CommandChecks) -> SCommand { + if *checks == CommandChecks::default() { + return SCommand::Simple(line); + } + warn!( + "The hashchecker plugin is deprecated and will be removed in RootAsRole 5.0: \ + use the IPE LSM to restrict which files can be executed" + ); + let mut entry = serde_json::Map::new(); + entry.insert("command".into(), Value::from(line)); + if let Some((kind, hash)) = &checks.hash { + entry.insert(kind.clone(), Value::from(hash.clone())); + } + if checks.read_only { + entry.insert("read_only".into(), Value::Bool(true)); + } + if checks.immutable { + entry.insert("immutable".into(), Value::Bool(true)); + } + SCommand::Complex(Value::Object(entry)) +} + +/// `chsr role R ssd|parents add|del|set R2,R3` and `... purge`: the roles that a member of R +/// must not hold (ssd), or whose tasks R gets (parents), kept as a list of names in R. +pub fn edit_role_links( + rconfig: &Rc>, + role_id: &str, + link: RoleLink, + action: InputAction, + names: Vec, +) -> Result> { + let key = link.key(); + debug!("chsr role r1 {key} {action:?}"); + let role = rconfig.role(role_id).ok_or("Role not found")?; + if names.iter().any(|name| name == role_id) { + return Err(format!("The {key} of {role_id} cannot name {role_id} itself").into()); + } + if matches!(action, InputAction::Add | InputAction::Set) { + for name in names.iter().filter(|name| rconfig.role(name).is_none()) { + println_ignore_io_error!("Role {name} does not exist (yet)"); + } + } + let mut role = role.as_ref().borrow_mut(); + let mut current: Vec = match role.extra_fields.get(key) { + None => Vec::new(), + Some(Value::String(name)) => vec![name.clone()], + Some(Value::Array(linked)) => linked + .iter() + .map(|name| name.as_str().map(str::to_string)) + .collect::>() + .ok_or_else(|| format!("The {key} of {role_id} holds something else than names"))?, + Some(other) => { + return Err(format!("The {key} of {role_id} is not a list of roles: {other}").into()); + } + }; + match action { + InputAction::Add | InputAction::Set => { + if action == InputAction::Set { + current.clear(); } - InputAction::Del => { - //if command is not in task, warns - if !task.as_ref().borrow().commands.sub.contains(&cmd) { - println!("Command {cmd:?} not in task"); + for name in names { + if !current.contains(&name) { + current.push(name); } - task.as_ref() - .borrow_mut() - .commands - .sub - .retain(|c| c != &cmd); } - _ => unreachable!("Unknown action {:?}", action), - }, - _ => unreachable!("Unknown setlist type"), + } + InputAction::Del => current.retain(|name| !names.contains(name)), + InputAction::Purge => current.clear(), + _ => return Err(format!("Unknown action {action:?}").into()), + } + if current.is_empty() { + role.extra_fields.remove(key); + } else { + role.extra_fields + .insert(key.to_string(), Value::from(current)); } Ok(true) } +/// `chsr role R task T purpose set ` and `... purpose unset`. +pub fn edit_task_purpose( + rconfig: &Rc>, + role_id: &str, + task_id: IdTask, + action: InputAction, + purpose: Option, +) -> Result> { + debug!("chsr role r1 task t1 purpose {action:?}"); + let task = rconfig.task(role_id, task_id)?; + task.as_ref().borrow_mut().purpose = match action { + InputAction::Set => Some(purpose.ok_or("purpose set needs a text")?), + InputAction::Del => None, + _ => return Err(format!("Unknown action {action:?}").into()), + }; + Ok(true) +} + +/// `chsr o env override allow|deny|unset`: whether `dosr -E` may change the env behavior. +pub fn set_env_override( + rconfig: &Rc>, + role_id: Option<&String>, + task_id: Option, + value: EnvOverride, +) -> Result> { + debug!("chsr o env override {value:?}"); + perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { + let mut binding = opt.as_ref().borrow_mut(); + match value { + EnvOverride::Allow | EnvOverride::Deny => { + binding.env.get_or_insert_default().override_behavior = + Some(value == EnvOverride::Allow); + } + EnvOverride::Unset => { + if let Some(env) = binding.env.as_mut() { + env.override_behavior = None; + } + } + } + Ok(()) + })?; + Ok(true) +} + pub fn cmd_setpolicy( rconfig: &Rc>, role_id: &str, @@ -551,6 +700,8 @@ pub fn cmd_setpolicy( Ok(true) } +/// `chsr o env keep-only|delete-only`: the behavior and its list (keep in delete mode, delete +/// in keep mode); the other env fields of the level (check, set, override, unknown fields) stay. pub fn env_set_policylist( rconfig: &Rc>, role_id: Option<&String>, @@ -560,20 +711,14 @@ pub fn env_set_policylist( ) -> Result> { debug!("chsr o env set keep-only|delete-only {options_env:?}"); perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { - opt.as_ref().borrow_mut().env = Some(SEnvOptions { - default_behavior: options_env_policy, - keep: if options_env_policy.is_delete() { - Some(options_env.clone()) - } else { - None - }, - delete: if options_env_policy.is_keep() { - Some(options_env.clone()) - } else { - None - }, - ..Default::default() - }); + let mut binding = opt.as_ref().borrow_mut(); + let env = binding.env.get_or_insert_default(); + env.default_behavior = options_env_policy; + if options_env_policy.is_delete() { + env.keep = Some(options_env.clone()); + } else if options_env_policy.is_keep() { + env.delete = Some(options_env.clone()); + } Ok(()) })?; Ok(true) @@ -635,6 +780,30 @@ pub fn set_execinfo( Ok(true) } +/// `chsr o tty owner`: sets or unsets the owner, then removes the option once nothing is left in +/// it; the fields that this version does not know are kept. +pub fn set_tty_owner( + rconfig: &Rc>, + role_id: Option<&String>, + task_id: Option, + owner: Option, +) -> Result> { + debug!("chsr o tty owner {owner:?}"); + perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { + let mut opt = opt.as_ref().borrow_mut(); + if let Some(owner) = owner { + opt.tty.get_or_insert_with(STty::default).owner = Some(owner); + } else if let Some(tty) = opt.tty.as_mut() { + tty.owner = None; + if tty.extra_fields.is_empty() { + opt.tty = None; + } + } + Ok(()) + })?; + Ok(true) +} + pub fn set_umask( rconfig: &Rc>, role_id: Option<&String>, @@ -686,13 +855,16 @@ pub fn path_set( .collect(), ); } - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(()) })?; Ok(true) } +/// `chsr o path (whitelist|blacklist) purge`: the level no longer defines the list, which is +/// inherited again, like the env lists. An explicit empty list would also remove the inherited +/// one: for the `sub` list, protections of the upper levels. pub fn path_purge( rconfig: &Rc>, role_id: Option<&String>, @@ -702,25 +874,21 @@ pub fn path_purge( debug!("chsr o path purge"); perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { let mut binding = opt.as_ref().borrow_mut(); - let path = binding.path.get_or_insert_default(); + let Some(path) = binding.path.as_mut() else { + return Ok(()); + }; match setlist_type { - Some(SetListType::White) => { - if let Some(add) = &mut path.add { - add.clear(); - } - } - Some(SetListType::Black) => { - if let Some(sub) = &mut path.sub { - sub.clear(); - } - } - _ => unreachable!("Unknown setlist type"), + Some(SetListType::White) => path.add = None, + Some(SetListType::Black) => path.sub = None, + _ => return Err("Unknown list type".into()), } Ok(()) })?; Ok(true) } +/// `chsr o workdir (whitelist|blacklist) purge`: the level no longer defines the list, see +/// [`path_purge`]. pub fn workdir_purge( rconfig: &Rc>, role_id: Option<&String>, @@ -730,29 +898,15 @@ pub fn workdir_purge( debug!("chsr o workdir purge"); perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { let mut binding = opt.as_ref().borrow_mut(); - let workdir_current = binding.workdir.take(); - let mut workdir_set = match workdir_current { - Some(SWorkdirEither::Struct(workdir_set)) => workdir_set, - Some(SWorkdirEither::Path(path)) => SWorkdirSet { - fallback: Some(path), - ..Default::default() - }, - None => SWorkdirSet::default(), + let Some(SWorkdirEither::Struct(workdir_set)) = binding.workdir.as_mut() else { + // no list at this level: a bare path or nothing + return Ok(()); }; match setlist_type { - Some(SetListType::White) => { - if let Some(add) = &mut workdir_set.add { - add.clear(); - } - } - Some(SetListType::Black) => { - if let Some(sub) = &mut workdir_set.sub { - sub.clear(); - } - } - _ => unreachable!("Unknown setlist type"), + Some(SetListType::White) => workdir_set.add = None, + Some(SetListType::Black) => workdir_set.sub = None, + _ => return Err("Unknown list type".into()), } - binding.workdir = Some(SWorkdirEither::Struct(workdir_set)); Ok(()) })?; Ok(true) @@ -783,53 +937,132 @@ pub fn env_whitelist_set( env.default_behavior = EnvBehavior::Delete; env.keep = Some(options_env.clone()); } - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(()) })?; Ok(true) } -pub fn unset_timeout( +/// `chsr o password set`: the fields given are changed, the others and the unknown fields of the +/// option are kept. +pub fn set_password( rconfig: &Rc>, role_id: Option<&String>, task_id: Option, - timeout_arg: [bool; 3], + password_arg: [bool; 2], + tries: Option, + delay: Option, ) -> Result> { - debug!("chsr o timeout unset"); + debug!("chsr o password set"); + if password_arg[PasswordOpt::Tries as usize] && tries.is_none() { + return Err("--tries needs a number of tries".into()); + } + if password_arg[PasswordOpt::Delay as usize] && delay.is_none() { + return Err("--delay needs a duration".into()); + } perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { - let mut timeout = STimeout::default(); - if timeout_arg[TimeoutOpt::Type as usize] { - timeout.type_field = None; - } - if timeout_arg[TimeoutOpt::Duration as usize] { - timeout.duration = None; + let mut opt = opt.as_ref().borrow_mut(); + let password = opt.password.get_or_insert_with(SPassword::default); + if let Some(tries) = tries { + password.tries = Some(tries); } - if timeout_arg[TimeoutOpt::MaxUsage as usize] { - timeout.max_usage = None; + if let Some(delay) = delay { + password.delay = Some(delay); } - if timeout_arg.iter().all(|b| *b) { - opt.as_ref().borrow_mut().timeout = None; - } else { - opt.as_ref().borrow_mut().timeout = Some(timeout); + Ok(()) + })?; + Ok(true) +} + +/// `chsr o password unset`: the fields named are removed, then the option once nothing is left +/// in it. +pub fn unset_password( + rconfig: &Rc>, + role_id: Option<&String>, + task_id: Option, + password_arg: [bool; 2], +) -> Result> { + debug!("chsr o password unset"); + perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { + let mut opt = opt.as_ref().borrow_mut(); + if let Some(password) = opt.password.as_mut() { + if password_arg[PasswordOpt::Tries as usize] { + password.tries = None; + } + if password_arg[PasswordOpt::Delay as usize] { + password.delay = None; + } + if password.tries.is_none() + && password.delay.is_none() + && password.extra_fields.is_empty() + { + opt.password = None; + } } + Ok(()) + })?; + Ok(true) +} +/// `chsr o timeout unset`: the fields named are removed, then the option once nothing is left +/// in it. +pub fn unset_timeout( + rconfig: &Rc>, + role_id: Option<&String>, + task_id: Option, + timeout_arg: [bool; 3], +) -> Result> { + debug!("chsr o timeout unset"); + perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { + let mut opt = opt.as_ref().borrow_mut(); + if let Some(timeout) = opt.timeout.as_mut() { + if timeout_arg[TimeoutOpt::Type as usize] { + timeout.type_field = None; + } + if timeout_arg[TimeoutOpt::Duration as usize] { + timeout.duration = None; + } + if timeout_arg[TimeoutOpt::MaxUsage as usize] { + timeout.max_usage = None; + } + if timeout.type_field.is_none() + && timeout.duration.is_none() + && timeout.max_usage.is_none() + && timeout.extra_fields.is_empty() + { + opt.timeout = None; + } + } Ok(()) })?; Ok(true) } +/// `chsr o timeout set`: the fields given are changed, the others and the unknown fields of the +/// option are kept. pub fn set_timeout( rconfig: &Rc>, role_id: Option<&String>, task_id: Option, + timeout_arg: [bool; 3], timeout_type: Option, timeout_duration: Option, timeout_max_usage: Option, ) -> Result> { debug!("chsr o timeout set"); + if timeout_arg[TimeoutOpt::Type as usize] && timeout_type.is_none() { + return Err("--type needs tty, ppid or uid".into()); + } + if timeout_arg[TimeoutOpt::Duration as usize] && timeout_duration.is_none() { + return Err("--duration needs a duration".into()); + } + if timeout_arg[TimeoutOpt::MaxUsage as usize] && timeout_max_usage.is_none() { + return Err("--max-usage needs a number".into()); + } perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { - let mut timeout = STimeout::default(); + let mut opt = opt.as_ref().borrow_mut(); + let timeout = opt.timeout.get_or_insert_with(STimeout::default); if let Some(timeout_type) = timeout_type { timeout.type_field = Some(timeout_type); } @@ -839,7 +1072,6 @@ pub fn set_timeout( if let Some(max_usage) = timeout_max_usage { timeout.max_usage = Some(max_usage); } - opt.as_ref().borrow_mut().timeout = Some(timeout); Ok(()) })?; Ok(true) @@ -855,9 +1087,13 @@ pub fn path_setlist2( ) -> Result> { debug!("chsr o path set whitelist|blacklist add|del|set path1:path2:path3 22222222222"); perform_on_target_opt(rconfig, role_id, task_id, |opt: Rc>| { - let mut default_path = SPathOptions::default(); let mut binding = opt.as_ref().borrow_mut(); - let path = binding.path.as_mut().unwrap_or(&mut default_path); + // removing from a list that the level does not define changes nothing + if binding.path.is_none() && !matches!(action, InputAction::Add | InputAction::Set) { + warn!("No path option at this level, nothing to remove"); + return Ok(()); + } + let path = binding.path.get_or_insert_default(); match setlist_type { Some(SetListType::White) => match action { InputAction::Add => { @@ -890,7 +1126,7 @@ pub fn path_setlist2( .collect(), ); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Black) => match action { InputAction::Add => { @@ -923,9 +1159,9 @@ pub fn path_setlist2( .collect(), ); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } Ok(()) })?; @@ -998,7 +1234,7 @@ pub fn workdir_setlist( .collect(), ); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Black) => match action { InputAction::Add => { @@ -1031,9 +1267,9 @@ pub fn workdir_setlist( .collect(), ); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, - _ => unreachable!("Unknown setlist type"), + _ => return Err("Unknown list type".into()), } binding.workdir = Some(SWorkdirEither::Struct(workdir_set)); Ok(()) @@ -1074,17 +1310,14 @@ pub fn workdir_setpolicy( || { SWorkdirEither::Struct(SWorkdirSet { default_behavior: options_workdir_policy, - fallback: None, - add: None, - sub: None, + ..Default::default() }) }, |workdir| match workdir { SWorkdirEither::Path(p) => SWorkdirEither::Struct(SWorkdirSet { default_behavior: options_workdir_policy, fallback: Some(p.clone()), - add: None, - sub: None, + ..Default::default() }), SWorkdirEither::Struct(sworkdir_set) => { sworkdir_set.default_behavior = options_workdir_policy; @@ -1110,9 +1343,13 @@ pub fn env_setlist_add( ) -> Result> { debug!("chsr o path {setlist_type:?} {action:?} path1 path2 path3"); perform_on_target_opt(rconfig, role_id, task_id, move |opt: Rc>| { - let mut default_env = SEnvOptions::default(); let mut binding = opt.as_ref().borrow_mut(); - let env = binding.env.as_mut().unwrap_or(&mut default_env); + // removing from a list that the level does not define changes nothing + if binding.env.is_none() && !matches!(action, InputAction::Add | InputAction::Set) { + warn!("No env option at this level, nothing to remove"); + return Ok(()); + } + let env = binding.env.get_or_insert_default(); match setlist_type { Some(SetListType::White) => match action { @@ -1136,7 +1373,7 @@ pub fn env_setlist_add( env.keep .replace(options_key_env.as_ref().ok_or("Empty list")?.clone()); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Black) => match action { InputAction::Add => { @@ -1159,7 +1396,7 @@ pub fn env_setlist_add( env.delete .replace(options_key_env.as_ref().ok_or("Empty list")?.clone()); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Check) => match action { InputAction::Add => { @@ -1181,7 +1418,7 @@ pub fn env_setlist_add( InputAction::Purge => { env.check = None; } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, Some(SetListType::Set) => match action { InputAction::Add => { @@ -1211,14 +1448,14 @@ pub fn env_setlist_add( env.set .replace(options_env_values.as_ref().ok_or("Empty list")?.clone()); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, None => match action { InputAction::Set => { env.keep .replace(options_key_env.as_ref().ok_or("Empty list")?.clone()); } - _ => unreachable!("Unknown action {:?}", action), + _ => return Err(format!("Unknown action {action:?}").into()), }, } Ok(()) @@ -1241,3 +1478,27 @@ pub fn env_setpolicy( })?; Ok(true) } + +#[cfg(test)] +mod tests { + use rootasrole_core::database::options::{Level, Opt, OptType, SBounding, STimeout}; + + use super::options_to_show; + + #[test] + fn test_options_to_show_only_the_option_asked_for() { + let opt = Opt::builder(Level::Role) + .bounding(SBounding::Strict) + .timeout(STimeout::default()) + .build(); + assert_eq!( + options_to_show(&opt, Some(OptType::Bounding)).unwrap(), + "\"strict\"" + ); + assert_eq!(options_to_show(&opt, Some(OptType::Root)).unwrap(), "null"); + let all: serde_json::Value = + serde_json::from_str(&options_to_show(&opt, None).unwrap()).unwrap(); + assert_eq!(all["bounding"], "strict"); + assert!(all.get("timeout").is_some()); + } +} diff --git a/src/chsr/cli/process/upgrade.rs b/src/chsr/cli/process/upgrade.rs index 2fee8e1a..3b58c34a 100644 --- a/src/chsr/cli/process/upgrade.rs +++ b/src/chsr/cli/process/upgrade.rs @@ -1,3 +1,4 @@ +use rootasrole_core::{eprintln_ignore_io_error, println_ignore_io_error}; use std::error::Error; use log::debug; @@ -65,12 +66,12 @@ pub fn upgrade(settings: &mut FileSettings, check: bool) -> Result &'static UsageSection { UsageSectionId::OptionsWorkdir => &USAGE_OPTIONS_WORKDIR, UsageSectionId::OptionsEnv => &USAGE_OPTIONS_ENV, UsageSectionId::OptionsTimeout => &USAGE_OPTIONS_TIMEOUT, + UsageSectionId::OptionsPassword => &USAGE_OPTIONS_PASSWORD, + UsageSectionId::OptionsTty => &USAGE_OPTIONS_TTY, UsageSectionId::OptionsAuth => &USAGE_OPTIONS_AUTH, UsageSectionId::OptionsExecinfo => &USAGE_OPTIONS_EXECINFO, UsageSectionId::OptionsUmask => &USAGE_OPTIONS_UMASK, @@ -638,8 +774,8 @@ fn render_usage(sections: &[UsageSectionId]) -> String { pub fn help() { debug!("chsr help"); - println!("{LONG_ABOUT}"); - println!( + println_ignore_io_error!("{LONG_ABOUT}"); + println_ignore_io_error!( "{}", render_usage(&[UsageSectionId::Usage, UsageSectionId::Commands]) ); @@ -653,6 +789,9 @@ fn rule_to_string(rule: Rule) -> String { Rule::opt_timeout_d_arg => "--duration (hh:mm:ss)", Rule::opt_timeout_t_arg => "--type (tty, ppid, uid)", Rule::opt_timeout_m_arg => "--max-usage (\\d+)", + Rule::opt_password_operations => "password set/unset operations", + Rule::opt_password_t_arg => "--tries (1-255)", + Rule::opt_password_d_arg => "--delay (hh:mm:ss)", Rule::roles_operations => "roles list/purge/add/del operations or existing role name", Rule::role_type_arg => "all, actors or tasks", Rule::role_grant_revoke => "grant, revoke", @@ -672,8 +811,23 @@ fn rule_to_string(rule: Rule) -> String { Rule::cred_caps_operations => "caps", Rule::list => "show, list, l", Rule::opt_timeout => "timeout", + Rule::opt_password => "password", + Rule::opt_tty => "tty", + Rule::opt_tty_owner => "owner", + Rule::opt_tty_owner_args => "caller, target or unset", Rule::opt_path | Rule::path => "path", Rule::opt_env => "env", + Rule::role_links | Rule::role_link_kind => "ssd, parents", + Rule::linked_roles | Rule::linked_role => "role names, separated by commas", + Rule::purpose_text => "the purpose of the task", + Rule::cmd_hash | Rule::cmd_hash_type => { + "--sha224, --sha256, --sha384 or --sha512 and a hash" + } + Rule::cmd_hash_value => "a hash in hexadecimal", + Rule::opt_env_override => "override", + Rule::env_override_value => "allow, deny", + Rule::env_key | Rule::env_key_list => "a variable name, where * matches any characters", + Rule::env_key_value | Rule::env_value_list => "NAME=value", Rule::opt_workdir => "workdir", Rule::opt_root => "root", Rule::opt_bounding => "bounding", @@ -701,7 +855,7 @@ fn rule_to_string(rule: Rule) -> String { Rule::del => "del", Rule::purge => "purge", _ => { - println!("{rule:?}"); + println_ignore_io_error!("{rule:?}"); "unknown rule" } } @@ -719,11 +873,15 @@ const DEFAULT_USAGE_SECTIONS: &[UsageSectionId] = &[ UsageSectionId::Upgrade, ]; +const ROLE_SECTIONS: &[UsageSectionId] = &[UsageSectionId::Role]; +const TASK_SECTIONS: &[UsageSectionId] = &[UsageSectionId::Task]; const OPTIONS_GENERAL_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsGeneral]; const OPTIONS_AUTH_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsAuth]; const OPTIONS_EXECINFO_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsExecinfo]; const OPTIONS_UMASK_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsUmask]; const OPTIONS_TIMEOUT_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsTimeout]; +const OPTIONS_PASSWORD_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsPassword]; +const OPTIONS_TTY_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsTty]; const OPTIONS_PATH_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsPath]; const OPTIONS_WORKDIR_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsWorkdir]; const OPTIONS_ENV_SECTIONS: &[UsageSectionId] = &[UsageSectionId::OptionsEnv]; @@ -756,6 +914,13 @@ const fn usage_sections_for_rule(rule: Rule) -> Option<&'static [UsageSectionId] | Rule::opt_timeout_d_arg | Rule::opt_timeout_t_arg | Rule::opt_timeout_m_arg => Some(OPTIONS_TIMEOUT_SECTIONS), + Rule::opt_password + | Rule::opt_password_operations + | Rule::opt_password_t_arg + | Rule::opt_password_d_arg => Some(OPTIONS_PASSWORD_SECTIONS), + Rule::opt_tty | Rule::opt_tty_owner | Rule::opt_tty_owner_args => { + Some(OPTIONS_TTY_SECTIONS) + } Rule::opt_path | Rule::opt_path_args | Rule::opt_path_set @@ -773,9 +938,20 @@ const fn usage_sections_for_rule(rule: Rule) -> Option<&'static [UsageSectionId] | Rule::opt_env_set | Rule::env_key_list | Rule::env_value_list - | Rule::env_key => Some(OPTIONS_ENV_SECTIONS), + | Rule::env_key + | Rule::opt_env_override + | Rule::env_override_value => Some(OPTIONS_ENV_SECTIONS), + Rule::role_links | Rule::role_link_kind | Rule::linked_roles | Rule::linked_role => { + Some(ROLE_SECTIONS) + } + Rule::purpose_keyword | Rule::purpose_text => Some(TASK_SECTIONS), Rule::caps_listing => Some(CREDENTIALS_CAPS_SECTIONS), - Rule::cmd_checklisting => Some(COMMAND_LISTING_SECTIONS), + Rule::cmd_checklisting + | Rule::cmd_checks + | Rule::cmd_hash + | Rule::cmd_hash_type + | Rule::cmd_hash_value + | Rule::cmd_check_flag => Some(COMMAND_LISTING_SECTIONS), Rule::opt_path_listing => Some(PATH_LISTING_SECTIONS), Rule::opt_env_listing => Some(ENV_LISTING_SECTIONS), Rule::convert => Some(CONVERT_SECTIONS), @@ -792,8 +968,8 @@ pub fn print_usage(e: pest::error::Error) -> Result> } rule_to_string(*rule) }); - println!("{usage}"); - println!( + println_ignore_io_error!("{usage}"); + println_ignore_io_error!( "{RED}{BOLD}Unrecognized command line:\n| {RST}{}{RED}{BOLD}\n| {}\n= {}{RST}", e.line(), underline(&e), @@ -833,6 +1009,28 @@ mod tests { ); } + #[test] + fn maps_password_rules_to_password_section() { + for rule in [ + Rule::opt_password, + Rule::opt_password_operations, + Rule::opt_password_t_arg, + Rule::opt_password_d_arg, + ] { + assert_eq!( + usage_sections_for_rule(rule), + Some(OPTIONS_PASSWORD_SECTIONS) + ); + } + } + + #[test] + fn maps_tty_rules_to_tty_section() { + for rule in [Rule::opt_tty, Rule::opt_tty_owner, Rule::opt_tty_owner_args] { + assert_eq!(usage_sections_for_rule(rule), Some(OPTIONS_TTY_SECTIONS)); + } + } + #[test] fn returns_none_for_unknown_rule() { assert_eq!(usage_sections_for_rule(Rule::EOI), None); diff --git a/src/chsr/main.rs b/src/chsr/main.rs index 439bbdab..34c3f716 100644 --- a/src/chsr/main.rs +++ b/src/chsr/main.rs @@ -1,13 +1,52 @@ //extern crate sudoers_reader; +// `println!` and the like panic when the output is a closed pipe (`EPIPE`, since Rust ignores +// `SIGPIPE`): outside the tests, the `*_ignore_io_error` macros of rootasrole-core are used. +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! println { + ($($tt:tt)*) => { + compile_error!("println! panics on EPIPE, use println_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! print { + ($($tt:tt)*) => { + compile_error!("print! panics on EPIPE, use print_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprintln { + ($($tt:tt)*) => { + compile_error!("eprintln! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprint { + ($($tt:tt)*) => { + compile_error!("eprint! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} + mod cli; #[cfg(not(tarpaulin_include))] mod security; mod util; +/// Report an error that stops chsr, on stderr and in the log. +#[cfg(not(tarpaulin_include))] +fn failure(message: &str) -> std::process::ExitCode { + log::error!("{message}"); + rootasrole_core::eprintln_ignore_io_error!("chsr: {message}"); + std::process::ExitCode::FAILURE +} + #[cfg(not(tarpaulin_include))] fn main() -> Result> { - use std::{env::temp_dir, process::ExitCode}; + use std::{path::Path, process::ExitCode}; use crate::{ cli::editor::defer, @@ -15,16 +54,16 @@ fn main() -> Result> { }; use ::landlock::{RestrictionStatus, RulesetStatus}; use capctl::Cap; - use log::{debug, error, warn}; + use log::{debug, warn}; use rootasrole_core::{ file::FileSettings, - log::subsribe, + log::subscribe, util::{RAR_CFG_TYPE, definitive_drop}, }; use crate::security::full_program_lock; - subsribe("chsr")?; + subscribe("chsr")?; // Drop privileges we don't need definitive_drop(&[ Cap::DAC_OVERRIDE, @@ -34,14 +73,38 @@ fn main() -> Result> { Cap::LINUX_IMMUTABLE, ])?; - let folder = nix::unistd::mkdtemp(&temp_dir().join("chsr_XXXXXX")) - .expect("Failed to create temporary folder"); + // argv is untrusted: a non-UTF-8 argument is refused, std::env::args() would panic + let Ok(args) = std::env::args_os() + .skip(1) + .map(std::ffi::OsString::into_string) + .collect::, _>>() + else { + return Ok(failure("an argument is not valid UTF-8")); + }; + // --help and a usage error need neither the policy nor the temporary folder + if let Some(result) = cli::usage_without_policy(&args) { + return Ok(if result.is_ok() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }); + } + + // not in TMPDIR, which the user chooses when the policy keeps it: a folder in a directory + // that the user owns can be renamed and replaced; /tmp is sticky + let folder = match nix::unistd::mkdtemp(&Path::new("/tmp").join("chsr_XXXXXX")) { + Ok(folder) => folder, + Err(e) => return Ok(failure(&format!("cannot create a temporary folder: {e}"))), + }; let _cleanup = defer(|| { let _ = std::fs::remove_dir_all(&folder); }); - let mut settings = FileSettings::write_all(RAR_CFG_PATH, RAR_CFG_DATA_PATH, RAR_CFG_TYPE) - .expect("Error on config read"); + let mut settings = match FileSettings::write_all(RAR_CFG_PATH, RAR_CFG_DATA_PATH, RAR_CFG_TYPE) + { + Ok(settings) => settings, + Err(e) => return Ok(failure(&format!("cannot read the policy: {e}"))), + }; // Apply Landlock restrictions let ruleset_status = match full_program_lock( @@ -62,7 +125,7 @@ fn main() -> Result> { } }; - match cli::main(&mut settings, std::env::args().skip(1)) + match cli::main(&mut settings, &args) .ruleset(&ruleset_status) .folder(&folder) .call() @@ -73,9 +136,7 @@ fn main() -> Result> { Ok(ExitCode::SUCCESS) } Ok(false) => Ok(ExitCode::SUCCESS), - Err(e) => { - error!("Unable to edit policy : {e}"); - Ok(ExitCode::FAILURE) - } + // on the terminal too: in a release build, the log only goes to syslog + Err(e) => Ok(failure(&format!("cannot edit the policy: {e}"))), } } diff --git a/src/chsr/security.rs b/src/chsr/security.rs index fee00e24..36fc6d52 100644 --- a/src/chsr/security.rs +++ b/src/chsr/security.rs @@ -3,10 +3,11 @@ use std::path::PathBuf; /// Security module, provides Landlock and Seccomp locking /// This way, the administrator cannot edit files that should not be edited use landlock::{ - ABI, Access, AccessFs, Compatible, PathBeneath, PathFd, RestrictionStatus, Ruleset, + ABI, Access, AccessFs, BitFlags, Compatible, PathBeneath, PathFd, RestrictionStatus, Ruleset, RulesetAttr, RulesetCreatedAttr, }; use libseccomp::{ScmpAction, ScmpFilterContext, ScmpSyscall}; +use log::debug; use crate::{cli::editor::SYSTEM_EDITOR_LIST, util::RAR_CFG_PATH}; @@ -34,49 +35,46 @@ pub fn full_program_lock( AccessFs::from_all(ABI::V6), ))?; - //TODO: Add rule allowing the path of the policy - for &editor in SYSTEM_EDITOR_LIST { - if !editor.is_empty() { - ruleset = ruleset.add_rule(PathBeneath::new( - PathFd::new(editor)?, - AccessFs::from_read(ABI::V6), - ))?; + // what the editor needs to run, where it exists + let read = AccessFs::from_read(ABI::V6); + let data = read & !AccessFs::Execute; + let optional = SYSTEM_EDITOR_LIST + .iter() + .filter(|editor| !editor.is_empty()) + .map(|&editor| (editor, read)) + .chain([ + ("/usr/share/locale", data), + ("/usr/share/terminfo", data), + ("/usr/share/vim", data), + ("/etc/vimrc", data), + ("/lib", read), + ("/usr/lib", read), + ]); + for (path, access) in optional { + if let Some(rule) = optional_rule(path, access) { + ruleset = ruleset.add_rule(rule)?; } } Ok(ruleset - // Allow locale + terminfo - .add_rule(PathBeneath::new( - PathFd::new("/usr/share/locale")?, - AccessFs::from_read(ABI::V6) & !AccessFs::Execute, - ))? - .add_rule(PathBeneath::new( - PathFd::new("/usr/share/terminfo")?, - AccessFs::from_read(ABI::V6) & !AccessFs::Execute, - ))? - // Allow vim runtime files - .add_rule(PathBeneath::new( - PathFd::new("/usr/share/vim")?, - AccessFs::from_read(ABI::V6) & !AccessFs::Execute, - ))? - // Allow /etc/vimrc - .add_rule(PathBeneath::new( - PathFd::new("/etc/vimrc")?, - AccessFs::from_read(ABI::V6) & !AccessFs::Execute, - ))? - .add_rule(PathBeneath::new( - PathFd::new("/lib")?, - AccessFs::from_read(ABI::V6), - ))? - .add_rule(PathBeneath::new( - PathFd::new("/usr/lib")?, - AccessFs::from_read(ABI::V6), - ))? .set_compatibility(landlock::CompatLevel::BestEffort) .no_new_privs(true) .restrict_self()?) } +/// The rule for a path that the editor may need, or `None` when it does not exist on this +/// system: one missing path, like `/etc/vimrc` on Debian or an editor that is not installed, made +/// the whole ruleset fail, and chsr then ran without Landlock. Leaving it out only allows less. +fn optional_rule(path: &str, access: BitFlags) -> Option> { + match PathFd::new(path) { + Ok(fd) => Some(PathBeneath::new(fd, access)), + Err(e) => { + debug!("Landlock: {path} is left out: {e}"); + None + } + } +} + #[cfg(debug_assertions)] const SECCOMP: ScmpAction = ScmpAction::Log; #[cfg(not(debug_assertions))] @@ -146,3 +144,15 @@ pub fn seccomp_lock() -> std::io::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_a_missing_path_is_left_out() { + let read = AccessFs::from_read(ABI::V6); + assert!(optional_rule("/nonexistent/rar-editor", read).is_none()); + assert!(optional_rule("/", read).is_some()); + } +} diff --git a/src/dosr/finder/api/ssd.rs b/src/dosr/finder/api/ssd.rs index a744e718..da572925 100644 --- a/src/dosr/finder/api/ssd.rs +++ b/src/dosr/finder/api/ssd.rs @@ -3,7 +3,7 @@ use std::{ hash::{DefaultHasher, Hash, Hasher}, }; -use log::debug; +use log::{debug, warn}; use serde_json::Value; #[cfg(feature = "hierarchy")] @@ -57,6 +57,21 @@ pub(super) fn excludes(role: &DLinkedRole) -> SrResult { } fn check_ssd_recursive(role: &DLinkedRole, visited: &mut HashSet) -> SrResult { + // a failed lookup of an actor leaves unknown whether the user holds a role: the separation + // of duties cannot be checked, so every role of the policy file that has one is disabled + // (decided by the maintainer on 2026-10-23); SSD only names roles of the same file + if role.role().extra_values.contains_key("ssd") + && role + .config() + .roles() + .any(|other| other.role().lookup_failed) + { + warn!( + "The role {} is disabled: it has a separation of duties, and an actor of its policy could not be looked up", + role.role().role + ); + return Ok(true); + } if let Some(Value::Array(ssd)) = role.role().extra_values.get("ssd") { for ssd in ssd { if let Value::String(ssd) = ssd { diff --git a/src/dosr/finder/cmd.rs b/src/dosr/finder/cmd.rs index e1cbdd63..7a36b12d 100644 --- a/src/dosr/finder/cmd.rs +++ b/src/dosr/finder/cmd.rs @@ -1,9 +1,57 @@ use log::{debug, info, warn}; use rootasrole_core::{ - database::score::{CmdMin, CmdOrder}, + database::{ + command::{anchored_regex, regex_arguments, split_command}, + score::{CmdMin, CmdOrder}, + }, util::{all_paths_from_env, match_single_path}, }; -use std::path::{Path, PathBuf}; +use std::{ + cell::RefCell, + collections::HashMap, + path::{Path, PathBuf}, +}; + +/// The paths found for a command name (the second key) in a PATH (the first key, joined by `:`). +type PathSearches = HashMap<(String, PathBuf), Vec>; + +thread_local! { + /// Where a command typed by its name is found in a PATH, during one decision: most tasks + /// share their PATH, and each search costs a `stat` per directory, for each command of each + /// task. It only exists while a decision runs ([`PathSearchCache::start`]), so that another + /// decision, or a direct call, sees the files as they are then. + static PATH_SEARCHES: RefCell> = const { RefCell::new(None) }; +} + +/// Keeps the searches in the PATH while it lives. +pub(super) struct PathSearchCache; + +impl PathSearchCache { + pub(super) fn start() -> Self { + PATH_SEARCHES.with(|searches| *searches.borrow_mut() = Some(HashMap::new())); + Self + } +} + +impl Drop for PathSearchCache { + fn drop(&mut self) { + PATH_SEARCHES.with(|searches| *searches.borrow_mut() = None); + } +} + +/// [`all_paths_from_env`], kept for the rest of the decision when a [`PathSearchCache`] lives. +fn paths_from_env(env_path: &[&str], exe_name: &Path) -> Vec { + PATH_SEARCHES.with(|searches| { + let mut searches = searches.borrow_mut(); + let Some(searches) = searches.as_mut() else { + return all_paths_from_env(env_path, exe_name); + }; + searches + .entry((env_path.join(":"), exe_name.to_path_buf())) + .or_insert_with(|| all_paths_from_env(env_path, exe_name)) + .clone() + }) +} /// Path of the command to run when a task allows every command, or for the `**` pattern. /// A name without `/` is only searched in PATH, never in the current directory (like a shell), @@ -22,7 +70,7 @@ pub(super) fn resolve_any_command(cmd_path: &Path, env_path: &[&str]) -> Option< fn match_path( env_path: &[&str], user_path: &Path, - role_path: &String, + role_path: &str, previous_min: CmdMin, final_path: &mut Option, ) -> CmdMin { @@ -49,7 +97,7 @@ fn match_path( } else { debug!("match_path: user relative path"); let mut curmin = CmdMin::empty(); - all_paths_from_env(env_path, user_path) + paths_from_env(env_path, user_path) .iter() .find_map(|cmd_path| { let min = match_single_path(cmd_path, role_path); @@ -72,33 +120,12 @@ fn match_path( } } -/// The regular expression of the arguments of a command of the policy, as written after its -/// path: arguments starting with `^` and ending with `$`, single-quoted or not. -fn regex_arguments(args: &str) -> Option<&str> { - let args = args.trim(); - let args = args - .strip_prefix('\'') - .and_then(|quoted| quoted.strip_suffix('\'')) - .filter(|quoted| quoted.starts_with('^') && quoted.ends_with('$')) - .unwrap_or(args); - (args.len() >= 2 && args.starts_with('^') && args.ends_with('$')).then_some(args) -} - /// The path of a command of the policy, unquoted, and its arguments as written: a regular /// expression must not go through shell splitting, which removes its backslashes. fn split_role_command(role_cmd: &str) -> Option<(String, &str)> { - let role_cmd = role_cmd.trim_start(); - let first = role_cmd.chars().next()?; - let path_end = if first == '\'' || first == '"' { - role_cmd[1..].find(first)? + 2 - } else { - role_cmd.find(char::is_whitespace).unwrap_or(role_cmd.len()) - }; - let path = shell_words::split(&role_cmd[..path_end]) - .ok()? - .into_iter() - .next()?; - Some((path, &role_cmd[path_end..])) + let (path, args) = split_command(role_cmd)?; + let path = shell_words::split(path).ok()?.into_iter().next()?; + Some((path, args)) } /// Whether the arguments of the user match the regular expression `regex` of the policy. @@ -112,7 +139,8 @@ fn match_regex_args( .order(CmdOrder::FullRegexArgs) .build()); } - evaluate_regex_cmd(regex, &shell_words::join(input_args)).inspect_err(|e| { + // anchored at both ends whatever its alternations: ^start|stop$ allows start or stop alone + evaluate_regex_cmd(&anchored_regex(regex), &shell_words::join(input_args)).inspect_err(|e| { warn!("Invalid regular expression in the policy, it never matches: {regex}: {e}"); }) } @@ -226,6 +254,18 @@ pub fn evaluate_command_match( previous_min: CmdMin, final_path: &mut Option, ) -> CmdMin { + // a path alone, the most common command of a policy, needs no splitting: like a command + // line of one word, it allows the command without arguments + if !role_cmd.is_empty() + && !role_cmd.contains(|c: char| c.is_whitespace() || matches!(c, '\'' | '"' | '\\')) + { + let result = match_path(env_path, cmd_path, role_cmd, previous_min, final_path); + return if result.is_empty() || cmd_args.is_empty() { + result + } else { + CmdMin::empty() + }; + } // a regular expression is taken as written: unquoted, it may contain spaces, quotes and // backslashes that shell splitting would change if let Some((role_path, args)) = split_role_command(role_cmd) @@ -719,6 +759,31 @@ mod tests { ) } + #[test] + fn test_a_top_level_alternation_is_anchored_on_both_sides() { + let systemctl = + |rule: &str, args: &[&str]| allows(rule, "/usr/bin/systemctl", args).matching(); + let rule = "/usr/bin/systemctl ^start|stop$"; + assert!(systemctl(rule, &["start"])); + assert!(systemctl(rule, &["stop"])); + // not ^start or stop$: the alternation is between the whole arguments + assert!(!systemctl(rule, &["start", "anything"])); + assert!(!systemctl(rule, &["please", "stop"])); + // a group, a character class and an escaped bar keep their meaning + assert!(systemctl( + "/usr/bin/systemctl ^(start|stop) caddy$", + &["stop", "caddy"] + )); + assert!(!systemctl( + "/usr/bin/systemctl ^(start|stop) caddy$", + &["stop", "sshd"] + )); + // a bar in a character class is no alternation; the arguments are joined with the + // quotes of a shell, so a bar typed by the user would be '|' + assert!(systemctl("/usr/bin/systemctl ^[x|y]z$", &["xz"])); + assert!(!systemctl("/usr/bin/systemctl ^[x|y]z$", &["x"])); + } + #[test] fn test_regex_arguments_with_or_without_quotes() { for rule in [ diff --git a/src/dosr/finder/de/cred.rs b/src/dosr/finder/de/cred.rs index a478f3e2..92eeee95 100644 --- a/src/dosr/finder/de/cred.rs +++ b/src/dosr/finder/de/cred.rs @@ -8,7 +8,7 @@ use rootasrole_core::{ database::{ actor::{DGroupType, DGroups, DUserType}, score::{CapsMin, SetgidMin, SetuidMin, TaskScore}, - structs::{SCapabilities, SetBehavior}, + structs::{GroupsFrom, SCapabilities, SetBehavior}, }, util::capabilities_are_exploitable, }; @@ -28,6 +28,8 @@ pub(super) struct CredFinderDeserializerReturn<'a> { pub struct CredData<'a> { pub setuid: Option>, pub setgroups: Option>, + /// Whose groups the command gets when `setgroups` is `None` + pub groups_from: Option, pub caps: Option, #[builder(default)] pub extra_values: HashMap, Value>, @@ -37,6 +39,8 @@ pub struct CredData<'a> { pub struct CredOwnedData { pub setuid: Option, pub setgroups: Option>, + /// Whose groups the command gets when `setgroups` is `None` + pub groups_from: Option, pub caps: Option, #[builder(default)] pub extra_values: HashMap, @@ -96,6 +100,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for CredFinderDeserializerReturn<'a> { { let mut setuid = None; let mut setgroups = None; + let mut groups_from = None; let mut caps = None; let mut score = TaskScore::default(); let mut ok = true; @@ -114,9 +119,10 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for CredFinderDeserializerReturn<'a> { } Field::Setgid => { debug!("CredFinderVisitor: setgid"); - let (groups, setuser_min, groups_ok) = + let (groups, setuser_min, groups_ok, from) = map.next_value_seed(SetGroupsDeserializerReturn { cli: self.cli })?; setgroups = groups; + groups_from = from; score.setuser_min.gid = setuser_min; if !groups_ok { ok = false; @@ -141,6 +147,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for CredFinderDeserializerReturn<'a> { cred: CredData { setuid, setgroups, + groups_from, caps, extra_values, }, @@ -159,24 +166,33 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for CredFinderDeserializerReturn<'a> { fn fixed_groups<'a, E: serde::de::Error>( cli: &Cli, groups: DGroups<'a>, -) -> Result<(Option>, Option, bool), E> { +) -> Result, E> { if let Some(requested) = cli.opt_filter.as_ref().and_then(|f| f.group.as_ref()) { let ids: Vec = (&groups).try_into().map_err(E::custom)?; if *requested != ids { - return Ok((None, None, false)); + return Ok((None, None, false, None)); } } let score = Some(SetgidMin::from(&groups)); - Ok((Some(groups), score, true)) + Ok((Some(groups), score, true, None)) } +/// The groups of a `setgid`, their score, whether the task can be used with the `-g` of the +/// command line, and `from` of a selector. +type SetGroupsResult<'a> = ( + Option>, + Option, + bool, + Option, +); + // New deserializer for SetGroups that returns values instead of using &mut struct SetGroupsDeserializerReturn<'a> { cli: &'a Cli, } impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { - type Value = (Option>, Option, bool); + type Value = SetGroupsResult<'a>; #[allow(clippy::too_many_lines)] fn deserialize(self, deserializer: D) -> Result where @@ -194,12 +210,14 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { Add, #[serde(alias = "s", alias = "sub")] Del, + #[serde(alias = "o")] + From, } struct SGroupsChooserVisitor<'a> { cli: &'a Cli, } impl<'de: 'a, 'a> serde::de::Visitor<'de> for SGroupsChooserVisitor<'a> { - type Value = (Option>, Option, bool); + type Value = SetGroupsResult<'a>; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("SGroups structure") @@ -261,7 +279,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { } } if groups.is_empty() { - return Ok((None, None, false)); + return Ok((None, None, false, None)); } fixed_groups(self.cli, DGroups::from(groups)) } @@ -273,9 +291,14 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { let mut score = None; let mut ok = false; let mut default_all = false; + let mut from = None; let filter = self.cli.opt_filter.as_ref().and_then(|x| x.group.as_ref()); 'fields: while let Some(key) = map.next_key()? { match key { + Field::From => { + debug!("SGroupsChooserVisitor: from"); + from = Some(map.next_value::()?); + } Field::Default => { debug!("SGroupsChooserVisitor: default"); let default = map.next_value::()?; @@ -361,7 +384,11 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for SetGroupsDeserializerReturn<'a> { score.replace((&requested).into()); groups = Some(requested); } - Ok((groups, score, ok)) + // without -g nor fallback, the groups that from names + if filter.is_none() && groups.is_none() && from.is_some() { + ok = true; + } + Ok((groups, score, ok, from)) } } deserializer.deserialize_any(SGroupsChooserVisitor { cli: self.cli }) @@ -699,7 +726,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::from(vec![1.into(), 2.into()]); assert_eq!(score, Some((&groups1).into())); @@ -742,12 +769,64 @@ mod test { FilterMatcher::builder().group(gids).unwrap().build() }); let cli = Cli::builder().maybe_opt_filter(filter).build(); - let (groups, score, ok) = SetGroupsDeserializerReturn { cli: &cli } + let (groups, score, ok, _) = SetGroupsDeserializerReturn { cli: &cli } .deserialize(&mut serde_json::Deserializer::from_str(json)) .unwrap(); ok.then(|| ((&groups.unwrap()).try_into().unwrap(), score.unwrap())) } + /// The result of a setgid selector for the `-g` of the command line, `requested`. + fn selector(json: &str, requested: Option<&[u32]>) -> (bool, bool, Option) { + let filter = requested.map(|gids| { + let gids: Vec = gids.iter().map(|gid| (*gid).into()).collect(); + FilterMatcher::builder().group(gids).unwrap().build() + }); + let cli = Cli::builder().maybe_opt_filter(filter).build(); + let (groups, _, ok, from) = SetGroupsDeserializerReturn { cli: &cli } + .deserialize(&mut serde_json::Deserializer::from_str(json)) + .unwrap(); + (ok, groups.is_some(), from) + } + + #[test] + fn test_group_selector_from() { + let gid = get_non_root_gid(0).unwrap(); + // without -g nor fallback, the task is usable with the groups that from names + assert_eq!( + selector(r#"{"from": "caller"}"#, None), + (true, false, Some(GroupsFrom::Caller)) + ); + assert_eq!( + selector( + &format!(r#"{{"default": "none", "add": [[{gid}]], "o": "target"}}"#), + None + ), + (true, false, Some(GroupsFrom::Target)) + ); + // without from, the none default still requires -g + assert_eq!( + selector(&format!(r#"{{"default": "none", "add": [[{gid}]]}}"#), None), + (false, false, None) + ); + // the all default without fallback: no groups here, the target user gives them + assert_eq!(selector(r#"{"default": "all"}"#, None), (true, false, None)); + // from does not allow a group that the selector does not list + assert_eq!( + selector(r#"{"from": "caller"}"#, Some(&[gid])), + (false, false, Some(GroupsFrom::Caller)) + ); + // a fallback is still taken first + assert_eq!( + selector( + &format!(r#"{{"default": "none", "fallback": [{gid}], "from": "caller"}}"#), + None + ), + (true, true, Some(GroupsFrom::Caller)) + ); + // a forced group has no from + assert_eq!(selector(&gid.to_string(), None), (true, true, None)); + } + #[test] fn test_group_selector_all_applies_the_requested_groups() { let fallback = get_non_root_gid(0).unwrap(); @@ -806,7 +885,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::Single("root".into()); assert_eq!(score, Some((&groups1).into())); @@ -817,7 +896,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::Single(gid1.into()); assert_eq!(score, Some((&groups1).into())); @@ -828,7 +907,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(!ok); assert_eq!(score, None); assert_eq!(groups, None); @@ -839,7 +918,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok(), "Failed to deserialize: {result:?}"); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::Single("root".into()); assert_eq!(score, Some((&groups1).into())); @@ -850,7 +929,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(!ok); assert_eq!(score, None); assert_eq!(groups, None); @@ -861,7 +940,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(!ok); assert_eq!(score, None); assert_eq!(groups, None); @@ -871,7 +950,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::Single(0.into()); assert_eq!(score, Some((&groups1).into())); @@ -889,7 +968,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok(), "Failed to deserialize: {result:?}"); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::from(vec!["root".into(), 1.into()]); assert_eq!(score, Some((&groups1).into())); @@ -900,7 +979,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok(), "Failed to deserialize: {result:?}"); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); let groups1 = DGroups::from(vec!["root".into(), 1.into()]); assert_eq!(score, Some((&groups1).into())); @@ -911,7 +990,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(!ok); assert_eq!(score, None); assert_eq!(groups, None); @@ -929,7 +1008,7 @@ mod test { let deserializer = SetGroupsDeserializerReturn { cli: &cli }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json)); assert!(result.is_ok()); - let (groups, score, ok) = result.unwrap(); + let (groups, score, ok, _) = result.unwrap(); assert!(ok); assert_eq!(score, None); assert_eq!(groups, None); diff --git a/src/dosr/finder/de/mod.rs b/src/dosr/finder/de/mod.rs index 8c3bf3be..c202fbff 100644 --- a/src/dosr/finder/de/mod.rs +++ b/src/dosr/finder/de/mod.rs @@ -43,6 +43,10 @@ pub struct DConfigFinder<'a> { pub struct DRoleFinder<'a> { #[cfg_attr(test, builder(default))] pub user_min: ActorMatchMin, + /// Looking up an actor of the role written by name failed: whether the user holds the + /// role is unknown, and it is not matched + #[cfg_attr(test, builder(default))] + pub lookup_failed: bool, #[cfg_attr(test, builder(into))] pub role: Cow<'a, str>, #[cfg_attr(test, builder(default))] diff --git a/src/dosr/finder/de/roles.rs b/src/dosr/finder/de/roles.rs index 036bcbf9..299915e8 100644 --- a/src/dosr/finder/de/roles.rs +++ b/src/dosr/finder/de/roles.rs @@ -1,18 +1,17 @@ use std::{borrow::Cow, collections::HashMap}; use log::{debug, warn}; -use nix::unistd::{Gid, Group}; use rootasrole_core::{ Cred, database::{ - actor::{DActor, DGroups}, + actor::{DActor, DGroupType, DGroups, DUserType}, score::ActorMatchMin, }, - util::{Either, StorageMethod}, + util::{StorageMethod, either_to_gid}, }; use serde::{ Deserialize, - de::{DeserializeSeed, IgnoredAny, Visitor}, + de::{DeserializeSeed, Visitor}, }; use serde_json::Value; @@ -119,6 +118,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for RoleFinderDeserializer<'a> { let mut options = None; let mut extra_values = HashMap::new(); let mut user_min = ActorMatchMin::default(); + let mut lookup_failed = false; while let Some(key) = map.next_key()? { match key { Field::Options => { @@ -127,23 +127,18 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for RoleFinderDeserializer<'a> { } Field::Name => { debug!("RoleFinderVisitor: name"); - let role_name = map.next_value()?; - if self - .cli - .opt_filter - .as_ref() - .and_then(|x| x.role.as_ref()) - .is_some_and(|r| r != &role_name) - { - while map.next_entry::()?.is_some() {} - return Ok(None); - } - role = Some(role_name); + // every role is read, -r included: the plugins need the others, + // the SSD plugin to know which roles the user holds, the hierarchy + // plugin for the tasks of the parents; -r applies when choosing + role = Some(map.next_value()?); } Field::Actors => { debug!("RoleFinderVisitor: actors"); - user_min = - map.next_value_seed(ActorsFinderDeserializer { cred: self.cred })?; + (user_min, lookup_failed) = + map.next_value_seed(ActorsFinderDeserializer { + cred: self.cred, + lookup: SYSTEM_LOOKUP, + })?; } Field::Tasks => { debug!("RoleFinderVisitor: tasks"); @@ -159,6 +154,7 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for RoleFinderDeserializer<'a> { } Ok(Some(DRoleFinder { user_min, + lookup_failed, role: role.unwrap_or_default(), tasks, options, @@ -177,20 +173,91 @@ impl<'de: 'a, 'a> DeserializeSeed<'de> for RoleFinderDeserializer<'a> { struct ActorsFinderDeserializer<'a> { cred: &'a Cred, + lookup: ActorLookup, +} + +/// How the actors written by name are looked up: in the system databases (NSS), or with a +/// stand-in in the tests, to make a lookup fail. +#[derive(Clone, Copy)] +struct ActorLookup { + user: fn(&DUserType<'_>) -> nix::Result>, + group: fn(&DGroupType<'_>) -> nix::Result>, +} + +fn system_user_id(user: &DUserType<'_>) -> nix::Result> { + user.try_fetch_id() +} + +fn system_group_id(group: &DGroupType<'_>) -> nix::Result> { + group.try_fetch_id() +} + +const SYSTEM_LOOKUP: ActorLookup = ActorLookup { + user: system_user_id, + group: system_group_id, +}; + +/// How well an actor matches the user, and whether looking a name up failed. A failed lookup +/// never matches, so it never grants a role; the SSD plugin disables the roles with a +/// separation of duties, which it cannot check then (`finder::api::ssd`). +fn actor_matches(user: &Cred, actor: &DActor<'_>, lookup: ActorLookup) -> (ActorMatchMin, bool) { + match actor { + DActor::User { id, .. } => match (lookup.user)(id) { + Ok(uid) if uid == Some(user.user.uid.as_raw()) => (ActorMatchMin::UserMatch, false), + Ok(_) => (ActorMatchMin::NoMatch, false), + Err(e) => { + warn!("Cannot look up the user {id}: {e}"); + (ActorMatchMin::NoMatch, true) + } + }, + DActor::Group { groups, .. } => { + // the groups of the user are kernel gids: only the policy side is looked up + let user_gids: Vec = user + .groups + .iter() + .map(|group| either_to_gid(group).as_raw()) + .collect(); + let mut failed = false; + let mut holds = |group: &DGroupType<'_>| match (lookup.group)(group) { + Ok(gid) => gid.is_some_and(|gid| user_gids.contains(&gid)), + Err(e) => { + warn!("Cannot look up the group {group}: {e}"); + failed = true; + false + } + }; + let matched = match groups { + DGroups::Single(group) => holds(group), + DGroups::Multiple(groups) => groups.iter().all(holds), + }; + if matched { + (ActorMatchMin::GroupMatch(groups.len()), failed) + } else { + (ActorMatchMin::NoMatch, failed) + } + } + DActor::Unknown(element) => { + // a type of actor that this version does not know never matches + warn!("Unknown actor type, it never matches: {element:?}"); + (ActorMatchMin::NoMatch, false) + } + } } impl<'de> DeserializeSeed<'de> for ActorsFinderDeserializer<'_> { - type Value = ActorMatchMin; + /// The best match of the actors, and whether looking one of them up failed. + type Value = (ActorMatchMin, bool); fn deserialize(self, deserializer: D) -> Result where D: serde::Deserializer<'de>, { struct ActorsFinderVisitor<'a> { cred: &'a Cred, + lookup: ActorLookup, } impl<'de> Visitor<'de> for ActorsFinderVisitor<'_> { - type Value = ActorMatchMin; + type Value = (ActorMatchMin, bool); fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a set of users") } @@ -199,54 +266,24 @@ impl<'de> DeserializeSeed<'de> for ActorsFinderDeserializer<'_> { A: serde::de::SeqAccess<'de>, { let mut user_matches = ActorMatchMin::NoMatch; + let mut lookup_failed = false; while let Some(actor) = seq.next_element::()? { debug!("ActorsSettingsVisitor: actor {actor:?}"); - let temp = Self::user_matches(self.cred, &actor); + let (temp, failed) = actor_matches(self.cred, &actor, self.lookup); + lookup_failed |= failed; if temp != ActorMatchMin::NoMatch && temp < user_matches { debug!("ActorsSettingsVisitor: Better actor found {temp:?}"); user_matches = temp; } } - Ok(user_matches) + Ok((user_matches, lookup_failed)) } } - impl ActorsFinderVisitor<'_> { - fn match_groups(groups: &[Either], role_groups: &[&DGroups<'_>]) -> bool { - for role_group in role_groups { - if match role_group { - DGroups::Single(group) => groups.iter().any(|g| group == g), - DGroups::Multiple(multiple_actors) => multiple_actors - .iter() - .all(|actor| groups.iter().any(|g| actor == g)), - } { - return true; - } - } - false - } - fn user_matches(user: &Cred, actor: &DActor<'_>) -> ActorMatchMin { - match actor { - DActor::User { id, .. } => { - if *id == user.user { - return ActorMatchMin::UserMatch; - } - } - DActor::Group { groups, .. } => { - if Self::match_groups(&user.groups, &[groups]) { - return ActorMatchMin::GroupMatch(groups.len()); - } - } - DActor::Unknown(element) => { - // a type of actor that this version does not know never matches - warn!("Unknown actor type, it never matches: {element:?}"); - } - } - ActorMatchMin::NoMatch - } - } - - deserializer.deserialize_seq(ActorsFinderVisitor { cred: self.cred }) + deserializer.deserialize_seq(ActorsFinderVisitor { + cred: self.cred, + lookup: self.lookup, + }) } } @@ -269,11 +306,88 @@ mod test { .user() .unwrap() .build(), + lookup: SYSTEM_LOOKUP, }; let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json)); assert!(result.is_ok(), "Failed to deserialize: {result:?}"); - let user_min = result.unwrap(); + let (user_min, lookup_failed) = result.unwrap(); assert_eq!(user_min, ActorMatchMin::UserMatch); + assert!(!lookup_failed); + } + + fn failing_user_lookup(_: &DUserType<'_>) -> nix::Result> { + Err(nix::Error::EIO) + } + + fn failing_group_lookup(_: &DGroupType<'_>) -> nix::Result> { + Err(nix::Error::ERANGE) + } + + #[test] + fn test_a_failed_lookup_never_matches_and_is_reported() { + let cred = Cred::builder() + .curdir() + .unwrap() + .groups() + .unwrap() + .user() + .unwrap() + .build(); + let failing = ActorLookup { + user: failing_user_lookup, + group: failing_group_lookup, + }; + let me = format!(r#"{{"type": "user", "id": "{}"}}"#, cred.user.name); + let me: DActor<'_> = serde_json::from_str(&me).unwrap(); + let my_group = format!(r#"{{"type": "group", "groups": [{}]}}"#, getgid().as_raw()); + let my_group: DActor<'_> = serde_json::from_str(&my_group).unwrap(); + let named_group: DActor<'_> = + serde_json::from_str(r#"{"type": "group", "groups": ["rar-no-such-group"]}"#).unwrap(); + let unknown: DActor<'_> = + serde_json::from_str(r#"{"type": "user", "id": "rar-no-such-user"}"#).unwrap(); + // the system databases: a match, and a name that nobody has, without any failure + assert_eq!( + actor_matches(&cred, &me, SYSTEM_LOOKUP), + (ActorMatchMin::UserMatch, false) + ); + assert_eq!( + actor_matches(&cred, &unknown, SYSTEM_LOOKUP), + (ActorMatchMin::NoMatch, false) + ); + assert_eq!( + actor_matches(&cred, &named_group, SYSTEM_LOOKUP), + (ActorMatchMin::NoMatch, false) + ); + // a failed lookup never matches, even for the user itself, and is reported + assert_eq!( + actor_matches(&cred, &me, failing), + (ActorMatchMin::NoMatch, true) + ); + assert_eq!( + actor_matches(&cred, &named_group, failing), + (ActorMatchMin::NoMatch, true) + ); + // a group given by its id + assert_eq!( + actor_matches(&cred, &my_group, SYSTEM_LOOKUP), + (ActorMatchMin::GroupMatch(1), false) + ); + // the failure of one actor is the failure of the role, even when another one matches + let actors = format!( + r#"[{{"type": "user", "id": {}}}, {{"type": "group", "groups": ["staff"]}}]"#, + getuid().as_raw() + ); + let failing_groups = ActorLookup { + user: SYSTEM_LOOKUP.user, + group: failing_group_lookup, + }; + let result = ActorsFinderDeserializer { + cred: &cred, + lookup: failing_groups, + } + .deserialize(&mut serde_json::Deserializer::from_str(&actors)) + .unwrap(); + assert_eq!(result, (ActorMatchMin::UserMatch, true)); } #[test] @@ -430,6 +544,7 @@ mod test { .user() .unwrap() .build(), + lookup: SYSTEM_LOOKUP, }; let result = actors.deserialize(&mut serde_json::Deserializer::from_str(int)); assert!(result.is_err(), "Expected error, got: {result:?}"); diff --git a/src/dosr/finder/mod.rs b/src/dosr/finder/mod.rs index 488a0d29..f21fa498 100644 --- a/src/dosr/finder/mod.rs +++ b/src/dosr/finder/mod.rs @@ -4,6 +4,7 @@ use std::{ cmp::Ordering, collections::HashMap, + ffi::CString, fs, io::BufReader, path::{Path, PathBuf}, @@ -13,13 +14,19 @@ use api::{Api, ApiEvent}; use bon::Builder; use de::{ConfigFinderDeserializer, DConfigFinder, DLinkedCommand, DLinkedRole, DLinkedTask}; use log::{debug, error, warn}; +use nix::{ + errno::Errno, + unistd::{Group, User, getgrouplist}, +}; use options::BorrowedOptStack; use rootasrole_core::{ Cred, database::{ actor::{DGroupType, DGroups}, - options::{SAuthentication, SBounding, SPrivileged, STimeout, SUMask}, + env::EnvFilter, + options::{SAuthentication, SBounding, SPrivileged, STimeout, SUMask, TtyOwner}, score::{ActorMatchMin, CmdMin, CmdOrder, HardenedBool, Score, hardened_bool_from_bool}, + structs::GroupsFrom, workdir::ResolvedWorkdir, }, util::{StorageMethod, check_immutable, check_others_permissions, read_with_privileges}, @@ -29,7 +36,10 @@ use serde::de::DeserializeSeed; use crate::{ Cli, error::{SrError, SrResult}, - finder::de::{cred::CredOwnedData, settings::read_storage}, + finder::de::{ + cred::{CredData, CredOwnedData}, + settings::read_storage, + }, }; pub mod api; @@ -38,6 +48,8 @@ use cmd::resolve_any_command; pub mod de; mod options; +pub use options::PasswordSettings; + #[derive(Debug, Default, Clone, Builder)] pub struct BestExecSettings { #[builder(default)] @@ -62,6 +74,10 @@ pub struct BestExecSettings { /// The final set of environment variable to keep/set pub env: HashMap, #[builder(default)] + /// The filter of the policy on the environment, for the variables that PAM modules set once + /// the session is open + pub env_filter: EnvFilter, + #[builder(default)] /// The PATH variable is managed indepedently given the policy pub env_path: Vec, /// The working directory to set, if specified in policy @@ -76,6 +92,12 @@ pub struct BestExecSettings { /// Information about whether the user should authenticate or bypass it pub auth: SAuthentication, #[builder(default)] + /// How many times the password is asked, and the delay after a wrong one + pub password: PasswordSettings, + #[builder(default)] + /// Who owns the pseudo-terminal of the command + pub tty_owner: TtyOwner, + #[builder(default)] /// Is root id has it's privileges or not? If not, root is going to be a simple user pub root: SPrivileged, #[builder(default)] @@ -108,6 +130,7 @@ pub fn find_best_exec_settings<'de: 'a, 'a, P>( where P: AsRef, { + let _path_searches = cmd::PathSearchCache::start(); let env_vars: Vec<(String, String)> = env_vars .into_iter() .map(|(key, value)| (key.into(), value.into())) @@ -205,10 +228,64 @@ where SrError::ConfigurationError } }) - .and_then(BestExecSettings::refuse_tie); + .and_then(BestExecSettings::refuse_tie) + .and_then(BestExecSettings::with_target_groups); } - parse_file(&file_path).and_then(BestExecSettings::refuse_tie) + parse_file(&file_path) + .and_then(BestExecSettings::refuse_tie) + .and_then(BestExecSettings::with_target_groups) +} + +/// The user and the groups of the credentials of a task, or what does not resolve: a name that +/// nobody has, an id without an entry, or a lookup that fails. Before, such a user was dropped, +/// so the command ran as the caller, and such a group was dropped from the list, so the command +/// kept the primary group of the caller when no group was left. +fn resolve_cred(cred: &CredData<'_>) -> Result<(Option, Option>), String> { + let setuid = cred + .setuid + .as_ref() + .map(|user| user.fetch_user().ok_or_else(|| format!("the user {user}"))) + .transpose()?; + let group = |group: &DGroupType<'_>| { + group + .fetch_group() + .ok_or_else(|| format!("the group {group}")) + }; + let setgroups = match &cred.setgroups { + None => None, + Some(DGroups::Single(single)) => Some(vec![group(single)?]), + Some(DGroups::Multiple(groups)) if groups.is_empty() => { + return Err("an empty list of groups".into()); + } + Some(DGroups::Multiple(groups)) => { + Some(groups.iter().map(group).collect::>()?) + } + }; + Ok((setuid, setgroups)) +} + +/// The groups of `user` as a login gives them (`initgroups(3)`): its primary group first, then +/// the groups that list it as a member, like sudo-rs `User::from_libc` (sudo-rs/src/system/mod.rs). +/// A gid without a name is named by its number. +fn login_groups(user: &User) -> nix::Result> { + let name = CString::new(user.name.as_bytes()).map_err(|_| Errno::EINVAL)?; + let mut gids = vec![user.gid]; + for gid in getgrouplist(&name, user.gid)? { + if !gids.contains(&gid) { + gids.push(gid); + } + } + gids.into_iter() + .map(|gid| { + Ok(Group::from_gid(gid)?.unwrap_or_else(|| Group { + name: gid.to_string(), + passwd: CString::default(), + gid, + mem: Vec::new(), + })) + }) + .collect() } impl BestExecSettings { @@ -221,7 +298,27 @@ impl BestExecSettings { env_vars: impl IntoIterator, impl Into)>, env_path: &[&str], ) -> SrResult { - Self::evaluate(cli, cred, data, env_vars, env_path).and_then(Self::refuse_tie) + Self::evaluate(cli, cred, data, env_vars, env_path) + .and_then(Self::refuse_tie) + .and_then(Self::with_target_groups) + } + + /// The task changes the user without giving the groups: the command gets the groups of the + /// target user, as a login gives them, unless `from` names the groups of the caller. A task + /// that keeps the user keeps the groups of the caller. A lookup that fails refuses the + /// command, it never falls back on the groups of the caller. + fn with_target_groups(mut self) -> SrResult { + if self.cred.setgroups.is_none() + && self.cred.groups_from != Some(GroupsFrom::Caller) + && let Some(user) = &self.cred.setuid + { + let groups = login_groups(user).map_err(|e| { + error!("Cannot read the groups of the user {}: {e}", user.name); + SrError::SystemError + })?; + self.cred.setgroups = Some(groups); + } + Ok(self) } /// Refuse the settings when another task is as good as the best one: none of them needs @@ -268,7 +365,15 @@ impl BestExecSettings { let mut result = Self::default(); let mut matching = false; let mut opt_stack = BorrowedOptStack::new(data.options.as_ref()); + let role_filter = cli + .opt_filter + .as_ref() + .and_then(|filter| filter.role.as_deref()); for role in data.roles() { + // the role asked with -r, the others were read for the plugins + if role_filter.is_some_and(|name| name != role.role().role.as_ref()) { + continue; + } // only the roles that the user holds: their actors match and no plugin excludes // them (separation of duties), the hierarchy plugin relies on it if !result.actors_settings(&role)? { @@ -288,8 +393,10 @@ impl BestExecSettings { if !matching { return Err(SrError::PermissionDenied); } - result.env = opt_stack - .calc_temp_env(opt_stack.calc_override_behavior(), cli.opt_filter.as_ref()) + let env = + opt_stack.calc_temp_env(opt_stack.calc_override_behavior(), cli.opt_filter.as_ref()); + result.env_filter = env.filter(); + result.env = env .final_env( env_vars, opt_stack.calc_path(env_path), @@ -312,6 +419,8 @@ impl BestExecSettings { result.auth = opt_stack.calc_authentication(); result.bounding = opt_stack.calc_bounding(); result.timeout = opt_stack.calc_timeout(); + result.password = opt_stack.calc_password(); + result.tty_owner = opt_stack.calc_tty_owner(); result.root = opt_stack.calc_privileged(); result.umask = opt_stack.calc_umask(); result.workdir = opt_stack.calc_workdir(); @@ -383,6 +492,65 @@ impl BestExecSettings { ) -> SrResult { debug!("task_settings: {:?}", data.id); let temp_opt_stack = BorrowedOptStack::from_task(data); + // The commands come first: most tasks do not allow the command, and their options need + // not be resolved. The task is evaluated on its own: the best settings found so far are + // only replaced if its full score is better, so a task cannot change how the next ones + // are compared. + let mut task_cmd_min = CmdMin::empty(); + let mut task_final_path = None; + // the PATH of this task: its commands are resolved in it, and it is kept if the task wins + let mut task_env_path = None; + if let Some(commands) = data.commands() { + // a command given by its path is compared without the PATH, unless a rule of the + // task resolves the command itself (`**`, the `all` default, a plugin) + let needs_path = !cli.cmd_path.is_absolute() + || commands.default_behavior.is_some_and(|b| b.is_all()) + || commands + .add() + .chain(commands.del()) + .any(|command| Self::resolves_the_command(&command)); + let t_env_path = if needs_path { + temp_opt_stack.calc_path(env_path) + } else { + Vec::new() + }; + let t_env_path_ref: Vec<&str> = t_env_path.iter().map(String::as_str).collect(); + for command in commands.del() { + if Self::match_command(&t_env_path_ref, cli, &command)?.is_some() { + debug!("task_settings: deny task, the command is in its deny list"); + return Ok(false); + } + } + if commands.default_behavior.is_some_and(|b| b.is_all()) { + debug!("default behavior is all"); + debug!("{}", &cli.cmd_path.display()); + task_final_path = Some( + resolve_any_command(&cli.cmd_path, &t_env_path_ref) + .ok_or(SrError::ExecutionFailed)?, + ); + // any command with any arguments, like a "**" rule + task_cmd_min = CmdMin::builder() + .matching() + .order(CmdOrder::FullWildcardPath | CmdOrder::FullRegexArgs) + .build(); + } else { + for command in commands.add() { + if let Some((cmd_min, final_path)) = + Self::match_command(&t_env_path_ref, cli, &command)? + && cmd_min.better(task_cmd_min) + { + task_cmd_min = cmd_min; + task_final_path = Some(final_path); + } + } + } + if needs_path { + task_env_path = Some(t_env_path); + } + } + if !task_cmd_min.matching() { + return Ok(false); + } if let Some(entry) = temp_opt_stack.invalid_env_entry() { error!( "task_settings: deny task {:?}: invalid environment entry `{entry}`, it must be a variable name where `*` matches any characters", @@ -390,10 +558,6 @@ impl BestExecSettings { ); return Ok(false); } - // the PATH of this task: its commands are resolved in it, and it is kept if the task wins - let mut task_env_path = None; - // We must do this check for each task as long they could have different options - // These checks are a small optimization to avoid useless command checks if cli .opt_filter .as_ref() @@ -428,53 +592,13 @@ impl BestExecSettings { debug!("task_settings: deny task due to inherited from role or config info hide"); return Ok(false); } - // The task is evaluated on its own: the best settings found so far are only replaced - // if its full score is better, so a task cannot change how the next ones are compared. - let mut task_cmd_min = CmdMin::empty(); - let mut task_final_path = None; - if let Some(commands) = data.commands() { - let t_env_path = temp_opt_stack.calc_path(env_path); - let t_env_path_ref: Vec<&str> = t_env_path.iter().map(String::as_str).collect(); - for command in commands.del() { - if Self::match_command(&t_env_path_ref, cli, &command)?.is_some() { - debug!("task_settings: deny task, the command is in its deny list"); - return Ok(false); - } - } - if commands.default_behavior.is_some_and(|b| b.is_all()) { - debug!("default behavior is all"); - debug!("{}", &cli.cmd_path.display()); - task_final_path = Some( - resolve_any_command(&cli.cmd_path, &t_env_path_ref) - .ok_or(SrError::ExecutionFailed)?, - ); - // any command with any arguments, like a "**" rule - task_cmd_min = CmdMin::builder() - .matching() - .order(CmdOrder::FullWildcardPath | CmdOrder::FullRegexArgs) - .build(); - } else { - for command in commands.add() { - if let Some((cmd_min, final_path)) = - Self::match_command(&t_env_path_ref, cli, &command)? - && cmd_min.better(task_cmd_min) - { - task_cmd_min = cmd_min; - task_final_path = Some(final_path); - } - } - } - task_env_path = Some(t_env_path); - } - let found = task_cmd_min.matching(); let mut score = data.score(task_cmd_min, temp_opt_stack.calc_security_min()); score.set_role_score(&held_through); Api::notify(ApiEvent::BestTaskSettingsFound( cli, data, opt_stack, self, &mut score, ))?; let task_address = std::ptr::from_ref(data.task).addr(); - if found - && task_final_path.is_some() + if task_final_path.is_some() && score.fully_matching() && !score.better_fully(&self.score) && score.cmp(&self.score) == Ordering::Equal @@ -485,10 +609,22 @@ impl BestExecSettings { self.tie .get_or_insert_with(|| format!("{}/{}", data.role().role().role, data.id)); } - if found - && let Some(final_path) = task_final_path + if let Some(final_path) = task_final_path && score.better_fully(&self.score) { + // a task whose user or groups do not exist cannot run as written: it is left out like + // a task that does not match, so that it tells nothing more before the authentication + let (setuid, setgroups) = match resolve_cred(&data.cred) { + Ok(cred) => cred, + Err(missing) => { + error!( + "The task {}/{} cannot run: {missing} does not exist or cannot be read", + data.role().role().role, + data.id + ); + return Ok(true); + } + }; debug!("found better task settings"); self.best_task = Some(task_address); // a tie with the previous best task does not matter anymore @@ -498,18 +634,28 @@ impl BestExecSettings { self.task = Some(data.id.to_string()); self.env_path = task_env_path.unwrap_or_else(|| temp_opt_stack.calc_path(env_path)); self.score = score; - self.cred.setuid = data.cred.setuid.clone().and_then(|u| u.fetch_user()); - self.cred.setgroups = data.cred.setgroups.clone().map(|g| match g { - DGroups::Single(g) => vec![g.fetch_group()].into_iter().flatten().collect(), - DGroups::Multiple(g) => g.iter().filter_map(DGroupType::fetch_group).collect(), - }); + self.cred.setuid = setuid; + self.cred.setgroups = setgroups; + self.cred.groups_from = data.cred.groups_from; self.cred.caps = data.cred.caps; opt_stack.set_role(data); opt_stack.set_task(data); debug!("resulting settings: {self:?}"); } - Ok(found) + Ok(true) + } + + /// Whether a command of the policy resolves the command of the user in the PATH: `**`, which + /// allows any command, or a complex command, which a plugin evaluates. + fn resolves_the_command(command: &DLinkedCommand<'_, '_, '_, '_, '_>) -> bool { + match &**command { + de::DCommand::Simple(role_cmd) => role_cmd + .trim_start() + .trim_start_matches(['\'', '"']) + .starts_with("**"), + de::DCommand::Complex(_) => true, + } } /// How a command of the policy matches the command line: its score and the path to run, @@ -572,6 +718,8 @@ mod tests { use rootasrole_core::Cred; use test_log::test; + mod perf; + // Helper: Dummy implementations for required traits/structs fn dummy_cli() -> Cli { Cli::builder() @@ -688,6 +836,65 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_a_task_that_changes_the_user_gets_its_groups() { + let root = User::from_uid(nix::unistd::Uid::from_raw(0)) + .unwrap() + .unwrap(); + let settings = |setuid: Option, groups_from, setgroups| BestExecSettings { + cred: CredOwnedData::builder() + .maybe_setuid(setuid) + .maybe_groups_from(groups_from) + .maybe_setgroups(setgroups) + .build(), + ..Default::default() + }; + // setuid without setgid: the groups of the target user, its primary group first + for groups_from in [None, Some(GroupsFrom::Target)] { + let groups = settings(Some(root.clone()), groups_from, None) + .with_target_groups() + .unwrap() + .cred + .setgroups + .unwrap(); + assert_eq!(groups[0].gid, root.gid, "{groups_from:?}"); + assert_eq!(groups, login_groups(&root).unwrap()); + } + // from: caller, a task that keeps the user, and forced groups are left as they are + let caller = settings(Some(root.clone()), Some(GroupsFrom::Caller), None); + assert!( + caller + .with_target_groups() + .unwrap() + .cred + .setgroups + .is_none() + ); + let same_user = settings(None, None, None); + assert!( + same_user + .with_target_groups() + .unwrap() + .cred + .setgroups + .is_none() + ); + let forced = Group::from_gid(root.gid).unwrap().unwrap(); + let forced_settings = settings(Some(root.clone()), None, Some(vec![forced.clone()])); + assert_eq!( + forced_settings.with_target_groups().unwrap().cred.setgroups, + Some(vec![forced]) + ); + // a lookup that fails refuses the command instead of keeping the groups of the caller + let mut broken = root; + broken.name = "bro\0ken".into(); + assert!( + settings(Some(broken), None, None) + .with_target_groups() + .is_err() + ); + } + #[test] fn test_retrieve_settings_with_matching_role() { let cli = dummy_cli(); @@ -1128,6 +1335,184 @@ mod tests { .map(|settings| settings.task.unwrap_or_default()) } + /// The final policy of the book, under its heading in `book/src/configuration.md`, or `None` + /// when the book is not in this source tree: the crate and the Debian sources leave it out. + fn book_policy() -> Option { + let page = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("book/src/configuration.md"), + ) + .ok()?; + let section = &page[page.find("## The complete WebOps policy")?..]; + let start = section.find("```json\n")? + "```json\n".len(); + let end = section[start..].find("\n```")? + start; + Some(serde_json::from_str(§ion[start..end]).expect("the policy of the book is JSON")) + } + + #[test] + fn test_the_policy_of_the_book() { + let Some(mut policy) = book_policy() else { + eprintln!("skipped: the book is not in this source tree"); + return; + }; + rootasrole_core::database::migrations::migrate_document(&mut policy).unwrap(); + serde_json::from_value::(policy.clone()) + .expect("rootasrole-core reads the policy of the book"); + // the current user takes the place of the team, but not of sys-admin, which excludes + // caddy-admin + let uid = nix::unistd::getuid().as_raw(); + for role in policy["roles"].as_array_mut().unwrap() { + if role["name"] != "sys-admin" { + role["actors"] = serde_json::json!([{"type": "user", "id": uid}]); + } + } + let dir = PolicyDir::new("book"); + let file = dir.0.join("default.json"); + write_file(&file, &policy, StorageMethod::JSON, 0o600); + let root = dir.0.join("rootasrole.json"); + write_file( + &root, + &root_settings(StorageMethod::JSON, &file, serde_json::json!([])), + StorageMethod::JSON, + 0o600, + ); + let decide = |cmd: &str, args: &[&str]| -> Option { + let cli = Cli::builder() + .cmd_path(cmd) + .cmd_args(args.iter().map(ToString::to_string)) + .build(); + find_best_exec_settings( + &cli, + &dummy_cred(), + &root, + &root, + StorageMethod::JSON, + vec![("A", "B")], + &["/usr/bin"], + ) + .ok() + .filter(|settings| settings.score.fully_matching()) + .and_then(|settings| settings.task) + }; + // the groups of read-logs come with systemd, which a container may not have: a task + // whose group does not exist is left out + let read_logs = policy["roles"] + .as_array() + .unwrap() + .iter() + .flat_map(|role| role["tasks"].as_array().into_iter().flatten()) + .filter(|task| task["name"] == "read-logs") + .flat_map(|task| match &task["cred"]["setgid"] { + Value::Array(groups) => groups.clone(), + group => vec![group.clone()], + }) + .all(|group| { + group + .as_str() + .is_some_and(|name| Group::from_name(name).ok().flatten().is_some()) + }) + .then_some("read-logs"); + let log = "/var/log/caddy/access.log"; + assert_eq!( + decide("/usr/bin/tail", &["-n", "50", log]).as_deref(), + read_logs + ); + assert_eq!(decide("/usr/bin/tail", &["-f", log]).as_deref(), read_logs); + assert_eq!( + decide("/usr/bin/journalctl", &["--no-pager", "-u", "caddy"]).as_deref(), + read_logs + ); + // the path traversal of the chapter + let traversal = "/var/log/caddy/../../../etc/shadow"; + assert_eq!(decide("/usr/bin/tail", &["-n", "50", traversal]), None); + assert_eq!(decide("/usr/bin/less", &[traversal]), None); + // the pager of systemctl, without --no-pager + assert_eq!(decide("/usr/bin/systemctl", &["status", "caddy"]), None); + assert_eq!( + decide("/usr/bin/systemctl", &["status", "--no-pager", "caddy"]).as_deref(), + Some("caddy-service") + ); + assert_eq!( + decide("/usr/bin/systemctl", &["restart", "caddy"]).as_deref(), + Some("caddy-service") + ); + assert_eq!(decide("/usr/bin/systemctl", &["restart", "sshd"]), None); + // alice's escape: more arguments than the regular expression allows + let escape = [ + "-d", + "-m", + "777", + "-o", + "alice", + "-g", + "alice", + "/srv/www/x", + "/etc", + ]; + assert_eq!(decide("/usr/bin/install", &escape), None); + // the user of web-push is www-data, which not every distribution has + if User::from_name("www-data").ok().flatten().is_some() { + assert_eq!( + decide("/usr/bin/install", &["-d", "-m", "755", "/srv/www/site"]).as_deref(), + Some("web-push") + ); + } + // the root-equivalent tasks of sys-admin are not held + assert_eq!(decide("/usr/bin/systemctl", &["reboot"]), None); + } + + #[test] + fn test_a_task_whose_credentials_do_not_resolve_is_left_out() { + let task = |name: &str, cred: Value| serde_json::json!({"name": name, "cred": cred, "commands": ["/usr/bin/ls -l"]}); + let role = |tasks: Vec| { + serde_json::json!([{ + "name": "r", + "actors": [{"type": "user", "id": nix::unistd::getuid().as_raw()}], + "tasks": tasks + }]) + }; + let decide = |roles: Value| { + let dir = PolicyDir::new("unresolved"); + let root = dir.0.join("rootasrole"); + write_file( + &root, + &root_settings(StorageMethod::JSON, &root, roles), + StorageMethod::JSON, + 0o600, + ); + let cli = Cli::builder() + .cmd_path("/usr/bin/ls") + .cmd_args(vec!["-l".to_string()]) + .build(); + find_best_exec_settings( + &cli, + &dummy_cred(), + &root, + &root, + StorageMethod::JSON, + vec![("A", "B")], + &["/usr/bin"], + ) + .unwrap() + }; + let broken = || { + vec![ + task("no-user", serde_json::json!({"setuid": "rar-no-such-user"})), + task( + "no-group", + serde_json::json!({"setgid": ["root", "rar-no-such-group"]}), + ), + ] + }; + // the broken tasks are more precise than any, which is chosen + let mut tasks = broken(); + tasks.push(serde_json::json!({"name": "any", "commands": ["/usr/bin/ls ^.*$"]})); + assert_eq!(decide(role(tasks)).task.as_deref(), Some("any")); + // without another task, none is chosen, and the command is refused after authentication + let settings = decide(role(broken())); + assert_eq!(settings.task, None); + assert!(!settings.score.fully_matching()); + } + #[test] fn test_find_best_exec_settings_single_file() { for method in [StorageMethod::JSON, StorageMethod::CBOR] { @@ -1143,6 +1528,45 @@ mod tests { } } + // the tty option of the policy reaches the settings of the execution, the build-time default + // without it + #[test] + fn test_find_best_exec_settings_tty_owner() { + for method in [StorageMethod::JSON, StorageMethod::CBOR] { + let dir = PolicyDir::new(&format!("tty-{method}")); + let root = dir.0.join("rootasrole"); + let mut roles = user_role("t", "/usr/bin/ls", &[]); + let tty_owner = |root: &Path| { + let cli = Cli::builder().cmd_path("/usr/bin/ls".to_string()).build(); + find_best_exec_settings( + &cli, + &dummy_cred(), + root, + root, + method, + vec![("A", "B")], + &["/usr/bin"], + ) + .unwrap() + .tty_owner + }; + write_file( + &root, + &root_settings(method, &root, roles.clone()), + method, + 0o600, + ); + assert_eq!( + tty_owner(&root), + rootasrole_core::util::TTY_OWNER, + "{method}" + ); + roles[0]["tasks"][0]["options"] = serde_json::json!({"tty": {"owner": "target"}}); + write_file(&root, &root_settings(method, &root, roles), method, 0o600); + assert_eq!(tty_owner(&root), TtyOwner::Target, "{method}"); + } + } + #[test] fn test_immutable_policy_refuses_a_mutable_file() { // a file added by someone who could not set the immutable flag (uid 0 without @@ -1409,6 +1833,167 @@ mod tests { )); } + /// The role chosen for `cmd`, after a failed lookup of the actors of the roles `failed`. + #[cfg(feature = "ssd")] + fn choose_after_failed_lookups(roles: &Value, cmd: &str, failed: &[&str]) -> SrResult { + api::register_plugins(); + let policy = serde_json::json!({ "roles": roles }).to_string(); + let cli = Cli::builder().cmd_path(cmd.to_string()).build(); + let cred = dummy_cred(); + let mut data = super::de::ConfigFinderDeserializer { + cli: &cli, + cred: &cred, + } + .deserialize(&mut serde_json::Deserializer::from_str(&policy)) + .expect("valid policy"); + for role in &mut data.roles { + role.lookup_failed = failed.contains(&role.role.as_ref()); + } + BestExecSettings::retrieve_settings(&cli, &cred, &data, vec![("A", "B")], &["/usr/bin"]) + .map(|settings| settings.role) + } + + #[cfg(feature = "ssd")] + #[test] + fn test_a_failed_lookup_disables_the_roles_with_ssd() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json( + "dev", + true, + Some("/usr/bin/ls"), + &serde_json::json!({"ssd": ["auditor"]}) + ), + role_json("auditor", false, None, &none), + role_json("ops", true, Some("/usr/bin/cat"), &none), + role_json("other", false, None, &none), + ]); + assert_eq!( + choose_after_failed_lookups(&roles, "/usr/bin/ls", &[]).unwrap(), + "dev" + ); + // whatever role the failed lookup belongs to, the roles with an ssd are disabled + for failed in ["auditor", "other", "dev"] { + assert!( + matches!( + choose_after_failed_lookups(&roles, "/usr/bin/ls", &[failed]), + Err(SrError::PermissionDenied) + ), + "{failed}" + ); + } + // the roles without one are not + assert_eq!( + choose_after_failed_lookups(&roles, "/usr/bin/cat", &["other"]).unwrap(), + "ops" + ); + } + + #[cfg(all(feature = "hierarchy", feature = "ssd"))] + #[test] + fn test_a_failed_lookup_disables_the_parents_of_a_role_with_ssd() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json( + "senior", + true, + None, + &serde_json::json!({"parents": ["junior"], "ssd": ["auditor"]}) + ), + role_json("junior", false, Some("/usr/bin/ls"), &none), + role_json("auditor", false, None, &none), + ]); + assert_eq!( + choose_after_failed_lookups(&roles, "/usr/bin/ls", &[]).unwrap(), + "junior" + ); + assert!(matches!( + choose_after_failed_lookups(&roles, "/usr/bin/ls", &["auditor"]), + Err(SrError::PermissionDenied) + )); + } + + /// The role and task chosen for `cmd` with `dosr -r role`. + fn choose_role(roles: &Value, cmd: &str, role: &str) -> SrResult<(String, Option)> { + api::register_plugins(); + let policy = serde_json::json!({ "roles": roles }).to_string(); + let cli = Cli::builder() + .cmd_path(cmd.to_string()) + .opt_filter(FilterMatcher::builder().role(role.to_string()).build()) + .build(); + let cred = dummy_cred(); + let data = super::de::ConfigFinderDeserializer { + cli: &cli, + cred: &cred, + } + .deserialize(&mut serde_json::Deserializer::from_str(&policy)) + .expect("valid policy"); + BestExecSettings::retrieve_settings(&cli, &cred, &data, vec![("A", "B")], &["/usr/bin"]) + .map(|settings| (settings.role, settings.task)) + } + + #[test] + fn test_role_filter_chooses_among_the_roles() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json("first", true, Some("/usr/bin/ls"), &none), + role_json("second", true, Some("/usr/bin/ls"), &none), + role_json("other", false, Some("/usr/bin/ls"), &none), + ]); + assert_eq!( + choose_role(&roles, "/usr/bin/ls", "second").unwrap().0, + "second" + ); + assert_eq!( + choose_role(&roles, "/usr/bin/ls", "first").unwrap().0, + "first" + ); + // a role that the user does not hold, or that does not exist + for role in ["other", "nope"] { + assert!(matches!( + choose_role(&roles, "/usr/bin/ls", role), + Err(SrError::PermissionDenied) + )); + } + } + + #[cfg(feature = "ssd")] + #[test] + fn test_role_filter_does_not_lift_the_separation_of_duties() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json( + "dev", + true, + Some("/usr/bin/ls"), + &serde_json::json!({"ssd": ["auditor"]}) + ), + role_json("auditor", true, None, &none), + ]); + // dosr -r dev read only the role dev: auditor, which the user holds, was not seen + assert!(matches!( + choose_role(&roles, "/usr/bin/ls", "dev"), + Err(SrError::PermissionDenied) + )); + } + + #[cfg(feature = "hierarchy")] + #[test] + fn test_role_filter_keeps_the_tasks_of_the_parents() { + let none = serde_json::json!({}); + let roles = serde_json::json!([ + role_json( + "senior", + true, + None, + &serde_json::json!({"parents": ["junior"]}) + ), + role_json("junior", false, Some("/usr/bin/ls"), &none), + ]); + let (role, task) = choose_role(&roles, "/usr/bin/ls", "senior").unwrap(); + assert_eq!(task.as_deref(), Some("junior_task"), "{role}"); + } + #[test] fn test_default_policy_runs_as_the_requested_user() { api::register_plugins(); @@ -1883,4 +2468,94 @@ mod tests { ); std::fs::remove_dir_all(&dir).unwrap(); } + + /// The environment that dosr decides for `cli`, completed with variables set by PAM. + fn env_with_pam_vars(cli: &Cli, pam_vars: &[(&str, &str)]) -> HashMap { + use serde::de::DeserializeSeed; + let policy = format!( + r#"{{"roles":[{{"name":"r","actors":[{{"type":"user","id":{}}}],"tasks":[{{"name":"t","commands":["/usr/bin/true"],"options":{{"env":{{"default":"delete","override_behavior":true,"keep":["PAM_*"],"check":["CHECKED"],"delete":["LD_*"],"set":{{"SET":"policy"}}}}}}}}]}}]}}"#, + nix::unistd::getuid() + ); + let cred = dummy_cred(); + let data = super::de::ConfigFinderDeserializer { cli, cred: &cred } + .deserialize(&mut serde_json::Deserializer::from_str(&policy)) + .expect("valid policy"); + let mut settings = BestExecSettings::retrieve_settings( + cli, + &cred, + &data, + [("PAM_USER_VAR", "user")], + &["/usr/bin"], + ) + .unwrap(); + settings.env_filter.add_vars( + &mut settings.env, + pam_vars + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())), + ); + settings.env + } + + #[test] + fn test_a_task_without_any_directory_gives_no_path() { + // an empty PATH would be the working directory for a shell, which the user chooses + let policy = format!( + r#"{{"roles":[{{"name":"r","actors":[{{"type":"user","id":{}}}],"tasks":[{{"name":"t","commands":["/usr/bin/true"],"options":{{"path":{{"default":"delete","add":[".",""]}},"env":{{"default":"delete","keep":["PATH"]}}}}}}]}}]}}"#, + nix::unistd::getuid() + ); + let cli = Cli::builder().cmd_path("/usr/bin/true".to_string()).build(); + let cred = dummy_cred(); + let data = super::de::ConfigFinderDeserializer { + cli: &cli, + cred: &cred, + } + .deserialize(&mut serde_json::Deserializer::from_str(&policy)) + .expect("valid policy"); + let settings = BestExecSettings::retrieve_settings( + &cli, + &cred, + &data, + [("PATH", ".:/tmp")], + &[".", "/usr/bin"], + ) + .unwrap(); + assert!(!settings.env.contains_key("PATH"), "{:?}", settings.env); + assert!(settings.env_path.is_empty(), "{:?}", settings.env_path); + } + + #[test] + fn test_pam_vars_go_through_the_env_filter_of_the_task() { + let pam_vars = [ + ("PAM_NEW", "pam"), + ("PAM_USER_VAR", "pam"), + ("SET", "pam"), + ("PATH", "/tmp/evil"), + ("CHECKED", "/unsafe"), + ("OTHER", "pam"), + ("LD_PRELOAD", "/tmp/evil.so"), + ]; + let cli = Cli::builder().cmd_path("/usr/bin/true".to_string()).build(); + let env = env_with_pam_vars(&cli, &pam_vars); + assert_eq!(env.get("PAM_NEW").unwrap(), "pam"); + assert_eq!(env.get("PAM_USER_VAR").unwrap(), "user"); + assert_eq!(env.get("SET").unwrap(), "policy"); + assert!(!env.get("PATH").unwrap().contains("/tmp/evil")); + assert!(!env.contains_key("CHECKED")); + assert!(!env.contains_key("OTHER")); + assert!(!env.contains_key("LD_PRELOAD")); + // dosr -E, which this task allows: the PAM variables are kept but for the delete list + let cli = Cli::builder() + .cmd_path("/usr/bin/true".to_string()) + .opt_filter( + FilterMatcher::builder() + .env_behavior(EnvBehavior::Keep) + .build(), + ) + .build(); + let env = env_with_pam_vars(&cli, &pam_vars); + assert_eq!(env.get("OTHER").unwrap(), "pam"); + assert!(!env.contains_key("CHECKED")); + assert!(!env.contains_key("LD_PRELOAD")); + } } diff --git a/src/dosr/finder/options.rs b/src/dosr/finder/options.rs index b81b2f29..60656735 100644 --- a/src/dosr/finder/options.rs +++ b/src/dosr/finder/options.rs @@ -7,16 +7,16 @@ use bon::{Builder, bon}; use rootasrole_core::database::FilterMatcher; use rootasrole_core::database::env::{EnvLevel, ResolvedEnv}; use rootasrole_core::database::options::{ - EnvBehavior, Level, PathBehavior, SAuthentication, SBounding, SInfo, SPathOptions, SPrivileged, - STimeout, SUMask, WorkdirBehavior, + EnvBehavior, Level, PathBehavior, SAuthentication, SBounding, SInfo, SPassword, SPathOptions, + SPrivileged, STimeout, STty, SUMask, TtyOwner, WorkdirBehavior, }; use rootasrole_core::database::path::{PathLevel, ResolvedPath}; use rootasrole_core::database::score::SecurityMin; use rootasrole_core::database::workdir::{ResolvedWorkdir, WorkdirLevel}; use rootasrole_core::util::{ AUTHENTICATION, BOUNDING, ENV_DEFAULT_BEHAVIOR, ENV_OVERRIDE_BEHAVIOR, ENV_PATH_BEHAVIOR, INFO, - PRIVILEGED, TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, UMASK, is_valid_env_name, - is_valid_env_pattern, + PASSWORD_DELAY, PASSWORD_TRIES, PRIVILEGED, TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, + TTY_OWNER, UMASK, is_valid_env_name, is_valid_env_pattern, }; use serde::{Deserialize, Serialize}; @@ -185,6 +185,10 @@ pub struct Opt<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub umask: Option, #[serde(default, flatten)] pub extra_fields: Value, @@ -203,6 +207,8 @@ impl<'a> Opt<'a> { execinfo: Option, workdir: Option>, timeout: Option, + password: Option, + tty: Option, umask: Option, #[builder(default)] extra_fields: Value, ) -> Self { @@ -216,12 +222,30 @@ impl<'a> Opt<'a> { execinfo, workdir, timeout, + password, + tty, umask, extra_fields, } } } +/// How many times dosr asks for the password, and how long it waits after a wrong one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PasswordSettings { + pub tries: std::num::NonZeroU8, + pub delay: jiff::SignedDuration, +} + +impl Default for PasswordSettings { + fn default() -> Self { + Self { + tries: PASSWORD_TRIES, + delay: PASSWORD_DELAY, + } + } +} + impl PathLevel for DPathOptions<'_> { fn behavior(&self) -> PathBehavior { self.default_behavior @@ -307,6 +331,8 @@ impl From> for rootasrole_core::database::options::Opt { .maybe_bounding(val.bounding) .maybe_authentication(val.authentication) .maybe_timeout(val.timeout) + .maybe_password(val.password) + .maybe_tty(val.tty) .build() } } @@ -470,6 +496,27 @@ impl<'c, 'a> BorrowedOptStack<'c, 'a> { extra_fields: Map::new(), }) } + /// The password tries and delay: each one from the most specific level that sets it, or the + /// build-time default. + pub fn calc_password(&self) -> PasswordSettings { + PasswordSettings { + tries: self + .get_opt_iter_rev() + .find_map(|o| o.password.as_ref()?.tries) + .unwrap_or(PASSWORD_TRIES), + delay: self + .get_opt_iter_rev() + .find_map(|o| o.password.as_ref()?.delay) + .unwrap_or(PASSWORD_DELAY), + } + } + /// Who owns the pseudo-terminal of the command: the most specific level that says it, or + /// the build-time default. + pub fn calc_tty_owner(&self) -> TtyOwner { + self.get_opt_iter_rev() + .find_map(|o| o.tty.as_ref()?.owner) + .unwrap_or(TTY_OWNER) + } pub fn calc_info(&self) -> SInfo { self.get_opt_iter_rev() .find_map(|o| o.execinfo) @@ -874,4 +921,67 @@ mod tests { ); assert_eq!(rar_opt.env.unwrap().default_behavior, EnvBehavior::Keep); } + + // each field of the password option comes from the most specific level that sets it + #[test] + fn test_calc_password_inherits_each_field() { + let stack = BorrowedOptStack::new(None); + assert_eq!(stack.calc_password(), PasswordSettings::default()); + + let config = Opt::builder(Level::Global) + .password( + SPassword::builder() + .tries(std::num::NonZeroU8::new(5).unwrap()) + .build(), + ) + .build(); + let mut stack = BorrowedOptStack::new(Some(&config)); + let task = Opt::builder(Level::Task) + .password( + SPassword::builder() + .delay(jiff::SignedDuration::from_secs(1)) + .build(), + ) + .build(); + stack.task = Some(&task); + assert_eq!( + stack.calc_password(), + PasswordSettings { + tries: std::num::NonZeroU8::new(5).unwrap(), + delay: jiff::SignedDuration::from_secs(1), + } + ); + } + + #[test] + fn test_password_option_of_the_policy() { + let opt: Opt = serde_json::from_str(r#"{"password": {"tries": 2, "delay": "00:00:01"}}"#) + .expect("a valid password option"); + let password = opt.password.expect("the password option"); + assert_eq!(password.tries, std::num::NonZeroU8::new(2)); + assert_eq!(password.delay, Some(jiff::SignedDuration::from_secs(1))); + assert!(serde_json::from_str::(r#"{"password": {"tries": 0}}"#).is_err()); + } + + // the most specific level that says who owns the terminal wins, else the build-time default + #[test] + fn test_calc_tty_owner() { + let stack = BorrowedOptStack::new(None); + assert_eq!(stack.calc_tty_owner(), TTY_OWNER); + + let config: Opt = serde_json::from_str(r#"{"tty": {"owner": "target"}}"#).unwrap(); + let mut stack = BorrowedOptStack::new(Some(&config)); + assert_eq!(stack.calc_tty_owner(), TtyOwner::Target); + // a level with a tty option that does not name the owner inherits it + let role: Opt = serde_json::from_str(r#"{"tty": {"future": 1}}"#).unwrap(); + stack.role = Some(&role); + assert_eq!(stack.calc_tty_owner(), TtyOwner::Target); + let task = Opt::builder(Level::Task) + .tty(STty::builder().owner(TtyOwner::Caller).build()) + .build(); + stack.task = Some(&task); + assert_eq!(stack.calc_tty_owner(), TtyOwner::Caller); + + assert!(serde_json::from_str::(r#"{"tty": {"owner": "root"}}"#).is_err()); + } } diff --git a/src/dosr/finder/tests/perf.rs b/src/dosr/finder/tests/perf.rs new file mode 100644 index 00000000..927bd90a --- /dev/null +++ b/src/dosr/finder/tests/perf.rs @@ -0,0 +1,137 @@ +//! Performance indicators of the finder, ignored by default. Run them in release: +//! +//! ```sh +//! cargo test --release --all-features --lib -- --ignored --nocapture finder::tests::perf +//! ``` +//! +//! Each one prints how long dosr takes to decide with a policy of 1 and of 10 000 tasks, where +//! only the last task allows the command (the policies of `perf/confgen`), and the cost of one +//! task. They assert no time, which depends on the machine: compare the indicators before and +//! after a change, on the same machine. + +use std::sync::{Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use super::*; +use test_log::test; + +const MANY: usize = 10_000; + +/// The indicators run one at a time, so that they do not measure each other. +static ONE_AT_A_TIME: Mutex<()> = Mutex::new(()); + +/// A role of the current user with `nb_tasks` tasks: each one allows a command that is not run, +/// the last one allows `/usr/bin/true`. `command` writes the command of a task from its number, +/// and each task runs it as `root`, or as `0` with `by_id`. +fn policy_of(nb_tasks: usize, command: impl Fn(usize) -> String, by_id: bool) -> Value { + let (user, group) = if by_id { + (serde_json::json!(0), serde_json::json!([0])) + } else { + (serde_json::json!("root"), serde_json::json!(["root"])) + }; + let tasks = (1..=nb_tasks) + .map(|i| { + serde_json::json!({ + "name": format!("t{i}"), + "cred": { + "setuid": {"default": "all", "fallback": user}, + "setgid": {"default": "all", "fallback": group} + }, + "commands": [if i == nb_tasks { "/usr/bin/true".to_string() } else { command(i) }] + }) + }) + .collect::>(); + serde_json::json!([{ + "name": "perf", + "actors": [{"type": "user", "id": nix::unistd::getuid().as_raw()}], + "tasks": tasks + }]) +} + +/// The mean time of a decision for `cmd` with a policy of `nb_tasks` tasks, over `iterations` +/// decisions. +fn decision_time( + name: &str, + method: StorageMethod, + nb_tasks: usize, + policy: &impl Fn(usize) -> Value, + cmd: &str, + iterations: u32, +) -> Duration { + let roles = policy(nb_tasks); + let dir = PolicyDir::new(&format!("perf-{}-{method}", name.replace(' ', "-"))); + let root = dir.0.join("rootasrole"); + write_file(&root, &root_settings(method, &root, roles), method, 0o600); + let cli = Cli::builder().cmd_path(cmd).build(); + let cred = dummy_cred(); + let decide = || { + let settings = find_best_exec_settings( + &cli, + &cred, + &root, + &root, + method, + vec![("A", "B")], + &["/usr/bin"], + ) + .expect("the last task allows the command"); + assert_eq!(settings.task, Some(format!("t{nb_tasks}"))); + assert!(settings.score.fully_matching()); + }; + decide(); + let start = Instant::now(); + for _ in 0..iterations { + decide(); + } + start.elapsed() / iterations +} + +/// Prints the time of a decision for 1 and `MANY` tasks with the policy written by `policy`, and +/// the cost of one task. +fn indicator(name: &str, method: StorageMethod, cmd: &str, policy: impl Fn(usize) -> Value) { + let _alone = ONE_AT_A_TIME.lock().unwrap_or_else(PoisonError::into_inner); + let one = decision_time(name, method, 1, &policy, cmd, 200); + let many = decision_time(name, method, MANY, &policy, cmd, 10); + let per_task = many.saturating_sub(one) / u32::try_from(MANY - 1).unwrap(); + println!( + "perf {name} ({method}, dosr {cmd}): 1 task {one:?}, {MANY} tasks {many:?}, one task {per_task:?}" + ); +} + +// a path written in full, the command given by its path, root named like in most policies +#[test] +#[ignore = "performance indicator, run in release"] +fn perf_absolute_command() { + for method in [StorageMethod::CBOR, StorageMethod::JSON] { + indicator("absolute command", method, "/usr/bin/true", |n| { + policy_of(n, |i| format!("/usr/bin/true{i}"), false) + }); + } +} + +// the same with root given by its id, like the policies of perf/confgen +#[test] +#[ignore = "performance indicator, run in release"] +fn perf_target_by_id() { + indicator("target by id", StorageMethod::CBOR, "/usr/bin/true", |n| { + policy_of(n, |i| format!("/usr/bin/true{i}"), true) + }); +} + +// the command typed by its name, searched in the PATH of each task +#[test] +#[ignore = "performance indicator, run in release"] +fn perf_command_in_path() { + indicator("command in PATH", StorageMethod::CBOR, "true", |n| { + policy_of(n, |i| format!("/usr/bin/true{i}"), false) + }); +} + +// the commands of the policy are patterns, which are compiled +#[test] +#[ignore = "performance indicator, run in release"] +fn perf_glob_commands() { + indicator("glob commands", StorageMethod::CBOR, "/usr/bin/true", |n| { + policy_of(n, |i| format!("/usr/bin/true{i}*"), false) + }); +} diff --git a/src/dosr/lib.rs b/src/dosr/lib.rs index e871d26f..28d266d4 100644 --- a/src/dosr/lib.rs +++ b/src/dosr/lib.rs @@ -2,6 +2,37 @@ //! It is a library so that its internals can be fuzzed in-process (see the `fuzzing` feature). #![cfg(feature = "finder")] +// `println!` and the like panic when the output is a closed pipe (`EPIPE`, since Rust ignores +// `SIGPIPE`): outside the tests, the `*_ignore_io_error` macros of rootasrole-core are used. +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! println { + ($($tt:tt)*) => { + compile_error!("println! panics on EPIPE, use println_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! print { + ($($tt:tt)*) => { + compile_error!("print! panics on EPIPE, use print_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprintln { + ($($tt:tt)*) => { + compile_error!("eprintln! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} +#[cfg(not(test))] +#[allow(unused_macros)] +macro_rules! eprint { + ($($tt:tt)*) => { + compile_error!("eprint! panics on EPIPE, use eprintln_ignore_io_error!") + }; +} + mod error; mod finder; #[cfg(feature = "fuzzing")] @@ -17,7 +48,7 @@ use bon::Builder; use capctl::{Cap, CapState}; use const_format::formatcp; use nix::unistd::{Uid, User}; -use rootasrole_core::database::options::SBounding; +use rootasrole_core::database::options::{SBounding, TtyOwner}; use rootasrole_core::util::{initialize_capabilities, with_privileges}; use rootasrole_core::{ Cred, @@ -26,11 +57,12 @@ use rootasrole_core::{ actor::{SGroupType, SGroups, SUserType}, options::EnvBehavior, }, - log::subsribe, + log::subscribe, }; use log::{debug, error, info}; use pam::PAM_PROMPT; +use rootasrole_core::{eprintln_ignore_io_error, print_ignore_io_error, println_ignore_io_error}; use std::ffi::{CString, OsString}; use std::os::unix::ffi::OsStringExt; use std::path::PathBuf; @@ -61,7 +93,7 @@ pub(crate) const RAR_CFG_DATA_PATH: &str = "target/rootasrole.json"; //and each role has a set of rights to execute commands."; #[allow(clippy::needless_raw_string_hashes)] const USAGE: &str = formatcp!( - r#"{UNDERLINE}{BOLD}Usage:{RST} {BOLD}dosr{RST} [OPTIONS] [COMMAND]... + r#"{UNDERLINE}{BOLD}Usage:{RST} {BOLD}dosr{RST} [OPTIONS] [--] [COMMAND]... {UNDERLINE}{BOLD}Arguments:{RST} [COMMAND]... @@ -80,21 +112,21 @@ const USAGE: &str = formatcp!( {BOLD}-D, --chdir {RST} Workdir option allows you to specify the working directory for the command - {BOLD}-S, --stdin{RST} - Use stdin for password prompt - {BOLD}-p, --prompt {RST} Prompt option allows you to override the default password prompt and use a custom one [default: "Password: "] + {BOLD}-S, --stdin{RST} + Read the password from the standard input instead of the terminal + + {BOLD}-K, --remove-timestamp{RST} + Remove your timestamp cookies before authentication; without a command, only remove them + {BOLD}-u, --user {RST} Specify the user to execute the command as {BOLD}-g --group (,...){RST} Specify the group to execute the command as - - {BOLD}-K, --remove-timestamp{RST} - Delete timestamp cookie after successful authentication {BOLD}-i, --info{RST} Display rights of executor @@ -147,12 +179,70 @@ impl Default for Cli { } } -const CAPABILITIES_ERROR: &str = - "You need at least dac_read_search or dac_override, setpcap and setuid capabilities to run sr"; +const CAPABILITIES_ERROR: &str = "You need at least dac_read_search or dac_override, setpcap and setuid capabilities to run dosr"; fn cap_effective_error(caplist: &str) -> String { format!("Unable to toggle {caplist} privilege. {CAPABILITIES_ERROR}") } +/// Why dosr lacks the capabilities that it needs to run at all (see [`CAPABILITIES_ERROR`]), +/// from the state of its process: `None` when it has them. +/// +/// Without them, reading the policy fails and the user would only see "Permission denied", as if +/// the policy refused the command. The usual case is dosr run from a command that dosr started: +/// with `root: user`, that command has `no_new_privs`, and under a strict bounding set it lacks +/// the capabilities dosr needs. +fn missing_privileges_reason( + no_new_privs: bool, + permitted: capctl::CapSet, + bounding: capctl::CapSet, +) -> Option { + let mut missing: capctl::CapSet = [Cap::SETPCAP, Cap::SETUID] + .into_iter() + .filter(|cap| !permitted.has(*cap)) + .collect(); + if !permitted.has(Cap::DAC_READ_SEARCH) && !permitted.has(Cap::DAC_OVERRIDE) { + missing.add(Cap::DAC_READ_SEARCH); + } + if missing.is_empty() { + return None; + } + let names = |caps: capctl::CapSet| { + caps.iter() + .map(|cap| cap.to_string()) + .collect::>() + .join(" ") + }; + let outside_bounding = missing & !bounding; + Some(if no_new_privs { + "no_new_privs is set in this session, so the kernel does not give dosr the capabilities \ + of its file: it is set by a service with NoNewPrivileges=yes, a container, \ + setpriv --no-new-privs, or a command that dosr runs with \"root\": \"user\"" + .to_string() + } else if !outside_bounding.is_empty() { + format!( + "the bounding set of this session lacks {}, which dosr needs: it is reduced by a \ + service with CapabilityBoundingSet=, a container, or a command that dosr runs with \ + a strict bounding set", + names(outside_bounding) + ) + } else { + format!( + "dosr lacks {}: its file has no capabilities (setcap '=p'), or its file system is \ + mounted nosuid", + names(missing) + ) + }) +} + +/// [`missing_privileges_reason`] for this process. +fn missing_privileges() -> Option { + let no_new_privs = capctl::prctl::get_no_new_privs().unwrap_or(false); + let permitted = CapState::get_current() + .map(|state| state.permitted) + .unwrap_or_default(); + missing_privileges_reason(no_new_privs, permitted, capctl::bounding::probe()) +} + fn getopt(s: I) -> SrResult where I: IntoIterator, @@ -215,8 +305,16 @@ where "-h" | "--help" => { args.help = true; } + // the end of the options, like sudo: the next word is the command, even when it + // starts with - + "--" => { + if let Some(command) = iter.next() { + args.cmd_path = command.as_ref().into(); + } + break; + } "-v" | "--version" => { - println!("dosr: version {}", env!("CARGO_PKG_VERSION")); + println_ignore_io_error!("dosr: version {}", env!("CARGO_PKG_VERSION")); std::process::exit(0); } _ => { @@ -256,14 +354,22 @@ where #[cfg(not(tarpaulin_include))] pub fn dosr_main() { - if let Err(e) = subsribe("sr") { - eprintln!("dosr: Failed to initialize logging: {e}"); + if let Err(e) = subscribe("dosr") { + eprintln_ignore_io_error!("dosr: Failed to initialize logging: {e}"); std::process::exit(1); } if let Err(e) = main_inner() { - eprintln!("dosr: {e}"); - if e == SrError::InsufficientPrivileges { - error!("Insufficient privileges to run sr. {CAPABILITIES_ERROR}"); + eprintln_ignore_io_error!("dosr: {e}"); + // whatever the error, it comes from the missing capabilities when dosr lacks them + let missing = missing_privileges(); + if let Some(reason) = &missing { + eprintln_ignore_io_error!("dosr: {reason}"); + } + if e == SrError::InsufficientPrivileges || missing.is_some() { + error!( + "Insufficient privileges to run dosr: {}", + missing.as_deref().unwrap_or(CAPABILITIES_ERROR) + ); } else if e == SrError::AuthenticationFailed { error!( "Authentication failed for user '{}', when trying running '''{}'''", @@ -272,7 +378,7 @@ pub fn dosr_main() { .unwrap_or_else(|_| Uid::current().to_string()), command_line() ); - eprintln!("This incident is reported."); + eprintln_ignore_io_error!("This incident is reported."); } else { error!( "User '{}' got a '{}' when trying running '''{}'''", @@ -295,7 +401,7 @@ fn main_inner() -> SrResult<()> { use crate::{ RAR_CFG_PATH, finder::api::{Api, register_plugins}, - pam::start_session, + pam::{session_env, start_session}, }; use finder::find_best_exec_settings; use rootasrole_core::util::RAR_CFG_TYPE; @@ -304,13 +410,13 @@ fn main_inner() -> SrResult<()> { drop_effective()?; let args = utf8_args(std::env::args_os())?; if args.len() < 2 { - println!("{USAGE}"); + println_ignore_io_error!("{USAGE}"); return Ok(()); } let args = getopt(args)?; if args.help { - println!("{USAGE}"); + println_ignore_io_error!("{USAGE}"); return Ok(()); } let user = make_cred().map_err(|_| SrError::SystemError)?; @@ -332,7 +438,7 @@ fn main_inner() -> SrResult<()> { } } register_plugins(); - let execcfg = find_best_exec_settings( + let mut execcfg = find_best_exec_settings( &args, &user, RAR_CFG_PATH, @@ -349,10 +455,22 @@ fn main_inner() -> SrResult<()> { debug!("Best exec settings: {execcfg:?}"); // Let the PAM session live until the end of the program - let session = start_session(execcfg.auth, &execcfg.timeout, &user, &args)?; + let session = start_session( + execcfg.auth, + &execcfg.timeout, + execcfg.password, + &user, + &args, + )?; + // The variables that PAM modules set go through the filter of the policy, like the user's + execcfg + .env_filter + .add_vars(&mut execcfg.env, session_env(&session)); if !execcfg.score.fully_matching() { - println!("You are not allowed to execute this command, this incident will be reported."); + println_ignore_io_error!( + "You are not allowed to execute this command, this incident will be reported." + ); error!( "User {} tried to execute command : {} {:?} without the permission.", &user.user.name, @@ -372,7 +490,7 @@ fn main_inner() -> SrResult<()> { .map(|cap| cap.to_string()) .collect::>() .join(" "); - eprintln!( + eprintln_ignore_io_error!( "dosr: the task needs capabilities that this session cannot obtain anymore: {names}" ); error!( @@ -386,8 +504,7 @@ fn main_inner() -> SrResult<()> { } if args.info { - use capctl::CapSet; - println!( + println_ignore_io_error!( "Role: {}", if execcfg.role.is_empty() { "None" @@ -395,8 +512,8 @@ fn main_inner() -> SrResult<()> { &execcfg.role } ); - println!("Task: {}", execcfg.task.as_ref().map_or("None", |v| v)); - print!( + println_ignore_io_error!("Task: {}", execcfg.task.as_ref().map_or("None", |v| v)); + print_ignore_io_error!( "Execute as user: {}", if let Some(u) = execcfg.cred.setuid { format!("{} ({})", u.name, u.uid) @@ -405,31 +522,21 @@ fn main_inner() -> SrResult<()> { } ); if let Some(gids) = execcfg.cred.setgroups.as_ref() { - print!(" and group(s): "); + print_ignore_io_error!(" and group(s): "); let groups = gids .iter() .map(|g| format!("{} ({})", g.name, g.gid)) .collect::>() .join(", "); - println!("{groups}"); + println_ignore_io_error!("{groups}"); } else { - println!(" your current group(s)"); + println_ignore_io_error!(" your current group(s)"); } - println!( + println_ignore_io_error!( "With capabilities: {}", - execcfg.cred.caps.map_or_else( - || "None".to_string(), - |caps| if caps == !CapSet::empty() { - "All capabilities".to_string() - } else { - caps.into_iter() - .fold(String::new(), |acc, cap| acc + &cap.to_string() + " ") - .trim_end() - .to_string() - } - ) + capabilities_display(execcfg.cred.caps) ); - println!( + println_ignore_io_error!( "Command: {} {:?}", execcfg.final_path.display(), args.cmd_args @@ -483,11 +590,17 @@ fn main_inner() -> SrResult<()> { cargs.join(" ") ); - let status = match rootasrole_exec::runner::run(command_builder, PRE_EXEC_ORCHESTRATOR, None) { + let tty_owner = tty_owner(execcfg.tty_owner, &user.user, execcfg.cred.setuid.as_ref()); + let status = match rootasrole_exec::runner::run_with_pty_setup( + command_builder, + PRE_EXEC_ORCHESTRATOR, + None, + |pty| tty_owner.map_or(Ok(()), |owner| give_pty(pty, owner)), + ) { Ok(status) => status, Err(e) => { error!("{e}"); - eprintln!("dosr: {} : {}", cfinal_path.display(), e); + eprintln_ignore_io_error!("dosr: {} : {}", cfinal_path.display(), e); std::process::exit(1); } }; @@ -496,6 +609,30 @@ fn main_inner() -> SrResult<()> { exit_like(status) } +/// The user that gets the pseudo-terminal of the command, when it is not the caller: the user +/// that the command runs as, if the `tty` option says `target`. +fn tty_owner<'a>(owner: TtyOwner, caller: &User, target: Option<&'a User>) -> Option<&'a User> { + match owner { + TtyOwner::Target => target.filter(|target| target.uid != caller.uid), + TtyOwner::Caller => None, + } +} + +/// Give the pseudo-terminal to `owner`, with `CAP_CHOWN` only for that, before the command starts. +fn give_pty(pty: &rootasrole_exec::pty::Pty, owner: &User) -> io::Result<()> { + with_privileges(&[Cap::CHOWN], || pty.follower.set_owner(owner.uid.as_raw())).map_err(|e| { + error!( + "Cannot give the terminal {} to {}: {e}", + pty.path.to_string_lossy(), + owner.name + ); + io::Error::new( + e.kind(), + format!("cannot give the terminal to {}: {e}", owner.name), + ) + }) +} + /// How the command ended: its exit code, or the signal that killed it. #[derive(Debug, PartialEq, Eq)] enum CommandEnd { @@ -650,13 +787,35 @@ fn execution_log_line(user: &Cred, execcfg: &finder::BestExecSettings, args: &Cl ) } +/// The credentials of the caller. Its groups are kept as ids: the policy names groups, which +/// are looked up once and compared with these ids, while the name of each group of the caller +/// would cost an NSS lookup at each run (with systemd-userdb, about 0.5 ms each). fn make_cred() -> std::io::Result { - Ok(Cred::builder() - .user()? - .groups()? - .curdir()? - .maybe_tty(procfs::controlling_tty()) - .build()) + Ok(Cred { + user: User::from_uid(Uid::current())? + .ok_or_else(|| std::io::Error::other("User not found"))?, + groups: nix::unistd::getgroups()? + .into_iter() + .map(rootasrole_core::util::Either::Right) + .collect(), + tty: procfs::controlling_tty(), + ppid: nix::unistd::getppid(), + curdir: std::env::current_dir()?, + }) +} + +/// The capabilities of the command, as `dosr -i` shows them: a task without capabilities +/// shows `None`, like a task that does not set them. +fn capabilities_display(caps: Option) -> String { + match caps { + Some(caps) if caps == !capctl::CapSet::empty() => "All capabilities".to_string(), + Some(caps) if !caps.is_empty() => caps + .into_iter() + .map(|cap| cap.to_string()) + .collect::>() + .join(" "), + _ => "None".to_string(), + } } /// The capabilities of the task that the bounding set does not have anymore: no process of @@ -812,6 +971,24 @@ mod tests { assert_eq!(args.cmd_args, ["\"a\"", "'b'", "'\"c\"'"]); } + #[test] + fn test_getopt_double_dash_ends_the_options() { + let args = getopt(["dosr", "-r", "r1", "--", "-weird", "-l", "--"]).unwrap(); + assert_eq!(args.cmd_path, PathBuf::from("-weird")); + assert_eq!(args.cmd_args, vec!["-l".to_string(), "--".to_string()]); + assert_eq!( + args.opt_filter.as_ref().and_then(|f| f.role.as_deref()), + Some("r1") + ); + let args = getopt(["dosr", "--", "ls", "-l"]).unwrap(); + assert_eq!(args.cmd_path, PathBuf::from("ls")); + assert_eq!(args.cmd_args, vec!["-l".to_string()]); + // nothing after it: no command, as without it + let args = getopt(["dosr", "-i", "--"]).unwrap(); + assert!(args.cmd_path.as_os_str().is_empty()); + assert!(args.info); + } + #[test] fn test_getopt_missing_prompt() { assert!(matches!( @@ -915,18 +1092,14 @@ mod tests { fn test_make_cred() { let user = make_cred().unwrap(); assert_eq!(user.user.uid, getuid()); - let groups = getgroups() - .unwrap() - .iter() - .map(|g| Group::from_gid(*g).unwrap().unwrap()) - .collect::>(); assert!(!user.groups.is_empty()); + // the groups of the caller are its ids, never looked up assert_eq!( user.groups .iter() - .map(|e| e.left().unwrap().clone()) + .map(|e| *e.right().unwrap()) .collect::>(), - groups + getgroups().unwrap() ); assert_eq!(user.ppid, Pid::parent()); } @@ -1005,6 +1178,93 @@ mod tests { } } + #[test] + fn test_missing_privileges_reason() { + let all = CapSet::from_iter([ + Cap::SETPCAP, + Cap::SETUID, + Cap::SETGID, + Cap::DAC_READ_SEARCH, + Cap::DAC_OVERRIDE, + ]); + assert_eq!(missing_privileges_reason(false, all, all), None); + // one of the two DAC capabilities is enough to read the policy + let only_override = CapSet::from_iter([Cap::SETPCAP, Cap::SETUID, Cap::DAC_OVERRIDE]); + assert_eq!( + missing_privileges_reason(false, only_override, only_override), + None + ); + // the file capabilities are ignored under no_new_privs + let reason = missing_privileges_reason(true, CapSet::empty(), all).unwrap(); + assert!(reason.contains("no_new_privs"), "{reason}"); + // a reduced bounding set names what it lacks + let bounding = CapSet::from_iter([Cap::SETPCAP, Cap::DAC_READ_SEARCH]); + let reason = missing_privileges_reason(false, bounding, bounding).unwrap(); + assert!(reason.contains("bounding set"), "{reason}"); + assert!(reason.contains(&Cap::SETUID.to_string()), "{reason}"); + assert!(!reason.contains(&Cap::SETPCAP.to_string()), "{reason}"); + // in the bounding set but not permitted: the file of dosr + let reason = missing_privileges_reason(false, CapSet::empty(), all).unwrap(); + assert!(reason.contains("setcap"), "{reason}"); + } + + #[test] + fn test_capabilities_display() { + assert_eq!(capabilities_display(None), "None"); + // "capabilities": "none" or [] in the policy + assert_eq!(capabilities_display(Some(CapSet::empty())), "None"); + assert_eq!( + capabilities_display(Some(!CapSet::empty())), + "All capabilities" + ); + let caps: CapSet = [Cap::NET_RAW, Cap::SYS_BOOT].into_iter().collect(); + assert_eq!( + capabilities_display(Some(caps)), + format!("{} {}", Cap::NET_RAW, Cap::SYS_BOOT) + ); + } + + // each option of dosr is listed once in its help + #[test] + fn test_usage_lists_each_option_once() { + for option in [ + "-r, --role", + "-t, --task", + "-E, --preserve-env", + "-D, --chdir", + "-p, --prompt", + "-S, --stdin", + "-K, --remove-timestamp", + "-u, --user", + "-g --group", + "-i, --info", + "-v, --version", + "-h, --help", + ] { + assert_eq!(USAGE.matches(option).count(), 1, "{option}"); + } + } + + // the terminal changes hands only with `target`, and only for another user + #[test] + fn test_tty_owner() { + let caller = User::from_uid(getuid()).unwrap().unwrap(); + let root = User::from_uid(Uid::from_raw(0)).unwrap().unwrap(); + let target = if caller.uid.is_root() { + User::from_name("nobody").unwrap().unwrap() + } else { + root + }; + assert_eq!( + tty_owner(TtyOwner::Target, &caller, Some(&target)).map(|u| u.uid), + Some(target.uid) + ); + assert_eq!(tty_owner(TtyOwner::Caller, &caller, Some(&target)), None); + assert_eq!(tty_owner(TtyOwner::Target, &caller, Some(&caller)), None); + assert_eq!(tty_owner(TtyOwner::Target, &caller, None), None); + assert_eq!(tty_owner(TtyOwner::Caller, &caller, None), None); + } + #[test] fn test_caps_outside_bounding() { use capctl::{Cap, CapSet}; diff --git a/src/dosr/pam/mod.rs b/src/dosr/pam/mod.rs index 0d52e756..bb5e1231 100644 --- a/src/dosr/pam/mod.rs +++ b/src/dosr/pam/mod.rs @@ -1,12 +1,22 @@ -use std::{borrow::Cow, ffi::CStr}; +use std::{ + borrow::Cow, + ffi::CStr, + fs::{File, OpenOptions}, + io, + os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, + path::Path, +}; use bon::Builder; use log::{debug, error, info, warn}; use nonstick2::{ - AuthnFlags, ConversationAdapter, ErrorCode, LibPamTransaction, PamShared, Result as PamResult, - TransactionBuilder, conv::Demux, items::ItemsMut, libpam::SessionActive, + AuthnFlags, ConversationAdapter, EnvironMap, ErrorCode, LibPamTransaction, PamShared, + Result as PamResult, TransactionBuilder, conv::Demux, items::Items, items::ItemsMut, + libpam::SessionActive, }; +use rootasrole_core::{eprintln_ignore_io_error, println_ignore_io_error}; +use crate::finder::PasswordSettings; #[cfg(feature = "timeout")] use crate::timeout; use crate::{ @@ -115,12 +125,12 @@ impl ConversationAdapter for SrConversationHandler<'_> { fn error_msg(&self, message: impl AsRef) { error!("{}", message.as_ref().to_string_lossy()); - eprintln!("{}", message.as_ref().to_string_lossy()); + eprintln_ignore_io_error!("{}", message.as_ref().to_string_lossy()); } fn info_msg(&self, message: impl AsRef) { info!("{}", message.as_ref().to_string_lossy()); - println!("{}", message.as_ref().to_string_lossy()); + println_ignore_io_error!("{}", message.as_ref().to_string_lossy()); } } @@ -136,12 +146,118 @@ fn set_pam_items(txn: &mut impl PamShared, user: &Cred) -> PamResult<()> { Ok(()) } +/// Whether the user of the PAM transaction is still the one dosr asked for. +fn same_pam_user(pam_user: Option<&std::ffi::OsStr>, requested: &str) -> bool { + pam_user == Some(std::ffi::OsStr::new(requested)) +} + +/// A PAM module may change `PAM_USER` during the transaction (`pam_get_item(3)`): dosr refuses +/// to go on for another user than the one it authenticates. +fn check_pam_user(txn: &impl PamShared, user: &Cred) -> SrResult<()> { + let pam_user = txn.items().user().map_err(|e| { + error!("Cannot read the user of the PAM transaction: {e}"); + SrError::AuthenticationFailed + })?; + if same_pam_user(pam_user.as_deref(), &user.user.name) { + Ok(()) + } else { + error!( + "A PAM module changed the user from {} to {pam_user:?}", + user.user.name + ); + Err(SrError::AuthenticationFailed) + } +} + +/// The variables that PAM modules set for the session (`pam_getenvlist(3)`), such as `pam_env` +/// or `pam_systemd`. The environment of the command is UTF-8: another variable is left out, as +/// a variable without a name. +pub fn session_env(txn: &impl PamShared) -> Vec<(String, String)> { + txn.environ() + .iter() + .filter_map(|(key, value)| match (key.into_string(), value.into_string()) { + (Ok(key), Ok(value)) if !key.is_empty() => Some((key, value)), + (key, _) => { + warn!("A variable of the PAM session is left out, it is not UTF-8 or has no name: {key:?}"); + None + } + }) + .collect() +} + +/// An exclusive lock on the user's terminal, released when dropped (when its file is closed). +struct TerminalLock { + _terminal: File, +} + +/// Lock the terminal at `path`, waiting while another dosr of the same pipeline authenticates on +/// it. `None` when there is no terminal or it cannot be locked: dosr then goes on without it. +fn lock_terminal(path: &Path) -> Option { + let terminal = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOCTTY) + .open(path) + .ok()?; + loop { + // SAFETY: flock does not access memory, and the descriptor stays open during the call + if unsafe { libc::flock(terminal.as_raw_fd(), libc::LOCK_EX) } == 0 { + return Some(TerminalLock { + _terminal: terminal, + }); + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + debug!("cannot lock the terminal: {error}"); + return None; + } + } +} + +/// Run `attempt` until it succeeds, at most `settings.tries` times. Only a wrong password +/// (`PAM_AUTH_ERR`) is asked again, after `settings.delay`: any other error, such as the end of +/// the input or an unknown user, stops at once. +fn retry_authentication( + mut state: T, + settings: PasswordSettings, + mut attempt: impl FnMut(T) -> Result, +) -> Result { + let tries = settings.tries.get(); + let mut try_number = 1; + loop { + match attempt(state) { + Ok(done) => return Ok(done), + Err((next, error)) => { + warn!("Authentication failed (try {try_number} of {tries}): {error}"); + if error != ErrorCode::AuthenticationError { + return Err(error); + } + std::thread::sleep(settings.delay.unsigned_abs()); + if try_number >= tries { + return Err(error); + } + eprintln_ignore_io_error!("Sorry, try again."); + try_number += 1; + state = next; + } + } + } +} + pub fn start_session<'a>( authentication: SAuthentication, #[cfg_attr(not(feature = "timeout"), allow(unused_variables))] timeout: &STimeout, + password: PasswordSettings, user: &Cred, cli: &Cli, ) -> SrResult>, SessionActive>> { + // `dosr a | dosr b` would read both passwords from the terminal at the same time, each one + // getting part of the keystrokes: the terminal is locked until this authentication is done, + // and the other dosr checks its timestamp once it gets the lock (sudo-rs #1650). + let _terminal_lock = if authentication.is_skip() || cli.stdin { + None + } else { + lock_terminal(Path::new("/dev/tty")) + }; let need_password_auth = if authentication.is_skip() { warn!("Skipping password authentication (PAM session-only mode)."); false @@ -176,9 +292,9 @@ pub fn start_session<'a>( })?; let txn = if need_password_auth { - txn.authenticate(AuthnFlags::SILENT) + retry_authentication(txn, password, |txn| txn.authenticate(AuthnFlags::SILENT)) .map_err(|e| { - error!("Authentication failed: {}", e.1); + error!("Authentication failed: {e}"); SrError::AuthenticationFailed })? .account_management(AuthnFlags::SILENT) @@ -192,6 +308,7 @@ pub fn start_session<'a>( SrError::AuthenticationFailed })? }; + check_pam_user(&txn, user)?; let txn = txn .establish_credentials() @@ -204,10 +321,12 @@ pub fn start_session<'a>( error!("Failed to open session: {}", e.1); SrError::SystemError })?; + check_pam_user(&txn, user)?; - // Failing to save the cookie only means that the user will be asked to authenticate again + // Failing to save the cookie only means that the user will be asked to authenticate again; + // a task that skips the authentication keeps no cookie #[cfg(feature = "timeout")] - if let Err(e) = timeout::update_cookie(user, user, timeout) { + if let Err(e) = timeout::record_authentication(authentication, user, timeout) { error!("Failed to update timeout cookie: {e}"); } @@ -219,14 +338,14 @@ mod tests { use super::*; use jiff::SignedDuration; use nix::{libc::dev_t, unistd::Pid}; - use nonstick2::items::Items; + use nonstick2::{EnvironMapMut, items::Items}; use rootasrole_core::{ Cred, database::options::{SAuthentication, STimeout, TimestampType}, }; use rootasrole_exec::terminal::TerminalExt; use serde_json::Map; - use std::{ffi::OsStr, sync::Mutex}; + use std::{ffi::OsStr, os::unix::ffi::OsStrExt, sync::Mutex}; use test_log::test; static PAM_TEST_LOCK: Mutex<()> = Mutex::new(()); @@ -376,7 +495,13 @@ mod tests { // When authentication is skipped, it should always succeed let cli = Cli::builder().prompt("Password: ").build(); - let result = start_session(authentication, &timeout, &user, &cli); + let result = start_session( + authentication, + &timeout, + PasswordSettings::default(), + &user, + &cli, + ); assert!(result.is_ok()); } @@ -394,7 +519,13 @@ mod tests { let user = create_test_user(); let cli = Cli::builder().prompt("Password: ").build(); - let _ = start_session(authentication, &timeout, &user, &cli); + let _ = start_session( + authentication, + &timeout, + PasswordSettings::default(), + &user, + &cli, + ); } #[test] @@ -483,7 +614,135 @@ mod tests { let cli = Cli::builder().prompt("Password: ").build(); // Test different timeout types don't cause errors - assert!(start_session(auth, &timeout_ppid, &user, &cli).is_ok()); - assert!(start_session(auth, &timeout_tty, &user, &cli).is_ok()); + assert!( + start_session( + auth, + &timeout_ppid, + PasswordSettings::default(), + &user, + &cli + ) + .is_ok() + ); + assert!( + start_session(auth, &timeout_tty, PasswordSettings::default(), &user, &cli).is_ok() + ); + } + + // Another dosr of the same pipeline opens the terminal on its own: while one holds the lock, + // the other cannot take it, and it can once the lock is dropped. + #[test] + fn test_terminal_lock_is_exclusive_and_released_on_drop() { + let pty = rootasrole_exec::pty::Pty::open().expect("open a pty"); + let path = Path::new(OsStr::from_bytes(pty.path.as_bytes())); + let other = File::open(path).expect("open the terminal again"); + // SAFETY: flock does not access memory, and `other` stays open while the closure lives + let try_lock = || unsafe { libc::flock(other.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + + let lock = lock_terminal(path).expect("lock the terminal"); + assert_eq!(try_lock(), -1, "the terminal is locked"); + drop(lock); + assert_eq!(try_lock(), 0, "the lock is released"); + } + + #[test] + fn test_no_terminal_lock_without_a_terminal() { + assert!(lock_terminal(Path::new("/nonexistent/tty")).is_none()); + } + + fn quick(tries: u8, delay_ms: i64) -> PasswordSettings { + PasswordSettings { + tries: std::num::NonZeroU8::new(tries).unwrap(), + delay: SignedDuration::from_millis(delay_ms), + } + } + + #[test] + fn test_retry_authentication_until_the_password_is_right() { + let mut calls = 0; + let result = retry_authentication((), quick(3, 10), |state| { + calls += 1; + if calls < 2 { + Err((state, ErrorCode::AuthenticationError)) + } else { + Ok("authenticated") + } + }); + assert_eq!(result, Ok("authenticated")); + assert_eq!(calls, 2); + } + + // a delay after each wrong password, the last one included, then dosr gives up + #[test] + fn test_retry_authentication_gives_up_after_the_tries() { + let start = std::time::Instant::now(); + let mut calls = 0; + let result: Result<(), _> = retry_authentication((), quick(3, 50), |state| { + calls += 1; + Err((state, ErrorCode::AuthenticationError)) + }); + assert_eq!(result, Err(ErrorCode::AuthenticationError)); + assert_eq!(calls, 3); + assert!(start.elapsed() >= std::time::Duration::from_millis(150)); + } + + // the end of the input or an unknown user is not a wrong password: no retry, no delay + #[test] + fn test_retry_authentication_stops_on_other_errors() { + for error in [ + ErrorCode::ConversationError, + ErrorCode::UserUnknown, + ErrorCode::MaxTries, + ] { + let start = std::time::Instant::now(); + let mut calls = 0; + let result: Result<(), _> = retry_authentication((), quick(3, 1000), |state| { + calls += 1; + Err((state, error)) + }); + assert_eq!(result, Err(error)); + assert_eq!(calls, 1); + assert!(start.elapsed() < std::time::Duration::from_millis(500)); + } + } + + #[test] + fn test_same_pam_user() { + assert!(same_pam_user(Some(OsStr::new("alice")), "alice")); + assert!(!same_pam_user(Some(OsStr::new("root")), "alice")); + assert!(!same_pam_user(None, "alice")); + } + + #[test] + fn test_session_env_reads_the_pam_environment() { + let user = create_test_user(); + let conv = SrConversationHandler::builder().no_interact(true).build(); + let Ok(mut txn) = TransactionBuilder::new_with_service(PAM_SERVICE) + .username(&user.user.name) + .build(conv.into_conversation()) + else { + eprintln!("Skipping test, PAM is not available"); + return; + }; + // what a module such as pam_env does (pam_putenv(3)) + { + let mut environ = txn.environ_mut(); + environ.insert("PAM_TEST_VAR", "value=with=equals"); + environ.insert("PAM_TEST_EMPTY", ""); + environ.insert( + OsStr::from_bytes(b"PAM_TEST_\xff"), + "a name that is not UTF-8", + ); + environ.insert("PAM_TEST_BYTES", OsStr::from_bytes(b"\xff")); + } + let env = session_env(&txn); + let pam_test_vars: Vec<_> = env + .iter() + .filter(|(key, _)| key.starts_with("PAM_TEST_")) + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + assert_eq!(pam_test_vars.len(), 2, "{pam_test_vars:?}"); + assert!(pam_test_vars.contains(&("PAM_TEST_VAR", "value=with=equals"))); + assert!(pam_test_vars.contains(&("PAM_TEST_EMPTY", ""))); } } diff --git a/src/dosr/timeout.rs b/src/dosr/timeout.rs index bd14811c..46614679 100644 --- a/src/dosr/timeout.rs +++ b/src/dosr/timeout.rs @@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize}; use rootasrole_core::{ Cred, - database::options::{STimeout, TimestampType}, + database::options::{SAuthentication, STimeout, TimestampType}, util::with_privileges, }; @@ -49,6 +49,10 @@ enum ParentRecord { Ppid { ppid: pid_t, ppid_start: u64, + /// The session of dosr: a child of the same parent in another session (after + /// `setsid(2)`) does not reuse the cookie. Only the id is kept: the session leader may be + /// gone while the session lives on, without a start time to read. + session: pid_t, }, None, } @@ -69,6 +73,7 @@ impl ParentRecord { (TimestampType::TTY | TimestampType::PPID, _) => Some(Self::Ppid { ppid: user.ppid.as_raw(), ppid_start: procfs::start_time(user.ppid).ok()?, + session: getsid(None).ok()?.as_raw(), }), (TimestampType::UID, _) => Some(Self::None), } @@ -377,6 +382,25 @@ pub fn is_valid(from: &Cred, cred_asked: &Cred, constraint: &STimeout) -> bool { res.is_some() } +/// Keep the cookie of `user` once its session is open: an authentication, or the cookie that +/// spared it, creates or extends it. A task that skips the authentication proves nothing about +/// who is at the terminal: its cookie would spare the password to a later task that requires it. +/// sudo-rs records an authentication only after it (`src/sudo/pipeline.rs`, +/// `auth_and_update_record_file`). +/// +/// # Errors +/// The errors of [`update_cookie`]. +pub fn record_authentication( + authentication: SAuthentication, + user: &Cred, + constraint: &STimeout, +) -> Result<(), Box> { + if authentication.is_skip() { + return Ok(()); + } + update_cookie(user, user, constraint) +} + /// Add a cookie to the user's cookie file pub fn update_cookie( from: &Cred, @@ -487,6 +511,36 @@ mod test { clear_cookies(&cred).unwrap(); } + // a task that skips the authentication neither creates nor extends a cookie + #[test] + fn test_skipped_authentication_gives_no_cookie() { + let _guard = MUTEX.lock().unwrap(); + setup(); + let cred = Cred { + user: User::from_uid(0.into()).unwrap().unwrap(), + curdir: "".into(), + groups: vec![], + tty: None, + ppid: Pid::parent(), + }; + clear_cookies(&cred).unwrap(); + let constraint = STimeout { + type_field: Some(TimestampType::PPID), + duration: Some(SignedDuration::from_secs(10)), + max_usage: Some(2), + extra_fields: Map::default(), + }; + record_authentication(SAuthentication::Skip, &cred, &constraint).unwrap(); + assert!(!is_valid(&cred, &cred, &constraint)); + record_authentication(SAuthentication::Perform, &cred, &constraint).unwrap(); + assert!(is_valid(&cred, &cred, &constraint)); + // a skipped authentication does not use the cookie either + record_authentication(SAuthentication::Skip, &cred, &constraint).unwrap(); + record_authentication(SAuthentication::Perform, &cred, &constraint).unwrap(); + assert!(is_valid(&cred, &cred, &constraint)); + clear_cookies(&cred).unwrap(); + } + #[test] fn test_cookie_requires_matching_ppid() { let _guard = MUTEX.lock().unwrap(); @@ -562,6 +616,128 @@ mod test { })); } + #[test] + fn test_cookie_requires_matching_session() { + let _guard = MUTEX.lock().unwrap(); + setup(); + // same parent process, but dosr runs in another session + assert!(!is_valid_after(|cookie| { + if let ParentRecord::Ppid { session, .. } = &mut cookie.parent_record { + *session += 1; + } + })); + } + + /// The cookie types of 4.1.0, whose PPID scope has no session. + mod v4_1_0 { + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug)] + pub enum CookieVersion { + V2(Cookiev2), + } + + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] + #[serde(rename_all = "UPPERCASE")] + pub enum ParentRecord { + Tty { + tty: u64, + session: i32, + session_start: u64, + }, + Ppid { + ppid: i32, + ppid_start: u64, + }, + None, + } + + #[derive(Serialize, Deserialize, Debug)] + pub struct Cookiev2 { + pub timestamp_type: rootasrole_core::database::options::TimestampType, + pub start_time: i64, + pub timestamp: i64, + pub usage: u64, + pub parent_record: ParentRecord, + pub auth_uid: u32, + } + } + + fn ppid_constraint() -> STimeout { + STimeout { + type_field: Some(TimestampType::PPID), + duration: Some(SignedDuration::from_secs(10)), + max_usage: None, + extra_fields: Map::default(), + } + } + + fn root_cred() -> Cred { + Cred { + user: User::from_uid(0.into()).unwrap().unwrap(), + curdir: "".into(), + groups: vec![], + tty: None, + ppid: Pid::parent(), + } + } + + #[test] + fn test_cookie_of_4_1_0_is_not_reused() { + let _guard = MUTEX.lock().unwrap(); + setup(); + let cred = root_cred(); + let constraint = ppid_constraint(); + clear_cookies(&cred).unwrap(); + let old = v4_1_0::CookieVersion::V2(v4_1_0::Cookiev2 { + timestamp_type: TimestampType::PPID, + start_time: boottime().unwrap(), + timestamp: boottime().unwrap(), + usage: 0, + parent_record: v4_1_0::ParentRecord::Ppid { + ppid: cred.ppid.as_raw(), + ppid_start: procfs::start_time(cred.ppid).unwrap(), + }, + auth_uid: 0, + }); + { + let mut file = CookieFile::open(&cred).unwrap(); + file.0.set_len(0).unwrap(); + cbor4ii::serde::to_writer(&mut *file.0, &[old]).unwrap(); + } + // a cookie without the session asks for the password again, then a new one is saved + assert!(!is_valid(&cred, &cred, &constraint)); + update_cookie(&cred, &cred, &constraint).unwrap(); + assert!(is_valid(&cred, &cred, &constraint)); + clear_cookies(&cred).unwrap(); + } + + #[test] + fn test_cookie_is_read_by_4_1_0() { + let _guard = MUTEX.lock().unwrap(); + setup(); + let cred = root_cred(); + let constraint = ppid_constraint(); + clear_cookies(&cred).unwrap(); + update_cookie(&cred, &cred, &constraint).unwrap(); + let cookies: Vec = { + let file = CookieFile::open(&cred).unwrap(); + cbor4ii::serde::from_reader(BufReader::new(&*file.0)).unwrap() + }; + clear_cookies(&cred).unwrap(); + // after a downgrade, 4.1.0 ignores the session and keeps its own scope + let [v4_1_0::CookieVersion::V2(cookie)] = cookies.as_slice() else { + panic!("one cookie expected: {cookies:?}"); + }; + assert_eq!( + cookie.parent_record, + v4_1_0::ParentRecord::Ppid { + ppid: cred.ppid.as_raw(), + ppid_start: procfs::start_time(cred.ppid).unwrap(), + } + ); + } + #[test] fn test_cookie_from_the_future_is_rejected() { let _guard = MUTEX.lock().unwrap(); @@ -581,6 +757,10 @@ mod test { ppid: Pid::parent(), }; let ppid_record = ParentRecord::new(TimestampType::PPID, &cred).unwrap(); + assert!(matches!( + ppid_record, + ParentRecord::Ppid { session, .. } if session == getsid(None).unwrap().as_raw() + )); assert_eq!( ParentRecord::new(TimestampType::TTY, &cred).unwrap(), ppid_record diff --git a/tests/capable/centos8/Vagrantfile b/tests/capable/centos8/Vagrantfile deleted file mode 100644 index ce42ed96..00000000 --- a/tests/capable/centos8/Vagrantfile +++ /dev/null @@ -1,17 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "generic/centos8" - - config.vm.provision "shell", inline: <<-SHELL - sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-*; - sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-* - yum clean all - yum install -y git make - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - make install; - dosr capable -j cat /etc/shadow;' - SHELL - end - \ No newline at end of file diff --git a/tests/capable/debian11/Vagrantfile b/tests/capable/debian11/Vagrantfile deleted file mode 100644 index fb34c0d9..00000000 --- a/tests/capable/debian11/Vagrantfile +++ /dev/null @@ -1,28 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "debian/bullseye64" - config.vm.synced_folder '.', '/vagrant', disabled: true - - config.vm.provider "libvirt" do |vb| - vb.memory = "2048" - end - - config.vm.provision "shell", inline: <<-SHELL - apt-get update - apt-get install -y git build-essential - apt-get upgrade -y - SHELL - config.vm.provision :shell do |shell| - shell.privileged = true - shell.inline = 'echo rebooting' - shell.reboot = true - end - config.vm.provision "shell", inline: <<-SHELL - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - PROFILE=debug make install; - dosr capable -j cat /etc/shadow;' - SHELL -end - \ No newline at end of file diff --git a/tests/capable/fedora37/Vagrantfile b/tests/capable/fedora37/Vagrantfile deleted file mode 100644 index 48b5d7d6..00000000 --- a/tests/capable/fedora37/Vagrantfile +++ /dev/null @@ -1,14 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "generic/fedora37" - - config.vm.provision "shell", inline: <<-SHELL - dnf install -y git gcc make - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - PROFILE=debug make install; - dosr capable -j cat /etc/shadow;' - SHELL - end - \ No newline at end of file diff --git a/tests/capable/rh9/Vagrantfile b/tests/capable/rh9/Vagrantfile deleted file mode 100644 index d4d0f08f..00000000 --- a/tests/capable/rh9/Vagrantfile +++ /dev/null @@ -1,51 +0,0 @@ -Vagrant.require_version ">= 2.1.0" # 2.1.0 minimum required for triggers - -user = ENV['RH_USER'] -password = ENV['RH_PW'] -pool_id = ENV['RH_POOL'] -if !user or !password - puts 'Required environment variables not found. Please set RH_USER and RH_PW' - abort -end - - -register_script = %{ -if ! subscription-manager status; then - sudo subscription-manager register --username="#{user}" --password="#{password}" - sudo subscription-manager attach --pool="#{pool_id}" -fi -} - -unregister_script = %{ -if subscription-manager status; then - sudo subscription-manager unregister -fi -} - -Vagrant.configure("2") do |config| - config.vm.box = "generic/rhel9" - # Disable guest additions check, because at this point the VM - # will not be registered with RHEL via subsctiption-manager - # and yum install will not work. - - - config.vm.provision "shell", inline: register_script - - config.vm.provision "shell", inline: <<-SHELL - yum install -y git gcc make - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - PROFILE=debug make install; - dosr capable -j cat /etc/shadow;' - SHELL - - config.trigger.before :destroy do |trigger| - trigger.name = "Before Destroy trigger" - trigger.info = "Unregistering this VM from RedHat Subscription Manager..." - trigger.warn = "If this fails, unregister VMs manually at https://access.redhat.com/management/subscriptions" - trigger.run_remote = {inline: unregister_script} - trigger.on_error = :continue - end # trigger.before :destroy -end # vagrant configure \ No newline at end of file diff --git a/tests/capable/ubuntu2204/Vagrantfile b/tests/capable/ubuntu2204/Vagrantfile deleted file mode 100644 index 78e25ace..00000000 --- a/tests/capable/ubuntu2204/Vagrantfile +++ /dev/null @@ -1,16 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "generic/ubuntu2204" - - config.vm.provision "shell", inline: <<-SHELL - apt-get update - apt-get install -y git build-essential - sudo -u vagrant sh -c 'git clone -b develop https://github.com/LeChatP/RootAsRole; - cd RootAsRole; - ./dependencies.sh -y; - sudo ./configure.sh -y; - PROFILE=debug make install; - dosr chsr role r_root task t_capable o env set RUST_LOG=debug - dosr capable -j cat /etc/shadow;' - SHELL - end - \ No newline at end of file diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 450bb683..b1a573ad 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -44,6 +44,44 @@ mod tests { assert_eq!(result.exit_code, 0); } + /// Nobody reads the output anymore (`dosr --help | true`): writing gets EPIPE, since Rust + /// ignores SIGPIPE, and dosr must not panic. + #[test] + fn test_dosr_output_to_a_closed_pipe() { + let (reader, writer) = std::io::pipe().expect("Failed to create a pipe"); + drop(reader); + for arg in ["--help", "--version"] { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_dosr")) + .arg(arg) + .stdout(writer.try_clone().expect("Failed to clone the pipe")) + .output() + .expect("Failed to run dosr"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(0), "dosr {arg}: {stderr}"); + assert!(!stderr.contains("panicked"), "dosr {arg}: {stderr}"); + } + } + + /// chsr prints its usage without reading the policy, so without any privilege. + #[test] + fn test_chsr_help_without_the_policy() { + // set even when chsr is not built (without the feature editor) + let Some(chsr) = + option_env!("CARGO_BIN_EXE_chsr").filter(|chsr| std::path::Path::new(chsr).exists()) + else { + eprintln!("Skipping: chsr is not built (feature editor)"); + return; + }; + let output = std::process::Command::new(chsr) + .arg("--help") + .output() + .expect("Failed to run chsr"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(0), "{stderr}"); + assert!(!stderr.contains("panicked"), "{stderr}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("chsr")); + } + #[test] fn test_all_dosr() { if let Ok(r) = try_run_dosr( diff --git a/tests/vagrant/Vagrantfile b/tests/vagrant/Vagrantfile index 275dbff3..56c8b059 100644 --- a/tests/vagrant/Vagrantfile +++ b/tests/vagrant/Vagrantfile @@ -12,11 +12,16 @@ # vagrant destroy -f # # Machines: debian (the distribution of the official package) and arch (the AUR package dosr). -# Their boxes can be replaced with RAR_BOX_DEBIAN and RAR_BOX_ARCH. +# Their boxes can be replaced with RAR_BOX_DEBIAN and RAR_BOX_ARCH. archlinux/archlinux publishes +# no box for libvirt anymore, so arch uses generic/arch, which is older than the repositories: the +# first `vagrant up arch` installs rsync without upgrading the system, which breaks rsync. Then: +# +# vagrant ssh arch -c 'sudo pacman -Sy --noconfirm archlinux-keyring && sudo pacman -Syu --noconfirm' +# vagrant reload arch --provision BOXES = { "debian" => ENV.fetch("RAR_BOX_DEBIAN", "debian/testing64"), - "arch" => ENV.fetch("RAR_BOX_ARCH", "archlinux/archlinux"), + "arch" => ENV.fetch("RAR_BOX_ARCH", "generic/arch"), }.freeze Vagrant.configure("2") do |config| diff --git a/tests/vagrant/e2e.sh b/tests/vagrant/e2e.sh index 03903b66..aa6d34bd 100755 --- a/tests/vagrant/e2e.sh +++ b/tests/vagrant/e2e.sh @@ -9,7 +9,15 @@ POLICY_DIR=/etc/security/rootasrole.d E2E_POLICY="$POLICY_DIR/e2e.json" PLANTED_POLICY="$POLICY_DIR/planted.json" TEST_USER=rar-e2e +TEST_PASSWORD=rar-e2e-password UNIT=rar-e2e +PAM_SERVICE=/etc/pam.d/dosr +PAM_SERVICE_BACKUP=/etc/pam.d/.dosr.rar-e2e +PAM_ENV_FILE=/etc/rar-e2e.env +BIG_GROUP=rar-e2e-big +GROUP_BACKUP=/etc/.group.rar-e2e +GSHADOW_BACKUP=/etc/.gshadow.rar-e2e +TTY_SCRIPT=/tmp/rar-e2e-tty.sh FAILED=0 # 0: pass, 77: skip, anything else: fail @@ -64,9 +72,13 @@ setup() { printf '%s\n' "[Unit]" "Description=RootAsRole end-to-end test unit" \ "[Service]" "ExecStart=/bin/sleep infinity" >"/etc/systemd/system/$UNIT.service" systemctl daemon-reload + # the version of the installed policy, without which chsr does not edit the file + local version + version=$(sed -n 's/^ *"version": *"\([^"]*\)".*/\1/p' "$ROOT_FILE" | head -n 1) write_policy "$E2E_POLICY" "$( cat </dev/null rm -f "/etc/systemd/system/$UNIT.service" systemctl daemon-reload remove_policy "$PLANTED_POLICY" remove_policy "$E2E_POLICY" + rm -f "$TTY_SCRIPT" userdel -r "$TEST_USER" 2>/dev/null } +# the installed binaries are files of root, not shared with the build tree (one link), and dosr +# has its file capabilities +installed_binaries_belong_to_root() { + [ "$(stat -c '%u %h' /usr/bin/dosr)" = "0 1" ] && + [ "$(stat -c '%u %h' /usr/bin/chsr)" = "0 1" ] && + getcap /usr/bin/dosr | grep -q '=p' +} + # the installation sets the immutable flag on the policy, its directory included policy_is_immutable() { immutable_policy || return 77 @@ -184,8 +277,247 @@ bounding_set_is_checked_first() { [ $? -ne 0 ] && grep -q 'cannot obtain' <<<"$out" } +# the variables that PAM modules set reach the command through the env filter of the task, and +# never replace the variables of dosr nor the ones of the user that the task keeps; pam_env is +# added for this check, since the PAM service of Debian does not run it +pam_env_is_filtered() { + local out user_out + cp -p "$PAM_SERVICE" "$PAM_SERVICE_BACKUP" || return 1 + printf '%s\n' RAR_E2E_KEPT=pam RAR_E2E_DROPPED=pam RAR_E2E_CHECKED=/unsafe PATH=/tmp/rar-e2e \ + >"$PAM_ENV_FILE" + echo "session optional pam_env.so readenv=1 envfile=$PAM_ENV_FILE user_readenv=0" \ + >>"$PAM_SERVICE" + out=$(as_user dosr -r e2e -t env /usr/bin/env) + user_out=$(as_user env RAR_E2E_KEPT=user dosr -r e2e -t env /usr/bin/env) + restore_pam_service + grep -qx RAR_E2E_KEPT=pam <<<"$out" && + ! grep -q '^RAR_E2E_DROPPED=' <<<"$out" && + ! grep -q '^RAR_E2E_CHECKED=' <<<"$out" && + ! grep -qx PATH=/tmp/rar-e2e <<<"$out" && + grep -qx RAR_E2E_KEPT=user <<<"$user_out" +} + +# a fresh authentication: no timestamp cookie, no failure counted by pam_faillock (Arch) +forget_authentications() { + as_user dosr -K >/dev/null 2>&1 + if command -v faillock >/dev/null; then + faillock --user "$TEST_USER" --reset + fi +} + +# a wrong password is asked again, up to the tries of the policy; the cookie of a successful +# authentication would skip the password, so the refusal is checked first +password_is_asked_again() { + local out + echo "$TEST_USER:$TEST_PASSWORD" | chpasswd || return 1 + forget_authentications + if printf '%s\n' wrong wrong wrong "$TEST_PASSWORD" | + as_user dosr -S -r e2e -t password /usr/bin/true >/dev/null 2>&1; then + return 1 + fi + forget_authentications + out=$(printf '%s\n' wrong wrong "$TEST_PASSWORD" | + as_user dosr -S -r e2e -t password /usr/bin/true 2>&1) || return 1 + forget_authentications + [ "$(grep -c 'Sorry, try again.' <<<"$out")" -eq 2 ] +} + +# a task that skips the authentication (date) gives no cookie: the task password, run after it +# from the same shell (same parent and session, the scope of the cookie), still asks the +# password and refuses wrong ones +skipped_authentication_gives_no_cookie() { + echo "$TEST_USER:$TEST_PASSWORD" | chpasswd || return 1 + forget_authentications + if as_user sh -c "dosr -r e2e -t date /usr/bin/date >/dev/null && printf '%s\\n' wrong wrong wrong | dosr -S -r e2e -t password /usr/bin/true" >/dev/null 2>&1; then + forget_authentications + return 1 + fi + forget_authentications +} + +# a failed lookup of an actor disables the roles with a separation of duties: the members of +# a group take more than the 1 MiB that the lookups of dosr allow, getgrnam_r fails with ERANGE +ssd_role_disabled_by_a_failed_lookup() { + local gid before after members since + # not id -u, which planted_policy_is_refused checks: this role would be chosen for it + gid=$(id -g "$TEST_USER") + cp -p /etc/group "$GROUP_BACKUP" && cp -p /etc/gshadow "$GSHADOW_BACKUP" || return 1 + groupadd "$BIG_GROUP" || { + restore_groups + return 1 + } + before=$(as_user dosr -r e2e-guarded -t gid /usr/bin/id -g 2>/dev/null) + # one argument is limited to 128 KiB: the member list goes through a file + members=$(mktemp) + seq -f 'rar%07g' 1 120000 | paste -sd, >"$members" + awk -F: -v OFS=: -v group="$BIG_GROUP" -v list="$members" \ + 'BEGIN { getline all < list } $1 == group { $4 = all } { print }' \ + /etc/group >"$members.group" + cat "$members.group" >/etc/group + since=$(date +%s) + after=$(as_user dosr -r e2e-guarded -t gid /usr/bin/id -g 2>/dev/null) + rm -f "$members" "$members.group" + restore_groups + [ "$before" = "$gid" ] && [ "$after" != "$gid" ] || return 1 + # the refusal comes from the lookup, when the journal tells + if command -v journalctl >/dev/null; then + journalctl -q --since "@$since" | grep -q "Cannot look up the group $BIG_GROUP" + fi +} + +# with the policy directory of the installation, chsr edits default.json, and leaves the root +# file and the other policy files as they are +chsr_edits_the_default_file() { + local root_before e2e_before found + [ -f "$POLICY_DIR/default.json" ] || return 77 + root_before=$(sha256sum <"$ROOT_FILE") + e2e_before=$(sha256sum <"$E2E_POLICY") + chsr role rar-e2e-chsr create || return 1 + found=$(grep -c '"rar-e2e-chsr"' "$POLICY_DIR/default.json") + chsr role rar-e2e-chsr delete || return 1 + [ "$found" -ge 1 ] && + ! grep -q '"rar-e2e-chsr"' "$POLICY_DIR/default.json" && + [ "$(sha256sum <"$ROOT_FILE")" = "$root_before" ] && + [ "$(sha256sum <"$E2E_POLICY")" = "$e2e_before" ] && + chsr list | grep -q "$POLICY_DIR/default.json" +} + +# a regular expression of several words written with chsr is matched by dosr: chsr used to quote +# each of its words, and dosr never matched it +chsr_regex_is_matched_by_dosr() { + local typed='/usr/bin/echo ^chsr (one|two) [^ ]+$' result=1 + chsr file e2e.json r e2e t chsr-regex add || return 1 + if chsr file e2e.json r e2e t chsr-regex cmd wl add "$typed" && + grep -qF "\"$typed\"" "$E2E_POLICY" && + [ "$(as_user dosr -r e2e -t chsr-regex /usr/bin/echo chsr two words)" = "chsr two words" ] && + ! as_user dosr -r e2e -t chsr-regex /usr/bin/echo chsr three words >/dev/null 2>&1; then + result=0 + fi + chsr file e2e.json r e2e t chsr-regex del || return 1 + return "$result" +} + +# the executions are logged under the name dosr, no longer sr +dosr_logs_under_its_name() { + local since + since=$(date '+%Y-%m-%d %H:%M:%S') + sleep 1 + as_user dosr /usr/bin/date >/dev/null || return 1 + for _ in 1 2 3 4 5; do + if journalctl -t dosr --since "$since" -o cat | grep -q 'TASK=date ;.*COMMAND=/usr/bin/date'; then + ! journalctl -t sr --since "$since" -o cat | grep -q 'COMMAND=/usr/bin/date' + return + fi + sleep 1 + done + return 1 +} + +# chsr is confined by Landlock, even without vim: one missing optional path made the whole ruleset +# fail; root itself cannot make chsr read a file outside the policy +chsr_is_confined_by_landlock() { + local since out + since=$(date '+%Y-%m-%d %H:%M:%S') + sleep 1 + out=$(chsr convert --from json /etc/hostname json "$POLICY_DIR/rar-e2e-convert.json" 2>&1) + remove_policy "$POLICY_DIR/rar-e2e-convert.json" + if journalctl -t chsr --since "$since" -o cat | grep -q 'Failed to apply landlock' || + ! grep -qi 'permission denied' <<<"$out"; then + echo "$out" >&2 + return 1 + fi +} + +# the groups of id -G, sorted +sorted_groups() { + tr ' ' '\n' | sort -n | tr '\n' ' ' +} + +# a task that changes the user without setgid gives the groups of the target user, as a login +# does, not the ones of the caller +groups_of_the_target_user() { + local got expected + got=$(as_user dosr -r e2e -t target-groups /usr/bin/id -G | sorted_groups) + expected=$(id -G nobody | sorted_groups) + [ "$got" = "$expected" ] && [ "$got" != "$(as_user id -G | sorted_groups)" ] +} + +# "from": "caller" keeps the groups of the caller +groups_of_the_caller_with_from() { + local got + got=$(as_user dosr -r e2e -t caller-groups /usr/bin/id -G | sorted_groups) + [ "$got" = "$(as_user id -G | sorted_groups)" ] +} + +# a task whose group does not exist is left out, and the log says why: the group was dropped, so +# the command ran with the primary group of the caller +missing_group_leaves_the_task_out() { + local since + since=$(date '+%Y-%m-%d %H:%M:%S') + sleep 1 + as_user dosr -r e2e -t missing-group /usr/bin/whoami >/dev/null 2>&1 && return 1 + for _ in 1 2 3 4 5; do + journalctl -t dosr --since "$since" -o cat | + grep -q 'e2e/missing-group cannot run: the group rar-e2e-missing does not exist' && + return 0 + sleep 1 + done + return 1 +} + +# a top-level alternation is anchored on both sides: ^one|two$ allows one or two alone, no longer +# any arguments that start with one or end with two +alternation_is_anchored() { + [ "$(as_user dosr -r e2e -t alternation /usr/bin/echo one)" = "one" ] && + ! as_user dosr -r e2e -t alternation /usr/bin/echo one more >/dev/null 2>&1 && + ! as_user dosr -r e2e -t alternation /usr/bin/echo say two >/dev/null 2>&1 +} + +# -- ends the options of dosr +double_dash_ends_the_options() { + as_user dosr -r e2e -t date -- /usr/bin/date >/dev/null +} + +# dosr -i shows None for a task whose capabilities are an empty set +info_shows_no_capabilities_as_none() { + as_user dosr -i -r e2e -t service /usr/bin/systemctl start "$UNIT" | + grep -qx 'With capabilities: None' +} + +# the owner of the terminal of a task run as nobody through a terminal, and whether the command +# can reopen it by its name, as pinentry does through GPG_TTY +terminal_seen_by() { + printf '%s\n' 't=$(tty)' 'stat -c %U "$t"' \ + 'if { true <>"$t"; } 2>/dev/null; then echo reopened; else echo refused; fi' >"$TTY_SCRIPT" + chmod 644 "$TTY_SCRIPT" + # no input: script would copy into the terminal what is left on the input of the provisioner + as_user script -qec "dosr -r e2e -t $1 /usr/bin/sh $TTY_SCRIPT" /dev/null &2 + return 1 + } +} + +# "tty": {"owner": "target"} gives the terminal to the user that the command runs as +terminal_given_to_the_target() { + local out + out=$(terminal_seen_by tty-target) + [ "$out" = "$(printf '%s\n' nobody reopened)" ] || { + echo "$out" >&2 + return 1 + } +} + trap cleanup EXIT setup +check "the installed binaries belong to root, dosr has its capabilities" installed_binaries_belong_to_root check "the policy, its directory included, is immutable" policy_is_immutable check "a policy file without the immutable flag is refused" planted_policy_is_refused check "dosr ends like a command killed by a signal" signal_is_forwarded @@ -194,4 +526,20 @@ check "a command written without arguments allows none" command_without_argument check "uid 0 without capabilities manages a unit" root_without_capabilities_manages_a_unit check "CAP_NET_BIND_SERVICE is granted" capability_is_granted check "the bounding set is checked before running the command" bounding_set_is_checked_first +check "the variables of pam_env go through the env filter of the task" pam_env_is_filtered +check "a wrong password is asked again, up to the tries of the policy" password_is_asked_again +check "a task that skips the authentication gives no cookie" skipped_authentication_gives_no_cookie +check "a failed lookup of an actor disables the roles with ssd" ssd_role_disabled_by_a_failed_lookup +check "chsr edits default.json of the policy directory" chsr_edits_the_default_file +check "a regular expression written with chsr is matched by dosr" chsr_regex_is_matched_by_dosr +check "the executions are logged under the name dosr" dosr_logs_under_its_name +check "chsr is confined by Landlock" chsr_is_confined_by_landlock +check "-- ends the options of dosr" double_dash_ends_the_options +check "dosr -i shows None for a task without capabilities" info_shows_no_capabilities_as_none +check "a task that changes the user gives the groups of the target user" groups_of_the_target_user +check "from: caller keeps the groups of the caller" groups_of_the_caller_with_from +check "a task whose group does not exist is left out" missing_group_leaves_the_task_out +check "a top-level alternation is anchored on both sides" alternation_is_anchored +check "the terminal of a command run as another user stays with the caller" terminal_stays_with_the_caller +check "tty owner target gives the terminal to the user of the command" terminal_given_to_the_target exit "$FAILED" diff --git a/tests/vagrant/provision.sh b/tests/vagrant/provision.sh index 4929e5a0..dcf2272f 100755 --- a/tests/vagrant/provision.sh +++ b/tests/vagrant/provision.sh @@ -1,5 +1,6 @@ #!/bin/bash # Build and install the copied tree in the virtual machine, as root (see the Vagrantfile). +# xtask checks the packages to build RootAsRole and does not install them: they come from here. set -euo pipefail . /etc/os-release @@ -7,10 +8,12 @@ case "$ID" in debian | ubuntu) export DEBIAN_FRONTEND=noninteractive apt-get update - apt-get install -y build-essential curl git pkg-config e2fsprogs libcap2-bin python3 + apt-get install -y build-essential curl git pkg-config e2fsprogs libcap2-bin python3 \ + clang libpam0g-dev libseccomp-dev pandoc ;; arch) - pacman -Syu --noconfirm --needed base-devel curl git e2fsprogs libcap python + pacman -Syu --noconfirm --needed base-devel curl git e2fsprogs libcap python \ + clang pam libseccomp pandoc-cli ;; *) echo "provision.sh: unsupported distribution $ID" >&2 @@ -18,14 +21,18 @@ case "$ID" in ;; esac +# The scripts run as vagrant with -H, not -i: sudo -i escapes the newlines of a command, which +# the login shell then joins as line continuations, so only the first line ran. # the build needs a recent Rust, the one of the distribution may be too old -sudo -u vagrant -i bash -c ' +sudo -u vagrant -H bash -c ' + set -euo pipefail command -v "$HOME/.cargo/bin/cargo" >/dev/null || curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal ' -# the other dependencies come from xtask (-i), sudo is the privilege escalation it uses (-p) -sudo -u vagrant -i bash -c ' +# sudo is the privilege escalation that xtask uses (-p) +sudo -u vagrant -H bash -c ' + set -euo pipefail . "$HOME/.cargo/env" cd "$HOME/RootAsRole" - cargo xtask install -bip sudo + cargo xtask install -bp sudo ' diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 35cf0ac6..8a9dee9c 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -31,10 +31,9 @@ konst = { version= "0.4", default-features = false, features = ["parsing_proc", semver = { version = "1.0", default-features = false, features = ["std", "serde"] } [features] -default = ["cli", "ebpf", "deploy"] +default = ["cli", "ebpf"] cli = [] ebpf = [] -deploy = [] [lints.rust] dead-code = { level = "allow" } diff --git a/xtask/src/configure.rs b/xtask/src/configure.rs index b682b0db..65defc42 100644 --- a/xtask/src/configure.rs +++ b/xtask/src/configure.rs @@ -17,8 +17,8 @@ use crate::util::{ ENV_PATH_ADD_LIST_SLICE, ENV_PATH_BEHAVIOR, ENV_PATH_REMOVE_LIST_SLICE, ENV_SET_LIST, INFO, ImmutableLock, Opt, OsTarget, PACKAGE_VERSION, PAM_CONFIG_SERVICE, Policy, RAR_AUTHENTICATION, RAR_BOUNDING, RAR_CFG_DATA_PATH, RAR_CFG_IMMUTABLE, RAR_CFG_PATH, RAR_CFG_TYPE, - RAR_USER_CONSIDERED, RootSettings, SEnvOptions, SPathOptions, STimeout, SWorkdirSet, - TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, UMASK, WORKDIR_ADD_LIST_SLICE, + RAR_USER_CONSIDERED, RemoteStorageSettings, RootSettings, SEnvOptions, SPathOptions, STimeout, + SWorkdirSet, TIMEOUT_DURATION, TIMEOUT_MAX_USAGE, TIMEOUT_TYPE, UMASK, WORKDIR_ADD_LIST_SLICE, WORKDIR_BEHAVIOR, WORKDIR_FALLBACK, WORKDIR_REMOVE_LIST_SLICE, cap_effective, toggle_lock_config, }; @@ -65,11 +65,17 @@ pub fn deploy_config_file() -> Result { } fn set_immutable(config: &mut RootSettings, value: bool) { - if let Some(settings) = config.storage.settings.as_mut() - && let Some(mut _immutable) = settings.immutable - { - _immutable = value; - } + // written in the root file: without it, dosr and chsr take the build default (true), even + // on a file system without immutable flags; the value was set on a copy before + config + .storage + .settings + .get_or_insert_with(|| RemoteStorageSettings { + immutable: None, + path: None, + extra_fields: Value::Object(serde_json::Map::new()), + }) + .immutable = Some(value); if !value { let roles = config @@ -340,26 +346,36 @@ fn resolve_policy_path(base_path: &Path) -> PathBuf { base_path.to_path_buf() } } -pub fn pam_config(os: &OsTarget) -> std::io::Result { +/// The PAM services of each distribution, built into xtask: reading them at run time depended on +/// the working directory, which the privileged installer may not even be able to search. +pub const PAM_CONFIGS: &[&str] = &[ + include_str!("../../resources/debian/deb_sr_pam.conf"), + include_str!("../../resources/rh/rh_sr_pam.conf"), + include_str!("../../resources/opensuse/opensuse.conf"), + include_str!("../../resources/arch/arch_sr_pam.conf"), +]; + +#[must_use] +pub const fn pam_config(os: &OsTarget) -> &'static str { match os { - OsTarget::Debian | OsTarget::Ubuntu => { - std::fs::read_to_string("../../resources/debian/deb_sr_pam.conf") - } - OsTarget::RedHat | OsTarget::Fedora => { - std::fs::read_to_string("../../resources/rh/rh_sr_pam.conf") - } - OsTarget::OpenSUSE => std::fs::read_to_string("../../resources/opensuse/opensuse.conf"), - OsTarget::ArchLinux => std::fs::read_to_string("../../resources/arch/arch_sr_pam.conf"), + OsTarget::Debian | OsTarget::Ubuntu => PAM_CONFIGS[0], + OsTarget::RedHat | OsTarget::Fedora => PAM_CONFIGS[1], + OsTarget::OpenSUSE => PAM_CONFIGS[2], + OsTarget::ArchLinux => PAM_CONFIGS[3], } } +/// Deploy the PAM service of dosr when it is missing, or empty: an empty file is what a failed +/// installation left, it is not a configuration of the administrator. fn deploy_pam_config(os: &OsTarget) -> io::Result { - if fs::metadata(Path::new("/etc/pam.d").join(PAM_CONFIG_SERVICE)).is_err() { - info!("Deploying PAM configuration file"); - let mut pam_conf = File::create(Path::new("/etc/pam.d").join(PAM_CONFIG_SERVICE))?; - pam_conf.write_all(pam_config(os)?.as_bytes())?; - pam_conf.sync_all()?; + let path = Path::new("/etc/pam.d").join(PAM_CONFIG_SERVICE); + if fs::metadata(&path).is_ok_and(|meta| meta.len() > 0) { + return Ok(0); } + info!("Deploying PAM configuration file"); + let mut pam_conf = File::create(&path)?; + pam_conf.write_all(pam_config(os).as_bytes())?; + pam_conf.sync_all()?; Ok(0) } @@ -443,6 +459,14 @@ fn upgrade_policy() { mod tests { use super::*; + #[test] + fn test_pam_services_end_with_a_newline() { + // a line appended to /etc/pam.d/dosr would otherwise be glued to the last one + for config in PAM_CONFIGS { + assert!(config.ends_with('\n'), "{config:?}"); + } + } + #[test] fn test_default_policy_file_of_a_policy_directory() { assert_eq!( @@ -454,4 +478,16 @@ mod tests { PathBuf::from("/etc/security/rootasrole.json") ); } + + #[test] + fn test_set_immutable_writes_the_value() { + let template = include_str!("../../resources/rootasrole.json") + .replace("PACKAGE_VERSION", &PACKAGE_VERSION.to_string()); + for value in [true, false] { + let mut settings: RootSettings = serde_json::from_str(&template).unwrap(); + set_immutable(&mut settings, value); + let written = serde_json::to_value(&settings).unwrap(); + assert_eq!(written["storage"]["settings"]["immutable"], value); + } + } } diff --git a/xtask/src/deploy/debian.rs b/xtask/src/deploy/debian.rs deleted file mode 100644 index b3928537..00000000 --- a/xtask/src/deploy/debian.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::{ - fs::File, - io::{BufRead, Write}, - path::Path, - process::Command, -}; - -use anyhow::{Context, anyhow}; -use jiff::Zoned; -use log::debug; - -use crate::{ - installer::{self, InstallDependenciesOptions, Profile, dependencies::install_dependencies}, - util::{OsTarget, PACKAGE_VERSION, detect_priv_bin, get_os, output_checked, run_checked}, -}; - -use super::setup_maint_scripts; - -fn dependencies(os: &OsTarget, priv_bin: Option<&Path>) -> Result<(), anyhow::Error> { - install_dependencies(os, &["upx"], priv_bin) - .context("failed to install packaging dependencies")?; - run_checked( - Command::new("cargo").arg("install").arg("cargo-deb"), - "install cargo-deb", - ) - .context("failed to install cargo-deb") -} - -fn generate_changelog() -> Result<(), anyhow::Error> { - let changelog_path = "target/debian/changelog"; - if std::path::Path::new(changelog_path).exists() { - return Ok(()); - } - let binding = output_checked( - Command::new("git").args(["tag", "--sort=-creatordate"]), - "list git tags", - )?; - let mut ordered_tags = binding.stdout.lines(); - - let from = ordered_tags - .next() - .ok_or_else(|| anyhow!("No git tag found for changelog generation"))??; - - let to = ordered_tags - .next() - .ok_or_else(|| anyhow!("At least two git tags are required for changelog generation"))??; - - debug!("Generating changelog from {from} to {to}"); - - let changes = output_checked( - Command::new("git").args(["log", "--pretty=format: %s", &format!("{to}..{from}")]), - "collect changelog entries", - )?; - debug!( - "Changes: {}", - String::from_utf8(changes.stdout.clone()) - .expect("Failed to convert git log output to string") - ); - let changelog = format!( - r"rootasrole ({version}) {dist}; urgency={urgency} -{changes} - - -- Eddie Billoir {date} -", - version = PACKAGE_VERSION, - dist = "unstable", - urgency = "low", - changes = - String::from_utf8(changes.stdout).expect("Failed to convert git log output to string"), - date = Zoned::now().strftime("%a, %d %b %Y %T %z") - ); - File::create(changelog_path)?.write_all(changelog.as_bytes())?; - - Ok(()) -} - -pub fn make_deb( - os: Option<&OsTarget>, - profile: Profile, - priv_bin: Option<&Path>, -) -> Result<(), anyhow::Error> { - let os = get_os(os)?; - let priv_bin = priv_bin.map(Path::to_path_buf).or_else(detect_priv_bin); - dependencies(&os, priv_bin.as_deref())?; - - installer::dependencies(&InstallDependenciesOptions { - os: Some(os), - install_dependencies: true, - dev: true, - priv_bin: priv_bin.clone(), - })?; - installer::build(&installer::BuildOptions { - profile, - toolchain: installer::Toolchain::default(), - clean_before: false, - priv_bin, - })?; - setup_maint_scripts()?; - generate_changelog()?; - - run_checked( - Command::new("cargo").arg("deb").arg("--no-build"), - "generate deb package", - )?; - Ok(()) -} diff --git a/xtask/src/deploy/mod.rs b/xtask/src/deploy/mod.rs deleted file mode 100644 index d1fe547b..00000000 --- a/xtask/src/deploy/mod.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::{collections::HashSet, path::PathBuf, process::Command}; - -use clap::Parser; - -use crate::util::{is_dry_run, run_checked}; -use crate::{installer::Profile, util::OsTarget}; - -mod debian; -mod redhat; - -#[derive(Debug, Parser)] -pub struct MakeOptions { - /// Build the target with debug profile (default is release) - #[clap(short = 'd', long = "debug", default_value_t = Profile::Release, default_missing_value = "debug", num_args = 0)] - pub profile: Profile, - - /// The current OS where the binary is running - #[clap(long, short)] - pub os: Option, - - /// The OS target for package generation - pub target: Vec, - - /// The binary to elevate privileges - #[clap(long, short = 'p', visible_alias = "privbin")] - pub priv_bin: Option, -} - -fn all() -> HashSet { - vec![OsTarget::Debian, OsTarget::ArchLinux, OsTarget::RedHat] - .into_iter() - .collect() -} - -pub fn deploy(opts: &MakeOptions) -> Result<(), anyhow::Error> { - if is_dry_run() { - log::debug!("Dry-run mode: skipping deploy changes"); - return Ok(()); - } - - let targets = if opts.target.is_empty() { - all() - } else { - opts.target.iter().cloned().collect::>() - }; - - for target in targets { - match target { - OsTarget::Debian => { - debian::make_deb(opts.os.as_ref(), opts.profile, opts.priv_bin.as_deref())?; - } - OsTarget::RedHat => { - redhat::make_rpm(opts.os.as_ref(), opts.profile, opts.priv_bin.as_deref())?; - } - _ => anyhow::bail!("Unsupported OS target"), - } - } - - Ok(()) -} - -pub fn setup_maint_scripts() -> Result<(), anyhow::Error> { - run_checked( - Command::new("cargo") - .arg("build") - .arg("--package") - .arg("xtask") - .arg("--no-default-features") - .arg("--release") - .arg("--bin") - .arg("postinst") - .arg("--bin") - .arg("prerm"), - "build maintenance scripts", - )?; - compress("target/release/postinst")?; - compress("target/release/prerm") -} - -fn compress(script: &str) -> Result<(), anyhow::Error> { - run_checked( - Command::new("upx").arg("--best").arg("--lzma").arg(script), - &format!("compress script {script}"), - )?; - Ok(()) -} diff --git a/xtask/src/deploy/redhat.rs b/xtask/src/deploy/redhat.rs deleted file mode 100644 index c472fef9..00000000 --- a/xtask/src/deploy/redhat.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use crate::{ - installer::{self, InstallDependenciesOptions, Profile}, - util::{OsTarget, detect_priv_bin, get_os, run_checked}, -}; - -fn install_dependencies() -> Result<(), anyhow::Error> { - run_checked( - Command::new("cargo") - .arg("install") - .arg("cargo-generate-rpm"), - "install cargo-generate-rpm", - )?; - Ok(()) -} - -pub fn make_rpm( - os: Option<&OsTarget>, - profile: Profile, - exe: Option<&Path>, -) -> Result<(), anyhow::Error> { - install_dependencies()?; - let os = get_os(os)?; - let exe = exe.map(Path::to_path_buf).or_else(detect_priv_bin); - - installer::dependencies(&InstallDependenciesOptions { - os: Some(os), - install_dependencies: true, - dev: true, - priv_bin: exe.clone(), - })?; - installer::build(&installer::BuildOptions { - profile, - toolchain: installer::Toolchain::default(), - clean_before: false, - priv_bin: exe, - })?; - - run_checked( - Command::new("cargo").arg("generate-rpm"), - "generate rpm package", - )?; - Ok(()) -} diff --git a/xtask/src/doctor.rs b/xtask/src/doctor.rs index 101c5061..8c82c09a 100644 --- a/xtask/src/doctor.rs +++ b/xtask/src/doctor.rs @@ -1,12 +1,12 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, -}; +use std::fs; use clap::Parser; use log::{info, warn}; -use crate::util::{RAR_CFG_PATH, detect_priv_bin, get_os}; +use crate::{ + installer::dependencies, + util::{RAR_CFG_PATH, command_exists, detect_priv_bin, get_os}, +}; #[derive(Debug, Parser)] pub struct DoctorOptions { @@ -15,39 +15,6 @@ pub struct DoctorOptions { pub full: bool, } -fn command_exists(command: &str) -> bool { - let Some(path) = env::var_os("PATH") else { - return false; - }; - - for dir in env::split_paths(&path) { - let candidate: PathBuf = dir.join(command); - if is_executable(&candidate) { - return true; - } - } - - false -} - -fn is_executable(path: &Path) -> bool { - if let Ok(metadata) = fs::metadata(path) { - if !metadata.is_file() { - return false; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - return metadata.permissions().mode() & 0o111 != 0; - } - #[cfg(not(unix))] - { - return true; - } - } - false -} - pub fn doctor(opts: &DoctorOptions) -> Result<(), anyhow::Error> { let os = get_os(None)?; info!("Detected target OS: {os}"); @@ -66,8 +33,27 @@ pub fn doctor(opts: &DoctorOptions) -> Result<(), anyhow::Error> { } } + for (build, what) in [(false, "run"), (true, "build")] { + match dependencies::missing_packages(&os, build) { + Ok(missing) if missing.is_empty() => info!("Found the packages to {what} RootAsRole"), + Ok(missing) => warn!("{}", dependencies::missing_message(&os, build, &missing)?), + Err(e) => warn!("The packages to {what} RootAsRole cannot be checked: {e:#}"), + } + } + match dependencies::missing_optional(&os) { + Ok(missing) => { + for (package, purpose) in missing { + warn!( + "{}", + dependencies::optional_message(&os, &package, &purpose)? + ); + } + } + Err(e) => warn!("The optional packages cannot be checked: {e:#}"), + } + if opts.full { - for command in ["pandoc", "gzip", "upx"] { + for command in ["gzip", "upx"] { if command_exists(command) { info!("Found optional command: {command}"); } else { diff --git a/xtask/src/installer/build.rs b/xtask/src/installer/build.rs index 53cb3ccd..632fd7c7 100644 --- a/xtask/src/installer/build.rs +++ b/xtask/src/installer/build.rs @@ -1,10 +1,10 @@ use std::{fs, process::Command}; -use log::debug; +use log::{debug, warn}; use crate::{ installer::Toolchain, - util::{change_dir_to_project_root, run_checked}, + util::{change_dir_to_project_root, command_exists, run_checked}, }; use super::BuildOptions; @@ -45,9 +45,19 @@ pub fn build(options: &BuildOptions) -> Result<(), anyhow::Error> { Ok(()) } +/// The manual pages need pandoc and gzip: without them, they are left out, and the installation +/// installs none. fn build_manpages() -> Result<(), anyhow::Error> { debug!("Building manpages"); + // the pages of a former build are never installed instead of these ones let _ = fs::remove_dir_all("target/man/"); + if let Some(command) = ["pandoc", "gzip"] + .into_iter() + .find(|command| !command_exists(command)) + { + warn!("{command} is not installed: the manual pages are not built"); + return Ok(()); + } fs::create_dir_all("target/man/")?; run_checked( Command::new("pandoc").args([ diff --git a/xtask/src/installer/dependencies.rs b/xtask/src/installer/dependencies.rs index 4f764c66..a5c8f2bc 100644 --- a/xtask/src/installer/dependencies.rs +++ b/xtask/src/installer/dependencies.rs @@ -1,17 +1,14 @@ -use std::{borrow::Cow, collections::HashMap, path::Path, process::ExitStatus, sync::OnceLock}; +//! The packages that `RootAsRole` needs to build and to run. xtask checks them and prints the +//! command that installs the missing ones, it does not install them: like Rust, they are left +//! to the administrator. -use anyhow::Context; -use capctl::CapState; -use log::info; -use nix::unistd::geteuid; -use serde::Deserialize; +use std::{borrow::Cow, collections::HashMap, fmt, io, process::Command, sync::OnceLock}; -use crate::{ - installer::OsTarget, - util::{self, detect_priv_bin, get_os, path_exe_from_env, run_checked, status_checked}, -}; +use anyhow::{Context, anyhow}; +use log::warn; +use serde::Deserialize; -use super::InstallDependenciesOptions; +use crate::{installer::OsTarget, util}; #[derive(Debug, Deserialize)] struct DependenciesManifest<'a> { @@ -22,15 +19,115 @@ struct DependenciesManifest<'a> { struct TargetDependencies<'a> { #[serde(default)] aliases: Vec>, - package_manager: PackageManager<'a>, - runtime: Vec>, - development: Vec>, + /// How the installed packages are found + query: PackageQuery, + /// The command that installs packages, printed for the administrator, never run + install: Vec>, + runtime: Vec>, + development: Vec>, + /// Packages that only some steps of the build need: without them, these steps are left out + #[serde(default)] + optional: Vec>, } +/// A package that is needed, or several packages of which one is enough, like a C compiler. #[derive(Debug, Deserialize)] -struct PackageManager<'a> { - refresh: Vec>, - install: Vec>, +#[serde(untagged)] +enum Requirement<'a> { + Package(Cow<'a, str>), + AnyOf(Vec>), +} + +impl Requirement<'_> { + fn packages(&self) -> Vec<&str> { + match self { + Self::Package(package) => vec![package.as_ref()], + Self::AnyOf(packages) => packages.iter().map(AsRef::as_ref).collect(), + } + } +} + +/// A package that a step of the build needs, which is left out without it. +#[derive(Debug, Deserialize)] +struct OptionalPackage<'a> { + package: Cow<'a, str>, + /// The command that the step runs: it is looked for in `PATH`, whatever installed it + command: Cow<'a, str>, + /// What is not built without it + #[serde(rename = "for")] + purpose: Cow<'a, str>, +} + +/// A requirement that is not installed: the packages that would satisfy it, the first one being +/// the one to install. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Missing(Vec); + +impl Missing { + fn to_install(&self) -> &str { + self.0.first().map_or("", String::as_str) + } +} + +impl fmt::Display for Missing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0.join(" or ")) + } +} + +/// The `requirements` that none of their packages satisfy, according to `installed`. +fn missing_of( + requirements: &[Requirement<'_>], + installed: impl Fn(&str) -> io::Result, +) -> io::Result> { + let mut missing = Vec::new(); + for requirement in requirements { + let packages = requirement.packages(); + let mut satisfied = false; + for package in &packages { + if installed(package)? { + satisfied = true; + break; + } + } + if !satisfied { + missing.push(Missing(packages.iter().map(ToString::to_string).collect())); + } + } + Ok(missing) +} + +/// How a distribution tells whether a package is installed. The queries change nothing. +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum PackageQuery { + Dpkg, + Rpm, + Pacman, +} + +impl PackageQuery { + fn is_installed(self, package: &str) -> io::Result { + let output = match self { + // one line for each architecture of a multiarch package + Self::Dpkg => Command::new("dpkg-query") + .args(["-W", "-f=${db:Status-Status}\\n", package]) + .output()?, + // a capability as well as a package name: pkgconf-pkg-config provides pkgconfig + Self::Rpm => Command::new("rpm") + .args(["-q", "--quiet", "--whatprovides", package]) + .output()?, + // a package that another one provides is satisfied too: pkgconf provides pkg-config + Self::Pacman => Command::new("pacman").args(["-T", package]).output()?, + }; + // a removed package keeps its configuration files: dpkg-query finds it, not installed + Ok(output.status.success() + && (self != Self::Dpkg + || output + .stdout + .split(|&byte| byte == b'\n') + .any(|line| line == b"installed"))) + } } fn dependencies_manifest() -> Result<&'static DependenciesManifest<'static>, anyhow::Error> { @@ -119,144 +216,259 @@ fn resolve_target<'a>( .ok_or_else(|| anyhow::anyhow!("Unsupported OS target in deps.json: {key}")) } -fn compose_command( - priv_bin: Option<&Path>, - base: &[Cow<'_, str>], -) -> Result, anyhow::Error> { - if base.is_empty() { - return Err(anyhow::anyhow!( - "Invalid package-manager command in deps.json" - )); - } - - let mut command = Vec::new(); - if is_priv_bin_necessary()? { - if let Some(priv_bin) = priv_bin { - if util::is_su_command(priv_bin) { - let shell_command = base - .iter() - .map(|arg| shell_quote(arg.as_ref())) - .collect::>() - .join(" "); - command.push(priv_bin.to_string_lossy().into_owned()); - command.push("-c".to_string()); - command.push(shell_command); - return Ok(command); - } else if util::is_run0_command(priv_bin) { - command.push(priv_bin.to_string_lossy().into_owned()); - command.push("--pipe".to_string()); - command.extend(base.iter().map(|s| s.as_ref().to_string())); - return Ok(command); - } - command.push(priv_bin.to_string_lossy().into_owned()); - } else { - return Err(anyhow::anyhow!("Privileged binary is required")); - } - } - command.extend(base.iter().map(|s| s.as_ref().to_string())); - Ok(command) +/// The packages that `os` needs to build `RootAsRole` (`build`), or to run it, and that are not +/// installed. +/// +/// # Errors +/// When the OS is not in the manifest, or its package database cannot be queried. +pub fn missing_packages(os: &OsTarget, build: bool) -> Result, anyhow::Error> { + let target = resolve_target(dependencies_manifest()?, os)?; + let requirements = if build { + &target.development + } else { + &target.runtime + }; + missing_of(requirements, |package| target.query.is_installed(package)) + .with_context(|| format!("Cannot query the packages of {os}")) } -fn shell_quote(arg: &str) -> String { - if arg - .chars() - .all(|c| c.is_ascii_alphanumeric() || "@%_+=:,./-".contains(c)) - { - arg.to_string() - } else { - format!("'{}'", arg.replace('\'', "'\\''")) - } +/// The optional packages of the build on `os` whose command is not found, with what is not built +/// without them. +/// +/// # Errors +/// When the OS is not in the manifest. +pub fn missing_optional(os: &OsTarget) -> Result, anyhow::Error> { + let target = resolve_target(dependencies_manifest()?, os)?; + Ok(target + .optional + .iter() + .filter(|optional| !util::command_exists(&optional.command)) + .map(|optional| (optional.package.to_string(), optional.purpose.to_string())) + .collect()) } -fn update_package_manager(os: &OsTarget, priv_bin: Option<&Path>) -> Result<(), anyhow::Error> { - let manifest = dependencies_manifest()?; - let target = resolve_target(manifest, os)?; - let command = compose_command(priv_bin, target.package_manager.refresh.as_slice())?; - log::info!( - "Updating package manager with command: {}", - command.join(" ") - ); - run_checked( - std::process::Command::new(&command[0]).args(&command[1..]), - "update package manager", - ) - .context("Failed to update package manager")?; +/// What is not built without the optional `package`, and the command that installs it. +/// +/// # Errors +/// When the OS is not in the manifest. +pub fn optional_message( + os: &OsTarget, + package: &str, + purpose: &str, +) -> Result { + Ok(format!( + "{package} is not installed: {purpose} are not built. To build them, install it, as root: {}", + install_command(os, &[package.to_string()])? + )) +} - Ok(()) +/// The command that installs `packages` on `os`, to print for the administrator. +/// +/// # Errors +/// When the OS is not in the manifest. +pub fn install_command(os: &OsTarget, packages: &[String]) -> Result { + let target = resolve_target(dependencies_manifest()?, os)?; + Ok(target + .install + .iter() + .map(AsRef::as_ref) + .chain(packages.iter().map(String::as_str)) + .collect::>() + .join(" ")) } -fn get_dependencies<'a>( - manifest: &'a DependenciesManifest<'a>, +/// The `missing` packages to build `RootAsRole` (`build`), or to run it, with the command that +/// installs them. +/// +/// # Errors +/// When the OS is not in the manifest. +pub fn missing_message( os: &OsTarget, - dev: bool, -) -> Result<&'a [Cow<'a, str>], anyhow::Error> { - let target = resolve_target(manifest, os)?; - if dev { - Ok(target.development.as_slice()) + build: bool, + missing: &[Missing], +) -> Result { + let to_install: Vec = missing + .iter() + .map(|missing| missing.to_install().to_string()) + .collect(); + Ok(format!( + "Missing packages to {} RootAsRole on {os}: {}. Install them, as root: {}", + if build { "build" } else { "run" }, + missing + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "), + install_command(os, &to_install)? + )) +} + +/// What to do with the `missing` packages: stop with the command that installs them, or only +/// warn in a dry run. +fn report_missing(os: &OsTarget, build: bool, missing: &[Missing]) -> Result<(), anyhow::Error> { + if missing.is_empty() { + return Ok(()); + } + let message = missing_message(os, build, missing)?; + if util::is_dry_run() { + warn!("{message}"); + Ok(()) } else { - Ok(target.runtime.as_slice()) + Err(anyhow!(message)) } } -fn is_priv_bin_necessary() -> Result { - if geteuid().is_root() { - // as long root own files/folders, it should not need capabilities. - return Ok(false); +/// Check the packages that `os` needs to build `RootAsRole` (`build`), or to run it. A system +/// whose packages cannot be queried is only warned about: the build tells then. A missing optional +/// package of the build is only warned about. +/// +/// # Errors +/// When packages are missing, with the command that installs them. +pub fn check(os: &OsTarget, build: bool) -> Result<(), anyhow::Error> { + match missing_packages(os, build) { + Ok(missing) => report_missing(os, build, &missing)?, + Err(e) => warn!("The packages of {os} cannot be checked: {e:#}"), } - let mut state = CapState::get_current()?; - if state.permitted.has(capctl::Cap::DAC_OVERRIDE) - && !state.effective.has(capctl::Cap::DAC_OVERRIDE) - { - state.effective.add(capctl::Cap::DAC_OVERRIDE); - state.set_current()?; - Ok(false) - } else { - Ok(true) + if build { + for (package, purpose) in missing_optional(os)? { + warn!("{}", optional_message(os, &package, &purpose)?); + } } + Ok(()) } -pub fn install_dependencies( - os: &OsTarget, - deps: &[&str], - priv_bin: Option<&Path>, -) -> Result { - let manifest = dependencies_manifest()?; - let target = resolve_target(manifest, os)?; +#[cfg(test)] +mod tests { + use super::*; + use crate::util::{DRY_RUN_TEST_LOCK, set_dry_run}; - let mut command = compose_command(priv_bin, target.package_manager.install.as_slice())?; - command.extend(deps.iter().map(std::string::ToString::to_string)); + #[test] + fn test_every_target_has_its_packages() { + let manifest = dependencies_manifest().unwrap(); + for os in [ + OsTarget::Debian, + OsTarget::Ubuntu, + OsTarget::RedHat, + OsTarget::Fedora, + OsTarget::OpenSUSE, + OsTarget::ArchLinux, + ] { + let target = resolve_target(manifest, &os).unwrap(); + assert!(!target.install.is_empty(), "{os}"); + assert!(!target.runtime.is_empty(), "{os}"); + assert!(!target.development.is_empty(), "{os}"); + // a C compiler, clang or gcc, links the binaries + assert!( + target + .development + .iter() + .any(|requirement| requirement.packages() == ["clang", "gcc"]), + "{os}" + ); + // pandoc only builds the manual pages + assert!( + target + .optional + .iter() + .any(|optional| optional.command == "pandoc"), + "{os}" + ); + } + assert_eq!( + install_command(&OsTarget::Debian, &["clang".into(), "pandoc".into()]).unwrap(), + "apt-get install clang pandoc" + ); + } - status_checked( - std::process::Command::new(&command[0]).args(&command[1..]), - "install required packages", - ) -} + #[test] + fn test_a_package_that_does_not_exist_is_missing() { + for query in [PackageQuery::Dpkg, PackageQuery::Rpm, PackageQuery::Pacman] { + match query.is_installed("rar-no-such-package") { + Ok(installed) => assert!(!installed, "{query:?}"), + // not the package manager of this system + Err(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound, "{query:?}"), + } + } + } -pub fn install(opts: &InstallDependenciesOptions) -> Result<(), anyhow::Error> { - let os = get_os(opts.os.as_ref())?; - let priv_bin = opts - .priv_bin - .clone() - .or_else(detect_priv_bin) - .and_then(|bin| { - path_exe_from_env( - &std::env::var_os("PATH") - .unwrap_or_default() - .to_string_lossy() - .split(':') - .collect::>(), - bin, - ) - }); - update_package_manager(&os, priv_bin.as_deref())?; - // dependencies are : libpam and libseccomp - info!("Installing dependencies: libpam.so and libseccomp.so for running the application"); + #[test] + fn test_the_package_manager_of_this_system_is_installed() { + // the query of another system may be installed as a tool, without a package database + let Ok(os) = util::get_os(None) else { + return; + }; + let target = resolve_target(dependencies_manifest().unwrap(), &os).unwrap(); + let itself = match target.query { + PackageQuery::Dpkg => "dpkg", + PackageQuery::Rpm => "rpm", + PackageQuery::Pacman => "pacman", + }; + assert!(target.query.is_installed(itself).unwrap(), "{os}"); + } - let manifest = dependencies_manifest()?; - let deps = get_dependencies(manifest, &os, opts.dev)?; - let deps: Vec<&str> = deps.iter().map(std::convert::AsRef::as_ref).collect(); - install_dependencies(&os, &deps, priv_bin.as_deref())?; + #[test] + fn test_one_package_of_a_group_is_enough() { + let requirements: Vec = + serde_json::from_str(r#"[["clang", "gcc"], "pkg-config"]"#).unwrap(); + let only = |names: &'static [&'static str]| { + move |package: &str| Ok::<_, io::Error>(names.contains(&package)) + }; + assert!( + missing_of(&requirements, only(&["gcc", "pkg-config"])) + .unwrap() + .is_empty() + ); + assert!( + missing_of(&requirements, only(&["clang", "pkg-config"])) + .unwrap() + .is_empty() + ); + assert_eq!( + missing_of(&requirements, only(&[])).unwrap(), + [ + Missing(vec!["clang".into(), "gcc".into()]), + Missing(vec!["pkg-config".into()]) + ] + ); + // a query that fails is not taken as a missing package + assert!(missing_of(&requirements, |_| Err(io::Error::other("no database"))).is_err()); + } - info!("Dependencies installed successfully"); - Ok(()) + #[test] + fn test_an_optional_package_is_found_by_its_command() { + assert!(util::command_exists("sh")); + assert!(!util::command_exists("rar-no-such-command")); + } + + #[test] + fn test_optional_package_message() { + let message = optional_message(&OsTarget::Debian, "pandoc", "the manual pages").unwrap(); + assert_eq!( + message, + "pandoc is not installed: the manual pages are not built. To build them, install it, as root: apt-get install pandoc" + ); + } + + #[test] + fn test_missing_packages_stop_unless_dry_run() { + let _guard = DRY_RUN_TEST_LOCK.lock().unwrap(); + let missing = [ + Missing(vec!["clang".into(), "gcc".into()]), + Missing(vec!["pkg-config".into()]), + ]; + set_dry_run(false); + let error = report_missing(&OsTarget::Debian, true, &missing) + .unwrap_err() + .to_string(); + // the group is named whole, and its first package is the one to install + assert!(error.contains("clang or gcc, pkg-config"), "{error}"); + assert!( + error.contains("apt-get install clang pkg-config"), + "{error}" + ); + assert!(report_missing(&OsTarget::Debian, true, &[]).is_ok()); + set_dry_run(true); + assert!(report_missing(&OsTarget::Debian, true, &missing).is_ok()); + set_dry_run(false); + } } diff --git a/xtask/src/installer/deps.json b/xtask/src/installer/deps.json index 04cf06ee..de302ebd 100644 --- a/xtask/src/installer/deps.json +++ b/xtask/src/installer/deps.json @@ -2,56 +2,35 @@ "targets": { "debian": { "aliases": ["ubuntu"], - "package_manager": { - "refresh": ["apt-get", "update"], - "install": ["apt-get", "install", "-y"] - }, - "runtime": ["libpam0g", "libseccomp-dev"], - "development": [ - "libpam0g-dev", - "libclang-dev", - "pandoc", - "libseccomp-dev" - ] + "query": "dpkg", + "install": ["apt-get", "install"], + "runtime": ["libpam0g", "libseccomp2"], + "development": [["clang", "gcc"], "pkg-config", "libpam0g-dev", "libseccomp-dev"], + "optional": [{ "package": "pandoc", "command": "pandoc", "for": "the manual pages" }] }, "fedora": { "aliases": ["rockylinux", "rocky", "centos", "almalinux", "rhel", "redhat"], - "package_manager": { - "refresh": ["dnf", "update", "-y"], - "install": ["dnf", "install", "-y"] - }, + "query": "rpm", + "install": ["dnf", "install"], "runtime": ["pam", "libseccomp"], - "development": [ - "clang-devel", - "openssl-devel", - "pam-devel", - "pandoc", - "libseccomp-devel" - ] + "development": [["clang", "gcc"], "pkgconfig", "pam-devel", "libseccomp-devel"], + "optional": [{ "package": "pandoc", "command": "pandoc", "for": "the manual pages" }] }, "opensuse": { "aliases": ["suse"], - "package_manager": { - "refresh": ["zypper", "refresh"], - "install": ["zypper", "install", "-y"] - }, - "runtime": ["pam", "libseccomp"], - "development": [ - "clang-devel", - "libopenssl-devel", - "pam-devel", - "pandoc", - "libseccomp-devel" - ] + "query": "rpm", + "install": ["zypper", "install"], + "runtime": ["pam", "libseccomp2"], + "development": [["clang", "gcc"], "pkg-config", "pam-devel", "libseccomp-devel"], + "optional": [{ "package": "pandoc", "command": "pandoc", "for": "the manual pages" }] }, "archlinux": { "aliases": ["arch"], - "package_manager": { - "refresh": ["pacman", "-Syu"], - "install": ["pacman", "-Syu", "--noconfirm"] - }, + "query": "pacman", + "install": ["pacman", "-S", "--needed"], "runtime": ["pam", "libseccomp"], - "development": ["clang", "pkg-config", "pandoc", "libseccomp"] + "development": [["clang", "gcc"], "pkgconf", "pam", "libseccomp"], + "optional": [{ "package": "pandoc-cli", "command": "pandoc", "for": "the manual pages" }] } } } diff --git a/xtask/src/installer/install.rs b/xtask/src/installer/install.rs index a8ea1a9b..f1c5cbf5 100644 --- a/xtask/src/installer/install.rs +++ b/xtask/src/installer/install.rs @@ -1,6 +1,7 @@ use std::env::{self, current_exe, set_current_dir}; use std::fs::{self, File}; use std::io; +use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -31,6 +32,35 @@ fn shell_quote(arg: &str) -> String { } } +/// Install `source` at `dest` as a new file of root: a copy, never the build artifact itself. +/// The artifact belongs to the user who built it, and cargo keeps it linked in `target/`: once +/// renamed to `dest`, the installed binary was the same file as one of the build tree, so a +/// `chown -R` of the tree gave it to the user and removed its capabilities, and the user owned +/// it until the installer changed its owner. The copy is made beside `dest` then renamed over +/// it, so that a running binary is never overwritten. +fn install_file(source: &Path, dest: &Path) -> Result<(), anyhow::Error> { + let name = dest + .file_name() + .and_then(|name| name.to_str()) + .context("the destination has no file name")?; + let tmp = dest.with_file_name(format!(".{name}.{}.new", std::process::id())); + // a file of a failed installation, in a directory that only root can write + let _ = fs::remove_file(&tmp); + let mut input = File::open(source)?; + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o700) + .open(&tmp)?; + let copied = io::copy(&mut input, &mut output).and_then(|_| output.sync_all()); + drop(output); + if let Err(e) = copied.and_then(|()| fs::rename(&tmp, dest)) { + let _ = fs::remove_file(&tmp); + return Err(e.into()); + } + Ok(()) +} + fn copy_executables(profile: Profile) -> Result<(), anyhow::Error> { let chsr_dest = Path::new(RAR_BIN_PATH).join(CHSR_DEST); let sr_dest = Path::new(RAR_BIN_PATH).join(SR_DEST); @@ -58,41 +88,8 @@ fn copy_executables(profile: Profile) -> Result<(), anyhow::Error> { return Err(anyhow!("sr or chsr does not exist in the target directory. \nYou may need first to do `sudo cargo clean`.\n{BOLD}{RED}Please build the project first using `cargo xtask build`{RST}")); } - // We can't use fs::copy directly because it will overwrite the destination file - // and it is possible that the destination file is currently under execution. - debug!("Copying sr to sr.tmp"); - fs::copy(sr, format!("{s_sr}.tmp"))?; - debug!("Copying chsr to chsr.tmp"); - fs::copy(chsr, format!("{s_chsr}.tmp"))?; - debug!("Moving sr to /usr/bin/dosr"); - match fs::rename(sr, &sr_dest) { - Ok(()) => {} - Err(e) if e.raw_os_error() == Some(18) => { - // EXDEV (errno 18): Invalid cross-device link - // Fall back to copy + remove for cross-filesystem operations - debug!("Cross-device link detected, using copy+remove instead"); - fs::copy(sr, &sr_dest)?; - fs::remove_file(sr)?; - } - Err(e) => return Err(e.into()), - } - debug!("Moving chsr to /usr/bin/chsr"); - match fs::rename(chsr, &chsr_dest) { - Ok(()) => {} - Err(e) if e.raw_os_error() == Some(18) => { - // EXDEV (errno 18): Invalid cross-device link - // Fall back to copy + remove for cross-filesystem operations - debug!("Cross-device link detected, using copy+remove instead"); - fs::copy(chsr, &chsr_dest)?; - fs::remove_file(chsr)?; - } - Err(e) => return Err(e.into()), - } - debug!("Renaming sr.tmp to sr"); - fs::rename(format!("{s_sr}.tmp"), sr)?; - debug!("Renaming chsr.tmp to chsr"); - fs::rename(format!("{s_chsr}.tmp"), chsr)?; - + install_file(sr, &sr_dest).context("Failed to install dosr")?; + install_file(chsr, &chsr_dest).context("Failed to install chsr")?; Ok(()) } @@ -103,6 +100,11 @@ fn copy_docs() -> Result<(), anyhow::Error> { fn enter_directory() -> io::Result<()> { set_current_dir("target/man/") } + // the manual pages are not built without pandoc + if !Path::new("target/man").is_dir() { + info!("No manual page was built, none is installed"); + return Ok(()); + } enter_directory()?; for file in glob::glob("**/*") diff --git a/xtask/src/installer/mod.rs b/xtask/src/installer/mod.rs index 6f6e6e3b..38466b65 100644 --- a/xtask/src/installer/mod.rs +++ b/xtask/src/installer/mod.rs @@ -4,7 +4,7 @@ pub mod install; mod uninstall; use std::fmt::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::{collections::VecDeque, fmt::Display}; @@ -14,12 +14,16 @@ use jiff::civil::Date; use semver::Version; use anyhow::anyhow; -use log::debug; +use capctl::{Cap, CapState}; +use log::{debug, info, warn}; use crate::util::path_exe_from_env; use crate::{ configure, - util::{OsTarget, detect_priv_bin, get_os, is_dry_run}, + util::{ + OsTarget, PAM_CONFIG_SERVICE, RAR_CFG_DATA_PATH, RAR_CFG_PATH, detect_priv_bin, get_os, + is_dry_run, + }, }; pub const RAR_BIN_PATH: &str = env!("RAR_BIN_PATH"); pub const SR_DEST: &str = "dosr"; @@ -35,7 +39,7 @@ pub struct InstallOptions { #[clap(long, hide = true)] pub nested_install: bool, - /// The OS target for PAM configuration and dependencies installation (if -i is set) + /// The OS target for PAM configuration and the packages to check /// By default, it tries to autodetect it #[clap(long, short)] pub os: Option, @@ -44,8 +48,8 @@ pub struct InstallOptions { #[clap(long, short = 'b')] pub build: bool, - /// Install dependencies before building - #[clap(long, short = 'i')] + /// Deprecated, does nothing: the packages are checked, and are installed by the administrator + #[clap(long, short = 'i', hide = true)] pub install_dependencies: bool, /// Clean the target directory after installing @@ -53,26 +57,6 @@ pub struct InstallOptions { pub clean_after: bool, } -#[derive(Debug, Parser)] -pub struct InstallDependenciesOptions { - /// The OS target for PAM configuration and dependencies installation (if -i is set) - /// By default, it tries to autodetect it - #[clap(long, short)] - pub os: Option, - - /// Install dependencies before installing - #[clap(long, short = 'i')] - pub install_dependencies: bool, - - /// Install development dependencies for compiling - #[clap(long, short = 'd')] - pub dev: bool, - - /// The binary to elevate privileges - #[clap(long, short = 'p', visible_alias = "privbin")] - pub priv_bin: Option, -} - #[derive(Debug, Parser)] pub struct UninstallOptions { /// Delete all configuration files @@ -277,32 +261,73 @@ impl FromStr for Toolchain { } } +/// What `configure` would change, for a dry run. +fn configure_plan() -> Vec { + vec![ + format!("deploy /etc/pam.d/{PAM_CONFIG_SERVICE} if it is missing or empty"), + format!( + "deploy the policy, {RAR_CFG_PATH} and {RAR_CFG_DATA_PATH}, if missing, immutable when the file system allows it, then run chsr upgrade" + ), + ] +} + +fn print_plan(what: &str, plan: &[String]) { + info!("Dry run, nothing is changed: {what} would"); + for step in plan { + info!(" - {step}"); + } +} + pub fn configure(os: Option) -> Result<(), anyhow::Error> { if is_dry_run() { - debug!("Dry-run mode: skipping configure changes"); + print_plan("configure", &configure_plan()); return Ok(()); } configure::configure(os) } -pub fn dependencies(opts: &InstallDependenciesOptions) -> Result<(), anyhow::Error> { - if is_dry_run() { - debug!("Dry-run mode: skipping dependencies installation"); - return Ok(()); +/// What the privileged part of `install` would change, for a dry run: these steps change the +/// system without a command that a dry run could print. +fn install_plan(opts: &InstallOptions, priv_bin: Option<&Path>) -> Vec { + let mut plan = Vec::new(); + let privileged = CapState::get_current().is_ok_and(|state| { + [Cap::DAC_OVERRIDE, Cap::CHOWN, Cap::SETFCAP] + .into_iter() + .all(|cap| state.permitted.has(cap)) + }); + if !privileged { + let exe = std::env::current_exe() + .map_or_else(|_| "xtask".into(), |exe| exe.display().to_string()); + plan.push( + priv_bin + .map(Path::to_path_buf) + .or_else(detect_priv_bin) + .map_or_else( + || "stop: no privilege escalation (dosr, sudo, doas, please) was found".into(), + |priv_bin| { + format!( + "run {} {exe} install --nested-install, to do the rest with privileges", + priv_bin.display() + ) + }, + ), + ); } - dependencies::install(opts) + let profile = opts.build_opts.profile; + plan.push(format!( + "copy target/{profile}/{SR_DEST} and target/{profile}/{CHSR_DEST} into {RAR_BIN_PATH} as new files of root, mode 0555" + )); + plan.push(format!( + "give {RAR_BIN_PATH}/{SR_DEST} the file capabilities =p" + )); + plan.extend(configure_plan()); + if opts.clean_after { + plan.push("run cargo clean".into()); + } + plan } pub fn install(opts: &InstallOptions) -> Result<(), anyhow::Error> { - if is_dry_run() { - debug!("Dry-run mode: skipping install changes"); - return Ok(()); - } - if opts.nested_install { - unsafe { std::env::set_var("ROOTASROLE_INSTALLER_NESTED", "1") }; - } else { - unsafe { std::env::remove_var("ROOTASROLE_INSTALLER_NESTED") }; - } let os = get_os(opts.os.as_ref())?; let priv_bin = opts .build_opts @@ -320,17 +345,25 @@ pub fn install(opts: &InstallOptions) -> Result<(), anyhow::Error> { ) }); if opts.install_dependencies { - debug!("Installing dependencies"); - dependencies(&InstallDependenciesOptions { - os: Some(os.clone()), - install_dependencies: true, - dev: opts.build, - priv_bin: priv_bin.clone(), - })?; + warn!( + "-i is deprecated and does nothing: xtask checks the packages, install the missing ones yourself" + ); } if opts.build { debug!("Building sr and chsr"); - build(&opts.build_opts)?; + dependencies::check(&os, true)?; + build::build(&opts.build_opts)?; + } else { + dependencies::check(&os, false)?; + } + if is_dry_run() { + print_plan("install", &install_plan(opts, priv_bin.as_deref())); + return Ok(()); + } + if opts.nested_install { + unsafe { std::env::set_var("ROOTASROLE_INSTALLER_NESTED", "1") }; + } else { + unsafe { std::env::remove_var("ROOTASROLE_INSTALLER_NESTED") }; } if install::install( priv_bin.as_deref(), @@ -346,14 +379,77 @@ pub fn install(opts: &InstallOptions) -> Result<(), anyhow::Error> { } } +/// Build, once the packages to build are checked. An OS that is not detected is only warned +/// about: the build tells then. pub fn build(opts: &BuildOptions) -> Result<(), anyhow::Error> { + match get_os(None) { + Ok(os) => dependencies::check(&os, true)?, + Err(e) => warn!("The packages to build cannot be checked: {e:#}"), + } build::build(opts) } +/// What `uninstall` would remove, for a dry run or without `--apply`. +fn uninstall_plan(opts: &UninstallOptions) -> Vec { + let mut plan = Vec::new(); + if opts.kind.is_all() || opts.kind.is_sr() { + plan.push(format!( + "remove {RAR_BIN_PATH}/{SR_DEST} and {RAR_BIN_PATH}/{CHSR_DEST}" + )); + plan.push(if opts.clean_config { + format!("remove /etc/pam.d/{PAM_CONFIG_SERVICE}") + } else { + format!("remove /etc/pam.d/{PAM_CONFIG_SERVICE} if it is the one that install deploys") + }); + if opts.clean_config { + plan.push(format!( + "remove the immutable flag of {RAR_CFG_PATH} and {RAR_CFG_DATA_PATH}, and remove {RAR_CFG_PATH}" + )); + } + } + plan +} + +/// Whether uninstall changes the system: `--apply` is required, and a dry run never does. +fn uninstall_applies(opts: &UninstallOptions) -> bool { + opts.apply && !is_dry_run() +} + pub fn uninstall(opts: &UninstallOptions) -> Result<(), anyhow::Error> { - if is_dry_run() { - debug!("Dry-run mode: skipping uninstall changes"); + if !uninstall_applies(opts) { + print_plan("uninstall", &uninstall_plan(opts)); + if !opts.apply { + info!("Add --apply to uninstall"); + } return Ok(()); } uninstall::uninstall(opts) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::{DRY_RUN_TEST_LOCK, set_dry_run}; + + #[test] + fn test_uninstall_needs_apply_and_no_dry_run() { + let _guard = DRY_RUN_TEST_LOCK.lock().unwrap(); + let opts = |apply| UninstallOptions { + clean_config: false, + apply, + kind: UninstallKind::All, + }; + set_dry_run(false); + assert!(!uninstall_applies(&opts(false))); + assert!(uninstall_applies(&opts(true))); + set_dry_run(true); + assert!(!uninstall_applies(&opts(true))); + set_dry_run(false); + let plan = uninstall_plan(&opts(false)).join("\n"); + assert!( + plan.contains(&format!("{RAR_BIN_PATH}/{SR_DEST}")), + "{plan}" + ); + assert!(!plan.contains(RAR_CFG_PATH), "{plan}"); + } +} diff --git a/xtask/src/installer/uninstall.rs b/xtask/src/installer/uninstall.rs index 00077629..8c82be61 100644 --- a/xtask/src/installer/uninstall.rs +++ b/xtask/src/installer/uninstall.rs @@ -2,24 +2,27 @@ use anyhow::Context; use log::warn; use std::fs; -use crate::util::{ - ImmutableLock, PAM_CONFIG_SERVICE, RAR_CFG_DATA_PATH, RAR_CFG_PATH, files_are_equal, - toggle_lock_config, +use crate::{ + configure::PAM_CONFIGS, + util::{ + ImmutableLock, PAM_CONFIG_SERVICE, RAR_CFG_DATA_PATH, RAR_CFG_PATH, toggle_lock_config, + }, }; use super::{CHSR_DEST, SR_DEST, UninstallOptions}; +// a Result like every command of xtask, for the dispatch in main +#[allow(clippy::unnecessary_wraps)] pub fn uninstall(opts: &UninstallOptions) -> Result<(), anyhow::Error> { let mut errors = vec![]; if opts.kind.is_all() || opts.kind.is_sr() { let pam_config_path = format!("/etc/pam.d/{PAM_CONFIG_SERVICE}"); errors.push(fs::remove_file(SR_DEST).context(SR_DEST)); errors.push(fs::remove_file(CHSR_DEST).context(CHSR_DEST)); - if opts.clean_config - || files_are_equal("resources/debian/deb_sr_pam.conf", &pam_config_path)? - || files_are_equal("resources/rh/rh_sr_pam.conf", &pam_config_path)? - || files_are_equal("resources/arch/arch_sr_pam.conf", &pam_config_path)? - { + // the PAM service is removed if it is one that the installer deployed + let deployed = fs::read_to_string(&pam_config_path) + .is_ok_and(|content| PAM_CONFIGS.contains(&content.as_str())); + if opts.clean_config || deployed { errors.push(fs::remove_file(&pam_config_path).context(pam_config_path)); } if opts.clean_config { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 790afe80..0a920a3f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,10 +1,9 @@ mod configure; -mod deploy; mod doctor; mod installer; pub mod util; -use Command::{Build, Configure, Dependencies, Deploy, Doctor, Install, Uninstall}; +use Command::{Build, Configure, Doctor, Install, Uninstall}; use std::process::exit; @@ -24,8 +23,6 @@ pub struct Options { #[derive(Debug, Parser)] enum Command { - #[cfg(feature = "cli")] - Dependencies(installer::InstallDependenciesOptions), #[cfg(feature = "cli")] Build(installer::BuildOptions), #[cfg(feature = "cli")] @@ -37,8 +34,6 @@ enum Command { os: Option, }, Uninstall(installer::UninstallOptions), - #[cfg(feature = "deploy")] - Deploy(deploy::MakeOptions), Doctor(doctor::DoctorOptions), } @@ -57,19 +52,64 @@ fn main() { if std::env::var_os("ROOTASROLE_INSTALLER_NESTED").is_some() { info!("nested install is enabled"); } - let opts = Options::parse(); - let ret = match opts.command { - Dependencies(opts) => installer::dependencies(&opts), + if let Err(e) = run(Options::parse()) { + error!("{e:#}"); + exit(1); + } +} + +/// Run the command of `opts`. `--dry-run` was parsed but never applied: a dry run of install +/// elevated through the real privilege escalator. +fn run(opts: Options) -> Result<(), anyhow::Error> { + util::set_dry_run(opts.dry_run); + match opts.command { Build(opts) => installer::build(&opts), Install(opts) => installer::install(&opts), Configure { os } => installer::configure(os), Uninstall(opts) => installer::uninstall(&opts), - Deploy(opts) => deploy::deploy(&opts), Doctor(opts) => doctor::doctor(&opts), + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, }; - if let Err(e) = ret { - error!("{e:#}"); - exit(1); + use super::*; + + /// A program that leaves a mark when it runs, given as the privilege escalation of the + /// tests: even a dry run that went wrong would run it, never sudo nor dosr. + fn marker_program(dir: &Path) -> (PathBuf, PathBuf) { + let program = dir.join("escalate"); + fs::write(&program, "#!/bin/sh\ntouch \"$0.ran\"\n").unwrap(); + fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).unwrap(); + (program, dir.join("escalate.ran")) + } + + #[test] + fn test_dry_run_runs_no_command() { + let _guard = util::DRY_RUN_TEST_LOCK.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("xtask_dry_run_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let (program, mark) = marker_program(&dir); + let program_arg = program.to_str().unwrap(); + for args in [ + vec!["xtask", "--dry-run", "install", "-p", program_arg], + vec!["xtask", "install", "--dry-run", "-bi", "-p", program_arg], + vec!["xtask", "--dry-run", "configure"], + ] { + run(Options::parse_from(&args)).unwrap(); + assert!(!mark.exists(), "{args:?} ran the privilege escalation"); + } + // the program does leave its mark when it is run: the check above can fail + util::set_dry_run(false); + util::run_checked(&mut std::process::Command::new(&program), "leave a mark").unwrap(); + assert!(mark.exists()); + let _ = fs::remove_dir_all(&dir); } } diff --git a/xtask/src/util.rs b/xtask/src/util.rs index 3e5009aa..9808e3ea 100644 --- a/xtask/src/util.rs +++ b/xtask/src/util.rs @@ -4,6 +4,7 @@ use std::{ fs::{self, File}, io, num::ParseIntError, + os::unix::process::ExitStatusExt, os::{fd::AsRawFd, unix::fs::MetadataExt}, path::{Path, PathBuf}, process::{Command, ExitStatus, Output}, @@ -621,6 +622,10 @@ pub const INFO: SInfo = SInfo::const_parse(env!("RAR_EXEC_INFO_DISPLAY")); static DRY_RUN: AtomicBool = AtomicBool::new(false); +/// Held by the tests that change `DRY_RUN`, which is global. +#[cfg(test)] +pub static DRY_RUN_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[derive(Debug)] pub enum ImmutableLock { Set, @@ -779,6 +784,15 @@ pub fn is_dry_run() -> bool { /// /// Will return an error if the command fails to execute or exits with a non-zero code pub fn status_checked(command: &mut Command, action: &str) -> Result { + // the commands run through here change something (packages, build, privileges): a dry run + // prints them instead; output_checked runs the queries, which change nothing + if is_dry_run() { + info!( + "{BOLD}Dry run, not running:{RST} {}\n{BOLD} Objective -->{RST} {action}", + shell_quote_command(command) + ); + return Ok(ExitStatus::from_raw(0)); + } let status = command .status() .with_context(|| format!("Failed to {action}: {command:?}"))?; @@ -814,7 +828,9 @@ fn shell_quote_command(command: &Command) -> String { /// /// Will return an error if the command fails to execute or exits with a non-zero code pub fn run_checked(command: &mut Command, action: &str) -> Result<(), anyhow::Error> { - log_command_execution(command, action); + if !is_dry_run() { + log_command_execution(command, action); + } let _ = status_checked(command, action)?; Ok(()) } @@ -949,6 +965,41 @@ pub fn get_os(os: Option<&OsTarget>) -> Result { }) } +/// Whether `command` is an executable file of a directory of `PATH`. +#[must_use] +pub fn command_exists(command: &str) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + + for dir in std::env::split_paths(&path) { + let candidate = dir.join(command); + if is_executable(&candidate) { + return true; + } + } + + false +} + +fn is_executable(path: &Path) -> bool { + if let Ok(metadata) = std::fs::metadata(path) { + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + return metadata.permissions().mode() & 0o111 != 0; + } + #[cfg(not(unix))] + { + return true; + } + } + false +} + #[must_use] pub fn detect_priv_bin() -> Option { // is /usr/bin/dosr exist ?