sudo-rs-0.2.10/.cargo_vcs_info.json0000644000000001360000000000100125100ustar { "git": { "sha1": "eb249e1374c23db11bf2ace64ecd17645a702425" }, "path_in_vcs": "" }sudo-rs-0.2.10/.cirrus.yml000064400000000000000000000012231046102023000134060ustar 00000000000000task: name: Cirrus CI / freebsd unit tests freebsd_instance: image_family: freebsd-14-2 memory: 2GB setup_rust_script: - pkg install -y git-tiny - curl https://sh.rustup.rs -sSf --output rustup.sh - sh rustup.sh -y --profile=minimal test_script: - . $HOME/.cargo/env - | # We skip a couple of tests which fail when running as root. # test_traverse_secure_open_positive fails because the build directory is # in the world writable /tmp. cargo test --workspace --all-targets --release -- --skip group_as_non_root \ --skip test_secure_open_cookie_file --skip test_traverse_secure_open_positive sudo-rs-0.2.10/.github/ISSUE_TEMPLATE/bug_report.md000064400000000000000000000016461046102023000175240ustar 00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. Please check the issue tracker for similar issues before opening a new one. If using an older version of sudo-rs, such as one packaged for an Ubuntu LTS version, please also try to reproduce the bug on the latest released version. **To Reproduce** Steps to reproduce the behavior: 1. Compile 'sudo-rs' '....' 2. Write the following contents to `/etc/sudoers-rs` '....' 3. Run the following command '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Environment (please complete the following information):** - Linux distribution: [e.g. Ubuntu 25.10] - `sudo-rs` version [`sudo --version`] or commit hash: [e.g. `d085c0a`] **Additional context** Add any other context about the problem here. sudo-rs-0.2.10/.github/ISSUE_TEMPLATE/feature_request.md000064400000000000000000000014061046102023000205510ustar 00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Describe the feature you'd like see implemented in `sudo-rs`** A clear and concise description of what you want to happen. **What problem can be solved with this feature?** A clear and concise description of what the problem is and how this feature would solve such feature. Ex. I'm always frustrated when [...] and this feature would fix this problem because [...] **Describe alternatives you've considered** Are you sure this problem cannot be solved by an already existing feature? You could also add any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. sudo-rs-0.2.10/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md000064400000000000000000000011041046102023000230540ustar 00000000000000**Describe the changes done on this pull request** A clear and concise description of the changes done by your pull request. **Pull Request Checklist** - [] I have read and accepted the [code of conduct](https://github.com/trifectatechfoundation/sudo-rs/blob/master/CODE_OF_CONDUCT.md) for this project. - [] I have tested, formatted and ran clippy over my changes. - [] I have commented and documented my changes. - [] This pull request will fix issue https://github.com/trifectatechfoundation/sudo-rs/issues/<#issue> where a proper discussion about a solution has taken place. sudo-rs-0.2.10/.github/codecov.yml000064400000000000000000000002611046102023000150040ustar 00000000000000ignore: - "test-binaries" - "test-framework" coverage: status: project: default: informational: true patch: default: informational: true sudo-rs-0.2.10/.github/dependabot.yml000064400000000000000000000002141046102023000154650ustar 00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: monthly open-pull-requests-limit: 10 sudo-rs-0.2.10/.github/problem-matchers/rust.json000064400000000000000000000010231046102023000177700ustar 00000000000000{ "problemMatcher": [ { "owner": "rust", "pattern": [ { "regexp": "^(warning|warn|error)(\\[(.*)\\])?: (.*)$", "severity": 1, "message": 4, "code": 3 }, { "regexp": "^([\\s->=]*(.*):(\\d*):(\\d*)|.*)$", "file": 2, "line": 3, "column": 4 } ] } ] } sudo-rs-0.2.10/.github/workflows/ci.yaml000064400000000000000000000337171046102023000161670ustar 00000000000000name: CI permissions: read-all on: push: branches: - main pull_request: merge_group: branches: - main jobs: e2e-tests: runs-on: ubuntu-latest env: SUDO_UNDER_TEST: ours CI: true steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: set up docker buildx run: docker buildx create --name builder --use - name: cache docker layers uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: /tmp/.buildx-cache key: docker-buildx-rs-${{ github.sha }} restore-keys: docker-buildx-rs- - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "compliance-tests" workspaces: | test-framework - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Run all E2E tests working-directory: test-framework run: cargo test -p e2e-tests --features apparmor - name: prevent the cache from growing too large run: | rm -rf /tmp/.buildx-cache mv /tmp/.buildx-cache-new /tmp/.buildx-cache compliance-tests-detect-changes: runs-on: ubuntu-latest outputs: updated: ${{ steps.filter.outputs.test-framework }} steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 id: filter with: filters: | test-framework: - 'test-framework/**' compliance-tests-og: needs: compliance-tests-detect-changes if: ${{ needs.compliance-tests-detect-changes.outputs.updated != 'false' }} runs-on: ubuntu-latest env: CI: true steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: set up docker buildx run: docker buildx create --name builder --use - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "compliance-tests" workspaces: | test-framework - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Test sudo-test itself working-directory: test-framework run: cargo test -p sudo-test - name: Run all compliance tests against original sudo working-directory: test-framework run: cargo test -p sudo-compliance-tests -- --include-ignored compliance-tests: runs-on: ubuntu-latest timeout-minutes: 20 env: SUDO_TEST_PROFRAW_DIR: /tmp/profraw CI: true steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: set up docker buildx run: docker buildx create --name builder --use - name: cache docker layers uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: /tmp/.buildx-cache key: docker-buildx-rs-${{ github.sha }} restore-keys: docker-buildx-rs- - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "compliance-tests" workspaces: | test-framework - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Run gated compliance tests against sudo-rs working-directory: test-framework env: SUDO_UNDER_TEST: ours run: cargo test -p sudo-compliance-tests --features apparmor - name: Check that we didn't forget to gate a passing compliance test working-directory: test-framework env: SUDO_UNDER_TEST: ours run: | tmpfile="$(mktemp)" cargo test -p sudo-compliance-tests -- --ignored | tee "$tmpfile" grep 'test result: FAILED. 0 passed' "$tmpfile" || ( echo "expected ALL tests to fail but at least one passed; the passing tests must be un-#[ignore]-d" && exit 1 ) - name: prevent the cache from growing too large run: | rm -rf /tmp/.buildx-cache mv /tmp/.buildx-cache-new /tmp/.buildx-cache compliance-tests-lint: needs: compliance-tests-detect-changes if: ${{ needs.compliance-tests-detect-changes.outputs.updated != 'false' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "compliance-tests" workspaces: | test-framework - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: clippy sudo-test working-directory: test-framework run: cargo clippy -p sudo-test --no-deps -- --deny warnings - name: clippy compliance-tests working-directory: test-framework run: cargo clippy -p sudo-compliance-tests --tests --no-deps -- --deny warnings - name: Check that all ignored tests are linked to a GH issue working-directory: test-framework/sudo-compliance-tests run: | grep -r '#\[ignore' ./src | grep -v -e '"gh' -e '"wontfix"' && echo 'found ignored tests not linked to a GitHub issue. please like them using the format #[ignore = "gh123"]' && exit 1; true build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Install llvm-tools component run: rustup component add llvm-tools - name: Add cargo-llvm-cov uses: taiki-e/install-action@47be02f2de8a32619316956f6117e150bdc6763f with: tool: cargo-llvm-cov - name: Install dependencies run: | sudo apt update sudo apt install libpam0g-dev - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "stable" - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Build run: cargo build --workspace --all-targets --release - name: Run tests run: cargo llvm-cov --workspace --all-targets --release --lcov --output-path lcov.info - name: Upload code coverage uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 with: files: lcov.info build-and-test-minimal: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Install nightly rust run: | rustup set profile minimal rustup override set nightly - name: Install dependencies run: | sudo apt update sudo apt install libpam0g-dev - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "nightly" - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Update to minimal direct dependencies run: cargo update -Zdirect-minimal-versions - name: Build run: cargo build --workspace --all-targets --release - name: Run tests run: cargo test --workspace --all-targets --release build-and-test-msrv: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Install rust 1.70 run: rustup override set 1.70 - name: Install dependencies run: | sudo apt update sudo apt install libpam0g-dev - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "msrv" - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Build run: cargo build --workspace --all-targets --release - name: Run tests run: cargo test --workspace --all-targets --release build-and-test-fedora: runs-on: ubuntu-latest container: fedora:latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Install dependencies run: | dnf install -y cargo pam-devel - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "stable-fedora" - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Reduce privileges run: | useradd builder chown builder . - name: Build run: sudo -ubuilder cargo build --workspace --all-targets --release - name: Run tests run: sudo -ubuilder cargo test --workspace --all-targets --release build-and-test-alpine: runs-on: ubuntu-latest container: alpine:latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Install dependencies run: | apk add cargo linux-pam-dev sudo tzdata coreutils-fmt - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "stable-alpine" - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Reduce privileges run: | adduser --disabled-password builder chown builder . - name: Build run: sudo -ubuilder cargo build --workspace --all-targets --release - name: Run tests run: | # Alpine hasn't done usr-merge yet sudo -ubuilder cargo test --workspace --all-targets --release \ -- --skip canonicalization --skip test_build_run_context build-and-test-32bit: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Add 32-bit target run: | rustup target add i686-unknown-linux-gnu - name: Install dependencies run: | sudo dpkg --add-architecture i386 sudo apt update sudo apt install libpam0g-dev:i386 gcc-multilib - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "stable-32bit" - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Build run: cargo build --target i686-unknown-linux-gnu --workspace --all-targets --release - name: Run tests run: cargo test --target i686-unknown-linux-gnu --workspace --all-targets --release miri: needs: build-and-test runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Install nightly rust and miri run: | rustup set profile minimal rustup override set nightly rustup component add miri - name: Install dependencies run: | sudo apt update sudo apt install libpam0g-dev - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: miri - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Run tests run: cargo miri test --workspace miri check-bindings: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Install dependencies run: | sudo apt update sudo apt install libpam0g-dev - name: Install rust-bindgen uses: taiki-e/install-action@47be02f2de8a32619316956f6117e150bdc6763f with: tool: bindgen-cli@0.70.1 - name: Install cargo-minify run: cargo install --locked --git https://github.com/tweedegolf/cargo-minify cargo-minify - name: Regenerate bindings run: make -B pam-sys - name: Check for differences run: git diff --exit-code format: runs-on: ubuntu-latest env: RUSTDOCFLAGS: "-D warnings" steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Run rustfmt run: | cargo fmt --all -- --check cargo fmt --manifest-path test-framework/Cargo.toml --all -- --check clippy: needs: format runs-on: ubuntu-latest env: RUSTDOCFLAGS: "-D warnings" steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "stable" - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Run clippy run: cargo clippy --no-deps --all-targets -- --deny warnings docs: needs: clippy runs-on: ubuntu-latest env: RUSTDOCFLAGS: "-D warnings" steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Rust Cache uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 with: shared-key: "stable" - name: Register rust problem matcher run: echo "::add-matcher::.github/problem-matchers/rust.json" - name: Build docs run: cargo doc --no-deps --document-private-items audit: needs: clippy runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Install cargo-audit uses: taiki-e/install-action@47be02f2de8a32619316956f6117e150bdc6763f with: tool: cargo-audit - name: Run audit run: cargo audit sudo-rs-0.2.10/.github/workflows/release.yml000064400000000000000000000061511046102023000170430ustar 00000000000000# To run the release workflow push a tag with the expected SHA256SUMS as tag message body. name: Release on: push: tags: '*' jobs: build: runs-on: ubuntu-24.04 permissions: read-all steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Sanity checks run: | # GHA makes the tag point to the commit rather than the tag object. # Remove the tag and fetch it again to get the real tag object. git tag -d "$(echo "$GITHUB_REF" | sed 's/refs\/tags\///')" git fetch https://github.com/trifectatechfoundation/sudo-rs.git --tags # Check if the tag has a signature to prevent accidentally pushing an unsigned tag. git tag -l --format='%(contents:signature)' "$(echo "$GITHUB_REF" | sed 's/refs\/tags\///')" | grep --quiet SIGNATURE || (echo "Tag not signed"; exit 1) - name: Run build run: ./util/build-release.sh # Upload the built tarballs first before comparing checksums to help with debugging. - name: Upload artifacts uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: release_files path: | target/pkg/SHA256SUMS target/pkg/*.tar.gz - name: Compare checksums run: | # Get the expected checksums from the tag message. git tag -l --format='%(contents:body)' "$(echo "$GITHUB_REF" | sed 's/refs\/tags\///')" | tr -s '\n' > expected_checksums.txt # Check that the actual checksums match what we expected. If not fail # the release and have the person doing the release check again for # reproducability problems. cat expected_checksums.txt diff -u expected_checksums.txt target/pkg/SHA256SUMS release: runs-on: ubuntu-24.04 permissions: contents: write needs: build steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Download artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: release_files path: release_files - name: Prepare release run: | echo "Release files:" ls -l release_files echo # Extract the first changelog entry from CHANGELOG.md echo "Changelog:" sed -n '4,${ /^## /q; p; }' CHANGELOG.md | tee changes.md - name: Create release env: GH_TOKEN: ${{ github.token }} run: | # GHA makes the tag point to the commit rather than the tag object. # Remove the tag and fetch it again to get the real tag object. RELEASE="$(echo "$GITHUB_REF" | sed 's/refs\/tags\///')" git tag -d "$RELEASE" git fetch https://github.com/trifectatechfoundation/sudo-rs.git --tags gh release create "$RELEASE" --draft \ --title "Version ${RELEASE#v}" \ --notes-file changes.md release_files/* \ --verify-tag echo "Draft release successfully created. Please review and publish." sudo-rs-0.2.10/.gitignore000064400000000000000000000002661046102023000132740ustar 00000000000000# Generated by Cargo # will have compiled files and executables /target/ /build/ # These are backup files generated by rustfmt **/*.rs.bk # Code coverage in lcov format /lcov.info sudo-rs-0.2.10/CHANGELOG.md000064400000000000000000000246671046102023000131300ustar 00000000000000# Changelog ## [0.2.10] - 2025-11-10 ### Changed - Message shown at password timeout has been made nicer (#1171) - Interrupting a PAM module that handles its own input such as `fprintd` will not prevent other authentication modes from being tried. (#1308) ### Fixed - Some formatting mistakes in the man pages (#1285) - Unhandled exception if user would attempt to execute a folder (#1298) - `Defaults passwd_tries=0` effectively disabled the limit on password tries rather than setting the limit to zero (#1313) - sudoedit ignored supplementary groups when checking writability (#1321) - A partially typed password would be output to standard input if a timeout occurred when `Defaults pwfeedback` was not enabled (GHSA-q428-6v73-fc4q). - Timestamp files did not take into account the setting of the `Defaults targetpw` and `Defaults rootpw` (GHSA-c978-wq47-pvvw) - Minor tokenization error in /etc/sudoers processing (#1296) ## [0.2.9] - 2025-10-03 ### Added - `SUDO_HOME` variable will now be set to the invoking user's home directory ### Changed - `Defaults noninteractive_auth` now controls whether PAM authentication modules are allowed to attempt authentication when `sudo --non-interactive` is being used (new default: off). Previous versions had this as always-on to allow fully automatic authentication methods to succeed. ### Fixed - Two bugs in managing the PTY connected to the child process that negatively impacted interactive usability (#1130, #1264) - `visudo --help` showed command flags that were removed (#1239) - Format flags in `SUDO_PROMPT` were not expanded (#1252) - `sudo` would abort with an unhandled exception instead if an attempt was was made to match a "netgroup", instead of ignoring these (#1262) - A few tokenizer errors in /etc/sudoers processing (#1273, #1274, #1283) - Some formatting mistakes in the man pages (#1285) ## [0.2.8] - 2025-08-04 ### Added - `sudo -e`, `sudoedit` to safely edit files as another user. ### Fixed - `NOEXEC:` could not be used to prevent all shell escapes on multi-architecture installations (#1229) - `sudo --list` would not show `NOEXEC`, `SETENV` and `APPARMOR_PROFILE` (#1228) - Skip paths not accessible by the target user during command resolution (#1234) ## [0.2.7] - 2025-07-01 ### Added - Linux kernels older than 5.9 are now supported. - Support for `Defaults noexec`/`NOEXEC:` on Linux systems based on seccomp filtering to prevent shell escapes in wide range of cases. This should also work on programs not written in C and statically linked executables. - Support for `passwd_timeout` - Support for `umask` and `umask_override` - `--preserve-env=VAR` is now supported to preserve selected environment variables in a more convenient way ### Changed - sudo-rs now uses CLOEXEC to close open file descriptors in the child process - Relative paths like `./` in `secure_path`/`PATH` are now ignored. - `apparmor.so` is dynamically loaded by sudo itself, as-needed ### Fixed - Usernames that start with `_` or have non-western characters were not supported as a valid username in /etc/sudoers (#1149) - Other usability improvements in /etc/sudoers (#1117, #1126, #1134, #1157) ## [0.2.6] - 2025-05-06 ### Added - Support for `Defaults setenv` - Support for the `list` pseudocommand to control `sudo -U` - Support for switching AppArmor profiles though `Defaults apparmor_profile` and the `APPARMOR_PROFILE` command modifier. To enable this, build sudo-rs with the apparmor feature enabled. ### Changed - Added a check against PAM modules changing the user during authentication (#1062) - `list` pseudocommand now controls whether a password is required for `sudo -l -U` ### Fixed - Usernames commonly used by Active Directory were not parsed correctly (#1064) - Test compilation was broken on 32-bit systems (#1074) - `pwfeedback` was ignored for `sudo --list` and `sudo --validate` (#1092) - Compilation with musl instead of glibc on Linux was not possible (#1084) - `sudo --list` now does more checking before reporting errors or listing the rights of a user, fixing two security bugs (CVE-2025-46717 and CVE-2025-46718) ## [0.2.5] - 2025-04-01 ### Added - `sudo visudo` will protect you from accidentally locking yourself out - Support for `--prompt` and `SUDO_PROMPT` environment variable - Support for `Defaults targetpw` - Support for `VAR=VALUE` matching in `Defaults env_keep/env_check` - Support for `--bell` ### Changed - Portability: sudo-rs supports FreeBSD! - `sudo -v` will only ask for a password if the policy requires it ### Fixed - Manual wrongly claimed `timestamp_timeout` supported negative values (#1032) - `timestamp_timeout` in excess of 292 billion years were not rejected (#1048) - Usernames in /etc/sudoers can contain special characters by using double quotes or escaping them (#1045) ## [0.2.4] - 2025-02-25 ### Added - Support for `SETENV:` and corresponding `sudo VAR=value command` syntax - Support for `Defaults rootpw` - Support for `Defaults pwfeedback` - Support for host/user/runas/command-specific `Defaults` ### Changed - Portability: sudo-rs now has experimental support for FreeBSD! - `pam-login` feature now controls if PAM service name 'sudo-i' is used ### Fixed - Bug in syslog writer could cause sudo to hang (#856) - SHELL was not canonicalized when using `sudo -s` or `sudo -i` (#962) - RunAs_Spec was not carried over on the same /etc/sudoers line (#974) - sudo --list did not unfold multiple-level aliases (#978) - The man page for sudoers was missing (#943) ### Other - sudo-rs copyright changed to Trifecta Tech Foundation ## [0.2.3] - 2024-07-11 ### Changed - Portability: sudo-rs now is compatible with s390x-unknown-linux-gnu - Removed unneeded code & fix hints given by newer Rust version ### Fixed - `visudo` would not properly truncate a `sudoers` file - high CPU load when child process did not terminate after closure of a terminal ## [0.2.2] - 2024-02-02 ### Changed - Several changes to the code to improve type safety - Improved error message when a PTY cannot be opened - Improved portability of the PAM bindings - su: improved parsing of su command line options - Add path information to parse errors originating from included files ### Fixed - Fixed a panic with large messages written to the syslog - sudo: respect `--login` regardless of the presence of `--chdir` ## [0.2.1] - 2023-09-21 ### Changed - Session records/timestamps are now stored in files with uids instead of usernames, fixing a security bug (CVE-2023-42456) - `visudo` will now resolve `EDITOR` via `PATH` - Input/output errors while writing text to the terminal no longer cause sudo to exit immediately - Switched several internal API calls from libc to Rust's std library - The `%h` escape sequence in sudoers includes directives is not supported in sudo-rs, this now gives a better diagnostic and no longer tries to include the file - Our PAM integration was hardened against allocation failures - An attempt was made to harden against rowhammer type attacks - Release builds no longer include debugging symbols ### Fixed - Fixed an invalid parsing when an escaped null byte was present in the sudoers file - Replaced informal error message in `visudo` with a proper error message ## [0.2.0] - 2023-08-29 ### Added - `visudo` can set/fix file permissions using the `--perms` CLI flag - `visudo` can set/fix the file owner using the `--owner` CLI flag - Read `env_editor` from sudoers file for visudo - Add basic support for `--list` in sudo ### Changed - `visudo` now uses a random filename for the temporary file you are editing - `su` now runs with a PTY by default - Included files with relative paths in the sudoers file are imported relative from the sudoers file - `sudo` now checks if ownership and setuid bits have been set correctly on its binary - When syslog messages are too large they will be split between multiple messages to prevent message truncation - We now accept a wider range of dependencies - Our MSRV (minimum supported rust version) has been set at 1.70.0 ### Fixed - Set arg0 to the non-resolved filename when running a command, preventing issues with symlinks when commands rely on link filenames ## [0.2.0-dev.20230711] - 2023-07-11 ### Added - Add initial `visudo` implementation - Add support for `~` in `--chdir` - Log commands that will be executed in the auth syslog - Add a manpage for the `sudo` command ### Changed - The SUDO_RS_IS_UNSTABLE environment variable is no longer required - Sudo-rs will now read `/etc/sudoers-rs` or `/etc/sudoers` if the former is not available. We no longer read `/etc/sudoers.test` - Removed signal-hook and signal-hook-registry dependencies - Improved error handling when `--chdir` is passed but not allowed - Properly handle `SIGWINCH` when running commands with a PTY ### Fixed - Only call ttyname and isatty on character devices - Fixed a bug in syslog FFI ## [0.2.0-dev.20230703] - 2023-07-03 ### Added - Add `timestamp_timeout` support in sudoers file - Add ability to disable `use_pty` in the sudoers file ### Changed - Set the TTY name for PAM sessions on a TTY - Set the requesting user for PAM sessions - Simplified some error messages when a command could not be executed - Reveal less about what caused a command not to be executable - Continued rework of the pty exec ### Fixed - Fixed exit codes for `su` - Fixed environment filtering for `su` - Fixed `SHELL` handling for `su` ## [0.2.0-dev.20230627] - 2023-06-27 ### Added - Add `passwd_tries` support in sudoers file - Add developer logs (only enabled with the `dev` feature) ### Changed - Only use a PTY to spawn the process if a TTY is available - Continued rework of the pty exec - Aliasing is now implemented similarly to the original sudo - You can no longer define an `ALL` alias in the sudoers file - Use canonicalized paths for the executed binaries - Simplified CLI help to only display supported actions [0.2.3]: https://github.com/trifectatechfoundation/sudo-rs/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/trifectatechfoundation/sudo-rs/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/trifectatechfoundation/sudo-rs/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/trifectatechfoundation/sudo-rs/compare/v0.2.0-dev.20230711...v0.2.0 [0.2.0-dev.20230711]: https://github.com/trifectatechfoundation/sudo-rs/compare/v0.2.0-dev.20230703...v0.2.0-dev.20230711 [0.2.0-dev.20230703]: https://github.com/trifectatechfoundation/sudo-rs/compare/v0.2.0-dev.20230627...v0.2.0-dev.20230703 [0.2.0-dev.20230627]: https://github.com/trifectatechfoundation/sudo-rs/compare/v0.1.0-dev.20230620...v0.2.0-dev.20230627 sudo-rs-0.2.10/CODE_OF_CONDUCT.md000064400000000000000000000002271046102023000141000ustar 00000000000000# Code of Conduct We abide by the [Rust Code of Conduct][coc] and ask that you do as well. [coc]: https://www.rust-lang.org/policies/code-of-conduct sudo-rs-0.2.10/CONTRIBUTING.md000064400000000000000000000105421046102023000135330ustar 00000000000000# Contributing to sudo-rs We welcome contributions to building a memory safe sudo / su implementation; this document lists some ways in which you can help. This project is about building a "drop-in replacement" for sudo and su. That does not mean we want to copy *all* of the behavior of original sudo or other su implementations. Whenever we add a feature, sudo becomes more complex, and unforeseen interactions due to complexity can result in security issues. Also, sudo has some features for backwards compatibility only---it makes no sense for us to re-implement a feature that by its nature won't be very well-used in practice. Other features have a very specific use-case in mind (for example, matching command lines with [regular expressions](https://xkcd.com/1171/)), which are very complex to use and would require the inclusion of a third-party library. I.e. every time we add a feature, we have to weigh its benefits to the cost of adding the feature. For this, the sudo-rs Core Team has adopted a few criteria for inclusions of features in sudo / su: 1. Security is more important than functionality. 2. Simplicity is preferred over complexity. 3. A feature to be added should actually *solve* a problem. 4. Features must support a common and reasonable use case. 5. Dependencies must be kept to an absolute minimum. ## Feature requests The easiest way to contribute is to request a feature that we currently do not have; use the issue tracker for this and explain the situation. Things that are currently possible with original sudo and that pass the above-mentioned criteria are likely to be accepted. ## Testing sudo You can install and run sudo on your personal system, or any other non-mission critical machine. We recommend installing it in `/usr/local/bin` so you have original sudo as a backup. Although sudo-rs is thoroughly tested for every change we make, a real-world test like this can uncover subtle problems in technical parts, or uncover common sudo use cases that we ignored so far. ## Small contributions You can also go through our code --- if you see any small mistakes or have suggestions please create an issue for them. If it is really a minor issue, like a typo or formatting issue, you can immediately create a pull request. ## Security auditing One way you can help is by looking at the security of our code and proposing fixes in it. More eyeballs spot more problems. If you find a security problem that can be used to used to compromise a system, do follow our [security policy] and report a vulnerability instead of using the issue tracker. [security policy]: https://github.com/trifectatechfoundation/sudo-rs/security/policy ## Working on a bigger issue If you want to pick up a bigger issue in the issue tracker, please reach out to the sudo-rs team first. The easiest way to do this is to comment on the issue. If you want to work on something that is not on the issue tracker, do make an issue *before* you begin to make sure your work will not be conflicting with ours. # Expectations for contributors ## Respect free software/open source licenses Since sudo-rs is licensed very permissively (Apache 2.0 or MIT), for every contribution you make, you have to ensure that it is either your original work, or a derived work from software that falls under a free software/open source license that allows its inclusion in our repository. In the latter case your contribution must have clear attributions so we can review whether we can include it in our project. ## Make your code easy to review The sudo-rs team has a limited amount of time to review contributions. You can help us by structuring your pull request in atomic commits, using the commentary field to explain what you are doing, and making an effort yourself to pass our CI checks. In short, you are the first reviewer of your contribution. ## Use of generative artificial intelligence Contributions clearly showing heavy use of generative AI, so-called "vibe coding", will be dismissed out of hand. There's nothing *inherently wrong* with using tool assistance while coding, including tools based on generative AI. But when using AI to generate whole subroutines (or more) only based on prompts, it becomes very hard to guarantee the previous two points: first, it is hard to tell whose original work the contribution is; secondly, you are less able to perform a 'first review' of any code you didn't write yourself. sudo-rs-0.2.10/COPYRIGHT000064400000000000000000000006361046102023000126000ustar 00000000000000Copyright (c) 2022-2025 Trifecta Tech Foundation and contributors Copyright (c) 1994-1996, 1998-2024 Todd C. Miller Except as otherwise noted (below and/or in individual files), sudo-rs is licensed under the Apache License, Version 2.0 or or the MIT license or , at your option. sudo-rs-0.2.10/Cargo.lock0000644000000025720000000000100104710ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "libc" version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "pretty_assertions" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" dependencies = [ "diff", "yansi", ] [[package]] name = "sudo-rs" version = "0.2.10" dependencies = [ "glob", "libc", "log", "pretty_assertions", ] [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" sudo-rs-0.2.10/Cargo.toml0000644000000032430000000000100105100ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" rust-version = "1.70" name = "sudo-rs" version = "0.2.10" build = "build.rs" exclude = [ "audit", "proofs", "util", ] publish = true autolib = false autobins = false autoexamples = false autotests = false autobenches = false default-run = "sudo" description = "A memory safe implementation of sudo and su." homepage = "https://github.com/trifectatechfoundation/sudo-rs" readme = "README.md" categories = ["command-line-interface"] license = "Apache-2.0 OR MIT" repository = "https://github.com/trifectatechfoundation/sudo-rs" [features] apparmor = [] default = ["sudoedit"] dev = [] do-not-use-all-features = [] pam-login = [] sudoedit = [] [lib] name = "sudo_rs" path = "src/lib.rs" [[bin]] name = "su" path = "bin/su.rs" [[bin]] name = "sudo" path = "bin/sudo.rs" [[bin]] name = "visudo" path = "bin/visudo.rs" [dependencies.glob] version = "0.3.0" [dependencies.libc] version = "0.2.152" [dependencies.log] version = "0.4.11" features = ["std"] [dev-dependencies.pretty_assertions] version = "1.2.1" [lints.clippy] undocumented_unsafe_blocks = "warn" [lints.rust.unsafe_op_in_unsafe_fn] level = "deny" priority = 0 [profile.release] opt-level = "s" lto = true strip = "symbols" sudo-rs-0.2.10/Cargo.toml.orig000064400000000000000000000033371046102023000141750ustar 00000000000000[package] name = "sudo-rs" description = "A memory safe implementation of sudo and su." version = "0.2.10" license = "Apache-2.0 OR MIT" edition = "2021" repository = "https://github.com/trifectatechfoundation/sudo-rs" homepage = "https://github.com/trifectatechfoundation/sudo-rs" publish = true categories = ["command-line-interface"] exclude = ["audit", "proofs", "util"] rust-version = "1.70" default-run = "sudo" [lib] path = "src/lib.rs" [[bin]] name = "sudo" path = "bin/sudo.rs" [[bin]] name = "su" path = "bin/su.rs" [[bin]] name = "visudo" path = "bin/visudo.rs" [dependencies] libc = "0.2.152" glob = "0.3.0" log = { version = "0.4.11", features = ["std"] } [dev-dependencies] pretty_assertions = "1.2.1" [features] default = ["sudoedit"] # when enabled, use "sudo-i" PAM service name for sudo -i instead of "sudo" # ONLY ENABLE THIS FEATURE if you know that original sudo uses "sudo-i" # on the system you are building sudo for (e.g. Debian, Fedora, but not Arch) pam-login = [] # this enables enforcing of AppArmor profiles apparmor = [] # enable detailed logging (use for development only) to /tmp # this will compromise the security of sudo-rs somewhat dev = [] # this feature should never be enabled, but it is here to prevent accidental # compilation using "cargo --all-features", which should not be used on sudo-rs do-not-use-all-features = [] # whether to enable 'sudoedit' (this will become a default feature, or # perhaps we can just remove the feature flag altogether) # NOTE: this is currently work in progress and *NOT* implemented sudoedit = [] [profile.release] strip = "symbols" lto = true opt-level = "s" [lints.rust] unsafe_op_in_unsafe_fn = { level = "deny" } [lints.clippy] undocumented_unsafe_blocks = "warn" sudo-rs-0.2.10/FAQ.md000064400000000000000000000573701046102023000122450ustar 00000000000000# Frequently Asked Questions ## Who is behind sudo-rs? Sudo-rs was originally started as a project by [ISRG](https://www.memorysafety.org), run by [Tweede golf](https://www.tweedegolf.com) and [Ferrous Systems](https://www.ferrous-systems.de). At this point in time it is owned and maintained by the non-profit [Trifecta Tech Foundation](https://trifectatech.org). The sudo-rs team has seen a few changes over time, but the current composition is: - Marc Schoolderman (core team) - Björn Baron (core team) - Ruben Nijveld - Christian Poveda - Jorge Aparicio Marc Schoolderman has an academic background in formal verification of software correctness; Ruben Nijveld is a maintainer of the Rust implementation of the [NTP, NTS and PTP protocols](https://github.com/pendulum-project) and active in the related IETF working group; Christian Poveda is a contributor to important tools such as [bindgen](https://github.com/rust-lang/rust-bindgen), [Miri](https://github.com/rust-lang/miri) and others; Jorge Aparicio has contributed to widely used Rust crates such as [heapless](https://crates.io/crates/heapless) and [cast](https://crates.io/crates/cast) and numerous others; Björn Baron is an [active contributor](https://www.rust-lang.org/governance/teams/compiler) to the Rust compiler. ## I don't like the command name 'sudo-rs'? We don't either! "sudo-rs" is the name of the project, which aims to provide an implementation of the `sudo` command written in Rust. End-users shouldn't *need* to be concerned about what programming language their tools are written in (although there is nothing wrong with taking an interest, of course!). Ideally, your Linux distribution will allow you to easily switch between `sudo` implementations, just as easily as you can switch between `vi` or `awk` implementations. But because for such a long time, `sudo` has been considered a core utility, Linux distributions are still catching up---the amount of work that they have to do to accomodate having multiple sudo options shouldn't be underestimated. And so for the initial versions, many have chosen to rename the command "sudo-rs" to avoid a packaging problem. If you are annoyed by this, do let your voice be heard to the maintainers of the sudo-rs package for your distribution! And be patient---maintaining packages for Linux distributions is largely a volunteer job, and since sudo-rs is a new tool it's only logical that distributions take a conservative approach. ## What will I notice if I start using sudo-rs? In most cases, not that much. Your password prompt will look differently. In other cases, if you have crafted a very specialized configuration, you may notice that some features are missing. This can be by design (e.g. we do not want sudo-rs to send email, or initiate any form of network connection), or simply an oversight. This should not happen with default configurations, so if this happens to you, you should have good chance of diagnosing the change necessary. If you think we are missing a feature that is not on our roadmap, or that we should prioritise higher, *do* file a feature request on our GitHub page! ## What is the advantage of rewriting sudo in Rust? The reasons that were mentioned in the [blog post](https://tweedegolf.nl/en/blog/91/reimplementing-sudo-in-rust) announcing sudo-rs still hold true: 1. Obviously, better memory safety. In C a programmer needs to pay attention at every turn to check that memory is being used correctly. The Rust programming language helps the programmer avoid mistakes by tracking data allocation "at compile time". On top of that, it performs runtime checks to prevent the worst possible outcome in case mistakes do happen. 2. Rust can be used as a systems language, like C, but it also facilitates programming at a much higher level of abstraction. For example, parts of the business logic of sudo-rs are implemented using `enum` types, and evaluated by chaining Rust "iterators" together. And of course our entire code base leans into the ease-of-use offered by `Option` and `Result` types. To achieve the same thing in C, a programmer would need to explicitly implement the logic underpinning those concept themselves. (Which is what you will find that original sudo has done---and that added complexity is where bugs can thrive). 3. A rewrite is also a good time for a rethink. As in every realistic piece of software, there are many many code paths in original sudo [that are seldom exercised](https://www.stratascale.com/vulnerability-alert-CVE-2025-32463-sudo-chroot) in normal usage. Bugs can lurk there as well, undiscovered for years until someone takes a look. But, if some code paths are seldomly executed, why include them at all? This of course is the lesson that OpenBSD's `doas` teaches us. ## Why are you replacing a battle-tested utility? Even though some people like to say that original sudo is "battle tested", that is only true for the most common usage scenarios. You can also say that COBOL is battle tested technology. And since sudo-rs is in fact a derived work of 'original' sudo, we also benefit from the "battle tested"-ness of the original. For example, we have [studied the past CVE's](https://github.com/trifectatechfoundation/sudo-rs/blob/main/docs/sudo-cve.md) so we don't fall prey to them. What is correct to say is that the maintainer of sudo, Todd Miller, has been battle tested. He has had the job of maintaining sudo for many years now, either in his spare time or in time graciously donated by his employer. Many millions of people (including tech giants) benefit from this. ## If I do `grep unsafe` why do I find hundreds of occurrences? Because they are necessary. The `unsafe` keyword is part of Rust's memory safety design. The most important thing it allows is dereferencing "raw pointers", and calling other functions marked as "unsafe", such as those found in the C library. Because sudo-rs is a system utility, it needs to interface with the operating system and system libraries, which are written in C. Most of the `unsafe` code in sudo-rs lives at those seams. A prime example of this is the `setuid()` function itself---without which it would be really hard to write sudo. Also note that about half of our `unsafe` blocks happens in unit test code---to test our "unsafe parts". For the other half, every usage of `unsafe` is accompanied by a `SAFETY` specification, every one of which has been vetted by at least two sudo-rs team members. Finally, wherever it was possible, we use [Miri](https://github.com/rust-lang/miri) to test our `unsafe` blocks to be sure we didn't create any so-called "undefined behaviour". We have seen some attempts at 'myth busting' Rust code by counting the number of times `unsafe` occurs. But that is mistaking the forest for the trees. Of course we understand the criticism: sudo-rs is a new program and needs to prove itself. But we are not spreading myths about sudo-rs having "memory safety-by-design" at its core. At the very least, a few hundred lines of well-documented `unsafe` code is still less than hundreds of thousands of them. ## Why did you get rid of the GNU license? We didn't. sudo is not a GNU tool but a cross-platform software project maintained by Todd Miller. It existed long before the GNU project did. It is licensed under the OpenBSD license, which is functionally equivalent to the MIT license that one can choose for sudo-rs. The reason Trifecta Tech Foundation keeps sudo-rs under the MIT+Apache 2.0 dual license is simply this: it is the most common in the Rust ecosystem, and it is exactly as permissive as original sudo towards end-users. In fact, requiring that external contributors also agree with distribution under Apache 2.0 actually makes sudo-rs a tiny bit more tightly licensed. We understand the objections that some people may have when a piece of software that falls under the GPL gets re-implemented under a more permissive license. It also wouldn't make good engineering sense for sudo-rs to use a more permissive license than Todd Miller uses, because it would mean we wouldn't be able to consult his source code. Trifecta Tech projects that are "re-implementations" typically respect the original license: zlib-rs uses the Zlib license, bzip2-rs uses the bzip2 license, etc. ## What operating systems does sudo-rs support? sudo-rs is fully supported for reasonably modern Linux systems, as well as on FreeBSD. There are some small differences. For example, on FreeBSD, `NOEXEC:` is not supported (since it can't really be implemented with the same level of guarantees as on Linux). Our compliance testing framework comparing `sudo-rs` to `sudo` is also executed on both platforms. In the future, we would also like to support MacOS and be able to say "of course it runs on NetBSD!", but right now we have prioritized other tasks. Patches are welcome! ## What is the official sudo-rs logo? Sadly, this is not a frequently asked question. But it should be! We are not graphics artists and would prefer someone suggests a logo! You can do that [here](https://github.com/trifectatechfoundation/sudo-rs/issues/851). We admire the "mad sandwich" that original sudo has, but it would be wrong to rip that off. ## Comparisons with other tools General remark: we try to honestly represent the advantages and disadvantages in this section, but of course we are hardly unbiased. At the same time, we are not trying to sell you anything, and respect any resources individual developers or companies invest in bringing more open source options to users. ### What about doas? On OpenBSD, doas is great and sudo-rs has taken inspiration from it. But it was written specifically for OpenBSD. On Linux, it is available as the OpenDoas port, which requires quite a bit of glue code (some of which is actually taken directly from Todd Miller's sudo!), and still uses over 5000 lines of C. It also doesn't come with an automated test suite. In the words of the maintainer of OpenDoas: > There are fewer eyes on random `doas` ports, just because `sudo` had a vulnerability > does not mean random doas ports are more secure if they are not reviewed. OpenDoas also has one unresolved CVE related to TTY hijacking for 2 years (https://nvd.nist.gov/vuln/search/results?query=opendoas) for which a remedy isn't easy (https://github.com/Duncaen/OpenDoas/issues/106). This is an attack scenario that sudo-rs, like sudo, util-linux's su and systemd's run0 have remedies for (and have had to spent substantial effort in "getting things right"). It's also clear that *at the time of writing* OpenDoas is not that actively maintained (https://github.com/Duncaen/OpenDoas/pull/124). That being said, we admire the minimalist approach exemplified by doas, and this is expressed by what we internally call our "Berlin Criteria" in our contributing guidelines. If we zoom in on a line-for-line comparison, how does sudo-rs compare to OpenDoas' ~5000 lines of C code? sudo-rs is around 40.000 lines of Rust code. Of those, 25.000 lines are test code, which leaves around 15.000 lines of production code. Of those, less than 350 are "unsafe". If we compare both to original sudo, we find that it contains over 180.000 lines of C. So on this spectrum, it is much closer to doas than to original sudo. On a more practical side, as with run0, switching to doas would require users to rewrite their sudoers configurations to doas configurations. That might be possible in many cases, but not all. This is not say that *you* should not use OpenDoas. TTY hijacking attack might not be relevant for you (for example, because you disabled the feature that allows it in the Linux kernel), and you may need the tiny footprint or prefer the simpler configuration file. But OpenDoas (at least in its current form) isn't a solution for everybody. ### What about run0? Run0 is a tool added to systemd in version 256 which serves a similar purpose to sudo/sudo-rs. Its main aim is to offer controlled privilege escalation without requiring the SUID flag on binaries, by merely functioning as a convenient interface to functionality that was already present in systemd. It is controlled by a security policy implemented through polkit. Having had the experience of writing a SUID program, we can definitely say that we see advantages to that approach. Since systemd is a security-critical component anway, there is something to be said to let it handle privilege escalation as well. However, there are still some trade-offs. - systemd is written in C. Which means it can't take advantage of Rust's memory safety features or higher-level abstractions for capturing the "business logic". And because `run0` itself is an untrusted program that is under full control of an attacker, its overall architecture is less simple than SUID programs such as `sudo` or `doas`. - systemd circumvents that by letting polkit handle policy decisions. But polkit essentially uses configuration files that are small JavaScript programs. We think those are more complex, harder and/or more error-prone to write for a sysadmin than /etc/sudoers or `doas` configurations. And of course, a JavaScript interpreter itself is not a simple piece of software. - sudo-rs can more easily be ported to other platforms -- see our port for FreeBSD. ## Are there actual memory safety vulnerabilities in the original sudo? Serious vulnerabilities in sudo are listed by the developer of C-based sudo, Todd Miller, on https://www.sudo.ws/security/advisories/. The first page lists several memory safety vulnerabilities (anything that says “buffer overflow”, “heap overflow" or “double free”). One of the oldest ones we know of is from 2001, published in Phrack https://phrack.org/issues/57/8 under the whimsical name “Vudo”, which quite dramatically showed an attacker gaining full access on a system that it only had limited access to. A good recent example is the “Baron Samedit” bug that was discovered by security firm Qualys in 2021, which like “Vudo" would cause an uncontrolled privilege escalation. There are many websites and YouTube videos that illustrate it. It is formally identified as CVE-2021-3156 and is described at https://www.sudo.ws/security/advisories/unescape_overflow/ Now, the fine point here of course is: "Baron Samedit” was discovered by security researchers who were working together with the developer of C-based sudo. If you want to know if any of these sudo vulnerabilities have been used to cause harm to systems, we need only look at CISA, that does include it (https://www.cisa.gov/news-events/cybersecurity-advisories/aa22-117a) in its list of “commonly exploited” vulnerabilities of 2021. Also, consider this: the bug behind “Baron Samedit” was present in sudo between 2011 and 2021. That’s a long time. So it’s quite possible that someone already knew it existed before 2021, but simply didn’t tell anybody else. Beyond sudo, a [memory safety vulnerability](https://nvd.nist.gov/vuln/detail/cve-2021-4034) was also discovered in `pkexec`, another sudo-like progam. Note that in real-world attacks, sudo vulnerabilities would usually be combined with exploits in other software. For example, it may be possible to gain limited access on a machine by using an exploit in a webserver. If that machine then has a seriously vulnerable outdated sudo on it that allows an attacker to turn that limited access into full access, what may look like a minor bug in a webserver can turn into a nightmare. I.e. memory safety bugs in sudo have the potential to dramatically amplify the impact of bugs in other pieces of software. ## Are there fewer bugs in sudo-rs? This is an unanswerable question. The real question of course is: what is the *probability* of discovering a bug in sudo-rs, compared to that in original sudo? One the one hand: we are a newer project, so we are likely to have messed up at some point. So for sure, we expect that sudo-rs will have some bugs that original sudo doesn't have. We have even shipped sudo-rs versions that had known bugs, and we will probably continue to do so in the future. Being open about this is normal for open source/free software. On the other hand, we have found several bugs in original sudo while we were implementing sudo-rs, and through our compliance testing framework we can clearly see that original sudo also is still actively introducing and fixing bugs. Most of the bugs we are talking about here as so small that no ordinary user will ever encounter them. Many of the more noticeable bugs have already been discovered by early adopters of sudo-rs, who have been sending in bug reports for over two years. This is all talking about simple *bugs*. For vulnerabilities, we dare to give a bolder answer. Sudo-rs uses a memory safe-by-design approach with the aim of dramatically lowering the risk of a memory safety bug. For original sudo, this risk is only reduced compared to other C projects because it has been around for a long time. We expect the probability of a memory safety vulnerability to be discovered in sudo-rs to be dramatically small---especially because we know which parts of the code they could be hiding in. And to this day, none have been found. For non-memory safety related vulnerabilities, we rely on our reduced feature set. Two recent CVE's in original sudo, [CVE-2025-32463](https://www.sudo.ws/security/advisories/chroot_bug/) and [ CVE-2025-32462](https://www.sudo.ws/security/advisories/host_any/) did not affect sudo-rs because of this reason. Secondly, because Rust allows describing the "business logic" in a more humanly readable way than C, it would also have been highly unlikely that we would have been suspectible to [CVE-2023-22809](https://www.sudo.ws/security/advisories/sudoedit_any/). ## Has sudo-rs been audited? Twice. By [Radically Open Security](https://www.radicallyopensecurity.com). The first audit took place in August of 2023 and uncovered [a path traversal vulnerability](https://github.com/trifectatechfoundation/sudo-rs/security/advisories/GHSA-2r3c-m6v7-9354) that also affected original sudo. A second audit in 2025 found no new vulnerabilities. Furthermore, an information leak vulnerability was discovered by cybersecurity enthusiast [Sonia Zorba](https://www.zonia3000.net). ## Is there a reason to not switch to sudo-rs? Certainly. There are features that sudo-rs doesn't support (such as sending mail, storing the sudoers file in LDAP, matching commands using regular expressions). If you cannot upgrade your workflow to not need those features, you may need to stick with sudo. Also, original sudo is a highly portable program and runs a highly diverse set of operating systems. Sudo-rs is only available for Linux and FreeBSD. There may also be socio-economic reasons. If you are operating in a highly conservative environment, that may also be a reason why you might prefer sudo: it has a lot of history behind it and is widely accepted. If you're the sysadmin that installed sudo on every workstation in your organization, you're unlikely to be blamed if a vulnerability is discovered. The good news is, original sudo is still being maintained. All sudo-rs is does is give you more freedom to choose which implementation of sudo to use. Freedom is good. ## What is the "test framework" all about? To ensure compatibility with original sudo, we have created an extensive set of integration tests where we construct a specified machine configuration inside a `docker` container, run a command with original sudo, run the same command with sudo-rs, and check whether the results are equivalent. This ensures that code paths are tested that are hard to test with only unit tests, and it also demonstrates that sudo-rs is a "drop-in replacement" for many cases. Several surprising original sudo behaviours were discovered while developing this test framework. Most of these turned out to be bugs in original sudo that we reported to Todd Miller and were promptly fixed. This testing framework also acts as an extra set of regression tests for original sudo--we discovered this recently while transitioning from Debian Bookworm to Debian Trixie. For details you can read the blog by sudo-rs engineer Jorge Aparicio: https://ferrous-systems.com/blog/testing-sudo-rs/ ## How is the original sudo developer involved in your project? Todd Miller is not part of the sudo-rs team, but he has collaborated with us and has frequently served as an advisor. For example, whenever we discovered behaviour in original sudo and were not sure whether to copy this or not, he would [chime in](https://github.com/trifectatechfoundation/sudo-rs/issues/427#issuecomment-1589619556) with useful advice. We have collaborated on vulnerabilities that required mitigations from both of us, for example around our three advisories, as well as [CVE-2023-42465](https://www.packetlabs.net/posts/sudo-command-is-vulnerable-to-rowhammer/) and [CVE-2023-2002](https://www.cve.org/CVERecord?id=CVE-2023-2002)). He has also [fixed a bug](https://github.com/trifectatechfoundation/sudo-rs/pull/1017) in the FreeBSD port of sudo-rs. ## How did sudo-rs development affect original sudo? During the development of our testing framework, we exercised some code paths in original sudo that were rarely used, and (not surprisingly), several bugs were discovered that way; most of which had a no or only a slight security impact. This also furthered the harmonization between sudo-rs and sudo on their common feature set. Bugs fixed in sudo 1.9.14: * https://github.com/sudo-project/sudo/commit/471028351650aa4477e59a1701608ffae5b1d4a2 * https://github.com/sudo-project/sudo/commit/8c1559e0e34fa83b061f148b63fc8e091a4b2517 * https://github.com/sudo-project/sudo/commit/64ab8cd23643feced561a1aabcc6be547e81ad58 * https://github.com/sudo-project/sudo/commit/78e65e14ea18278a904beddd54b964609b715762 Bugs fixed in sudo 1.9.15: * https://github.com/sudo-project/sudo/commit/e7d4c05acea3f15fd8bcc4949acb7e06940284c1 * https://github.com/sudo-project/sudo/commit/1c7a20d7447937cd2e29b61c9c013f5b1df76fd6 * https://github.com/sudo-project/sudo/commit/d1625f9c8325abe4f5c3706d4ac9442fcccc91ad * https://github.com/sudo-project/sudo/commit/db704c22ec248c871907cfd966091a28332e1d0f * https://github.com/sudo-project/sudo/commit/d486db46cf25f09b19aeb9109d58531f3a3d2d33 * https://github.com/sudo-project/sudo/commit/7363ad7b3230b7b03a83f68a0ea33b4144c78a79 Bugs fixed in sudo 1.9.17: * https://github.com/sudo-project/sudo/commit/0be9f0f947139b32feaa5cd7b5d858069e0af48c * https://github.com/sudo-project/sudo/commit/4dbb07c19bdeba34c93243adeb0114715afff473 * https://github.com/sudo-project/sudo/commit/b04386f63163d99eb67a78f9af8515b3af13c8b0 * https://github.com/sudo-project/sudo/commit/82ebb1eaa92368952ae92ee0819b573f24e304cd * https://github.com/sudo-project/sudo/commit/28837b2af1d98c08f0cb75dd075fc290435775a1 Bugs fixed in sudo 1.9.18: * https://github.com/sudo-project/sudo/commit/12724d1b73d6d7dd3197ceadefdd9e600fcda537 * https://github.com/sudo-project/sudo/commit/e2a2982153a39eb793adfc9f2a8524adfdae8c6f Time permitting, we would also like to contribute our improvements to the Linux seccomp-based `NOEXEC` mechanism back to the sudo project. ## Do you participate in a bug bounty program? We do not at the moment---also given the [experiences of other open source projects](https://arstechnica.com/gadgets/2025/05/open-source-project-curl-is-sick-of-users-submitting-ai-slop-vulnerabilities/). If you discover a vulnerability in sudo-rs, follow the instructions in our [security policy](https://github.com/trifectatechfoundation/sudo-rs/blob/main/SECURITY.md). If we agree that it is a vulnerability we will publicly acknowledge this in our repository---you will get the public credit. ## Can I contribute to sudo-rs? Yes! In fact, we also believe that the newer code base, written in a safer language, actually lends itself well for being more accepting of outside contributions. Multiple features/bugs in sudo-rs have already been implemented/fixes by external contributors. We have a [contributors' guide](https://github.com/trifectatechfoundation/sudo-rs/blob/main/CONTRIBUTING.md) which lists some of the things to be mindful of. Happy hacking!sudo-rs-0.2.10/LICENSE-APACHE000064400000000000000000000227731046102023000132370ustar 00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS sudo-rs-0.2.10/LICENSE-MIT000064400000000000000000000021641046102023000127370ustar 00000000000000Copyright (c) 2022-2025 Trifecta Tech Foundation and contributors Copyright (c) 1994-1996, 1998-2024 Todd C. Miller Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. sudo-rs-0.2.10/Makefile000064400000000000000000000022421046102023000127400ustar 00000000000000PAM_SRC_DIR = src/pam BINDGEN_CMD = bindgen --allowlist-function '^pam_.*$$' --allowlist-var '^PAM_.*$$' --opaque-type pam_handle_t --blocklist-function pam_vsyslog --blocklist-function pam_vprompt --blocklist-function pam_vinfo --blocklist-function pam_verror --blocklist-type '.*va_list.*' --ctypes-prefix libc --no-layout-tests --sort-semantically PAM_VARIANT = $$(./get-pam-variant.bash) .PHONY: all clean pam-sys pam-sys-diff pam-sys-diff: @$(BINDGEN_CMD) $(PAM_SRC_DIR)/wrapper.h | \ sed 's/rust-bindgen [0-9]*\.[0-9]*\.[0-9]*/&, minified by cargo-minify/' | \ diff --color=auto $(PAM_SRC_DIR)/sys_$(PAM_VARIANT).rs - \ || (echo run \'make -B pam-sys\' to apply these changes && false) @echo $(PAM_SRC_DIR)/sys_$(PAM_VARIANT).rs does not need to be re-generated # use 'make pam-sys' to re-generate the sys.rs file for your local platform pam-sys: $(BINDGEN_CMD) $(PAM_SRC_DIR)/wrapper.h --output $(PAM_SRC_DIR)/sys_$(PAM_VARIANT).rs cargo minify --apply --allow-dirty sed -i.bak 's/rust-bindgen [0-9]*\.[0-9]*\.[0-9]*/&, minified by cargo-minify/' $(PAM_SRC_DIR)/sys_$(PAM_VARIANT).rs rm $(PAM_SRC_DIR)/sys_$(PAM_VARIANT).rs.bak clean: rm $(PAM_SRC_DIR)/sys.rs sudo-rs-0.2.10/README.md000064400000000000000000000274741046102023000125750ustar 00000000000000# sudo-rs A safety oriented and memory safe implementation of sudo and su written in Rust. ## Status of this project Sudo-rs is being developed further; features you might expect from original sudo may still be unimplemented or not planned. If there is an important one you need, please request it using the issue tracker. If you encounter any usability bugs, also please report them on the [issue tracker](https://github.com/trifectatechfoundation/sudo-rs/issues). Suspected vulnerabilities can be reported on our [security page](https://github.com/trifectatechfoundation/sudo-rs/security). Sudo-rs has been audited twice: an audit of version 0.2.0 was performed in August 2023, and a second audit of version 0.2.8 in August 2025. The audit reports can be found [here](docs/audit). Sudo-rs currently is targeted for FreeBSD and Linux-based operating systems only. ## Installing sudo-rs You can install sudo-rs using the package manager of your Linux distribution. Many Linux distributions will also keep original sudo installed and so offer sudo-rs using modified command names. You can work around that by creating e.g. an `alias`, but that will only change your own invocations of `sudo` to sudo-rs and not affect other programs and scripts that use `sudo`. To avoid that and/or to get the latest version, you can use our prepackaged binaries (see below). ### Ubuntu 25.10 (Questing Quokka) sudo-rs is installed and enabled by default; you can control which sudo version is being used by running ```sh update-alternatives --config sudo ``` The sudo-rs package is based on v0.2.8 with additional bug fixes that will be part of v0.2.9. ### Arch Linux sudo-rs can be installed from the distribution repositories: ```sh pacman -S sudo-rs ``` This will offer the functionality using the commands `sudo-rs`, `sudoedit-rs`, `visudo-rs` and `su-rs` to avoid conflicts. The sudo-rs package on Arch Linux is typically up-to-date. ### Fedora If you are running Fedora 41 or later, you can use: ```sh dnf install sudo-rs ``` This will offer the functionality using the commands `sudo-rs`, `visudo-rs` and `su-rs` to avoid conflicts. The version packaged is based on release 0.2.6 from May 2025 which is missing `sudoedit`, `NOEXEC:`, and a few other improvements. ### Debian If you are running Debian 13 (trixie) or later you can use: ```sh apt-get install sudo-rs ``` This will offer the functionality using the commands `sudo-rs`, `visudo-rs`. If you want to invoke sudo-rs via the usual commands `sudo` and `visudo` instead, prepend `/usr/lib/cargo/bin` to your current `$PATH` variable. Due to a misconfiguration in this package, `su-rs` cannot be used because it does not have the setuid flag set. The sudo-rs version packaged in Debian 13 (trixie) is based on release 0.2.5 from April 2025 which is missing `sudoedit`, `NOEXEC:`, and several other improvements, but is up-to-date with respect to security patches. Debian unstable (sid) may have a newer version. ### FreeBSD We are maintaining the FreeBSD port of sudo-rs ourselves, which is available in the ports tree. Sudo-rs is available in two flavours: ``` pkg install sudo-rs ``` To get sudo-rs using the commands `sudo`, `visudo` and `sudoedit`. This conflicts with the `security/sudo` package and so you cannot have both installed at the same time. Alternatively, ``` pkg install sudo-rs-coexist ``` Installs the commands as `sudo-rs`, `visudo-rs`' and `sudoedit-rs` and does not conflict with the `security/sudo` package. To run these commands, the `pkg` utility needs to be using the `2025Q4` quarterly version (or later) of the ports tree. To use the absolute latest version, you can [switch from quarterly to `latest`](https://wiki.freebsd.org/Ports/QuarterlyBranch#How_to_switch_from_quarterly_to_latest). ### NixOS On NixOS sudo-rs can be installed by adding the following to your configuration: ```nix security.sudo-rs.enable = true; ``` This will replace the usual `sudo` and `sudoedit` commands. ### Installing our pre-compiled x86-64 binaries You can also switch to sudo-rs manually by using our pre-compiled tarballs. We currently only offer these for x86-64 Linux systems. We recommend installing sudo-rs and su-rs in your `/usr/local` hierarchy so it does not affect the integrity of the package manager of your Linux distribution. You can achieve this using the commands: ```sh sudo tar -C /usr/local -xvf sudo-0.2.10.tar.gz ``` and for su-rs: ```sh sudo tar -C /usr/local -xvf su-0.2.10.tar.gz ``` This will install sudo-rs and su-rs in `/usr/local/bin` using the usual commands `sudo`, `visudo`, `sudoedit` and `su`. Please double check that in your default `PATH`, the folders `/usr/local/bin` and `/usr/local/sbin` have priority over `/usr/bin` and `/usr/sbin`. If you **don't** have Todd Miller's `sudo` installed, you also have to make sure that: * You manually create a `/etc/sudoers` or `/etc/sudoers-rs` file, this could be as simple as: Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" %sudo ALL=(ALL:ALL) ALL `sudo-rs` will try to process `/etc/sudoers-rs` if it exists, otherwise it will use `/etc/sudoers`. For an explanation of the sudoers syntax you can look at the [sudoers man page](https://www.sudo.ws/docs/man/sudoers.man/). * (Strongly recommended) You create `/etc/pam.d/sudo` and `/etc/pam.d/sudo-i` files that contain (for Debian/Ubuntu): session required pam_limits.so @include common-auth @include common-account @include common-session-noninteractive If you don't do this, either a "fallback" PAM policy will be used or `sudo-rs` will simply refuse to run since it cannot initialize PAM. On Fedora, the syntax for PAM configuration is slightly different, but the correct PAM configuration files will most likely be already installed. On FreeBSD, you may want to put these files in `/usr/local/etc/pam.d` instead. ### Building from source Sudo-rs is written in Rust. The minimum required Rust version is 1.70. If your Linux distribution does not package that version (or a later one), you can always install the most recent version through [rustup]. You also need the C development files for PAM (`libpam0g-dev` on Debian, `pam-devel` on Fedora). On Ubuntu or Debian-based systems, use the following command to install the PAM development library: ``` sudo apt-get install libpam0g-dev ``` On Fedora, CentOS and other Red Hat-based systems, you can use the following command: ``` sudo yum install pam-devel ``` With dependencies installed, building sudo-rs is a simple matter of: ``` cargo build --release ``` This produces a binary `target/release/sudo`. However, this binary must have the setuid flag set and must be owned by the root user in order to provide any useful functionality. Consult your operating system manual for details. Sudo-rs then also needs the configuration files; please follow the installation suggestions in the previous section. ### Feature flags #### --features pam-login By default, sudo-rs will use the PAM service name `sudo`. On Debian and Fedora systems, it is customary that the name `sudo-i` is used when the `-i / --login` command line option is used. To get this behaviour, enable the `pam-login` feature when building: ``` cargo build --release --features pam-login ``` This feature is enabled on our pre-supplied binaries. #### --features apparmor sudo-rs has support for selecting AppArmor profile on Linux distributions that support AppArmor such as Debian and Ubuntu. To enable this feature, build sudo-rs with apparmor support enabled: ``` cargo build --release --features apparmor ``` This feature is disabled on our pre-supplied binaries. [rustup]: https://rustup.rs/ ## Differences from original sudo sudo-rs supports less functionality than sudo. Some of this is by design. In most cases you will get a clear error if you try something that is not supported (e.g. use a configuration flag or command line option that is not implemented). Exceptions to the above, with respect to your `/etc/sudoers` configuration: * `use_pty` is enabled by default, but can be disabled. * `env_reset` is ignored --- this is always enabled. * `visiblepw` is ignored --- this is always disabled. * `verifypw` is ignored --- this is always set to `all` (the default) * the (NO)PASSWD tag on the "list" pseudocommand will determine whether a password is required for the `sudo -U --list` command, instead of `listpw`. * `mail_badpass`, `always_set_home`, `always_query_group_plugin` and `match_group_by_gid` are not applicable to our implementation, but ignored for compatibility reasons. * `timestamp_type` is always set at `tty`. * `sudoedit_checkdir` is always `on`, and `sudoedit_follow` is always `off`. Some other notable restrictions to be aware of: * Some functionality is not supported, such as preventing shell escapes using `INTERCEPT` and storing config in LDAP using `sudoers.ldap`, and `cvtsudoers`. * Sudo-rs always uses PAM for authentication, so your system must be set up for PAM. Sudo-rs will use the `sudo` and `sudo-i` service configuration. This also means that resource limits, umasks, etc have to be configured via PAM and not through the sudoers file. * sudo-rs will not include the sendmail support of original sudo. * The sudoers file must be valid UTF-8. * To prevent a common configuration mistake in the sudoers file, wildcards are not supported in *argument positions* for a command. E.g., `%sudoers ALL = /sbin/fsck*` will allow `sudo fsck` and `sudo fsck_exfat` as expected, but `%sudoers ALL = /bin/rm *.txt` will not allow an operator to run `sudo rm README.txt`, nor `sudo rm -rf /home .txt`, as with original sudo. If you find a common use case for original sudo missing, please create a feature request for it in our issue tracker. ## Aim of the project Our current target is to build a drop-in replacement for all common use cases of sudo. For the sudoers config syntax this means that we support the default configuration files of common Linux distributions. Our implementation should support all commonly used command line options from the original sudo implementation. Some parts of the original sudo are explicitly not in scope. Sudo has a large and rich history and some of the features available in the original sudo implementation are largely unused or only available for legacy platforms. In order to determine which features make it we both consider whether the feature is relevant for modern systems, and whether it will receive at very least decent usage. Finally, of course, a feature should not compromise the safety of the whole program. Our `su` implementation is made using the building blocks we created for our sudo implementation. It is a suitable replacement for the `su` distributed by [util-linux]. [util-linux]: https://github.com/util-linux/util-linux ## Future work While our initial target is a drop-in replacement for most basic use cases of sudo, our work may evolve beyond that target. We are also looking into alternative ways to configure sudo without the sudoers config file syntax and to extract parts of our work in usable crates for other people. ## History The initial development of sudo-rs was started and funded by the [Internet Security Research Group](https://www.abetterinternet.org/) as part of the [Prossimo project](https://www.memorysafety.org/) ## Acknowledgements Sudo-rs is an independent implementation, but it incorporates documentation and Rust translations of code from [sudo](https://www.sudo.ws/), maintained by Todd C. Miller. We thank Todd and the other sudo contributors for their work. An independent security audit of sudo-rs was made possible by the [NLNet Foundation](https://nlnet.nl/), who also [sponsored](https://nlnet.nl/project/sudo-rs/) work on increased compatibility with the original sudo and the FreeBSD port. The sudo-rs project would not have existed without the support of its sponsors, a full overview is maintained at https://trifectatech.org/initiatives/privilege-boundary/ sudo-rs-0.2.10/SECURITY.md000064400000000000000000000031061046102023000130710ustar 00000000000000# Security policy **Do not report security vulnerabilities through public GitHub issues.** Instead, you can report them using [our security page](https://github.com/trifectatechfoundation/sudo-rs/security). Alternatively, you can also send them by email to security+sudo@tweedegolf.com. You can encrypt your email using GnuPG if you want. Use the GPG key with fingerprint [C2E4 CAC4 B122 25DE 1C3B B1C9 289D 0820 03D0 1E95](https://keys.openpgp.org/search?q=C2E4CAC4B12225DE1C3BB1C9289D082003D01E95). Include as much of the following information: * Type of issue (e.g. buffer overflow, privilege escalation, etc). * The location of the affected source code (tag/branch/commit or direct URL). * Any special configuration required to reproduce the issue. * The Linux distribution affected. * Step-by-step instructions to reproduce the issue. * Impact of the issue, including how an attacker might exploit the issue. If you have found a bug that also exists in the original sudo (which, although unlikely, means it is a very serious issue), you **must** also follow the steps at https://www.sudo.ws/security/policy/ ## Preferred Languages We prefer to receive reports in English. If necessary, we also understand Spanish, German and Dutch. ## Disclosure Policy Like original sudo, we adhere to the principle of [Coordinated Vulnerability Disclosure](https://vuls.cert.org/confluence/display/CVD/Executive+Summary). # Security Advisories Security advisories will be published [on GitHub](https://github.com/trifectatechfoundation/sudo-rs/security/advisories) and possibly through other channels. sudo-rs-0.2.10/bin/su.rs000064400000000000000000000000461046102023000130450ustar 00000000000000fn main() { sudo_rs::su_main(); } sudo-rs-0.2.10/bin/sudo.rs000064400000000000000000000000501046102023000133630ustar 00000000000000fn main() { sudo_rs::sudo_main(); } sudo-rs-0.2.10/bin/visudo.rs000064400000000000000000000000511046102023000137230ustar 00000000000000fn main() { sudo_rs::visudo_main() } sudo-rs-0.2.10/build.rs000064400000000000000000000022601046102023000127450ustar 00000000000000use std::path::Path; // Return the first existing path given a list of paths as string slices fn get_first_path(paths: &[&'static str]) -> Option<&'static str> { paths.iter().find(|p| Path::new(p).exists()).copied() } fn main() { let path_zoneinfo: &str = get_first_path(&[ "/usr/share/zoneinfo", "/usr/share/lib/zoneinfo", "/usr/lib/zoneinfo", "/usr/lib/zoneinfo", ]) .expect("no zoneinfo database"); let path_maildir: &str = get_first_path(&["/var/mail", "/var/spool/mail", "/usr/spool/mail"]).unwrap_or("/var/mail"); // TODO: use _PATH_STDPATH and _PATH_DEFPATH_ROOT from paths.h println!("cargo:rustc-env=SUDO_PATH_DEFAULT=/usr/bin:/bin:/usr/sbin:/sbin"); println!( "cargo:rustc-env=SU_PATH_DEFAULT_ROOT=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ); println!( "cargo:rustc-env=SU_PATH_DEFAULT=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games" ); println!("cargo:rustc-env=PATH_MAILDIR={path_maildir}"); println!("cargo:rustc-env=PATH_ZONEINFO={path_zoneinfo}"); println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rustc-link-lib=pam"); } sudo-rs-0.2.10/clippy.toml000064400000000000000000000000511046102023000134710ustar 00000000000000msrv = "1.70" check-private-items = true sudo-rs-0.2.10/docs/man/su.1.man000064400000000000000000000026321046102023000142710ustar 00000000000000.\" Automatically generated by Pandoc 3.6.3 .\" .TH "SU" "1" "" "sudo\-rs 0.2.10" "sudo\-rs" .SH NAME \f[CR]su\f[R] \- run a shell or command as another user .SH SYNOPSIS \f[CR]su\f[R] options [\-] [<\f[I]user\f[R]> [<\f[I]argument\f[R]>\&...]] .SH OPTIONS .TP \f[CR]\-c\f[R] \f[I]command\f[R], \f[CR]\-\-command\f[R]=\f[I]command\f[R] Pass a single command to the shell with \f[CR]\-c\f[R]. .TP \f[CR]\-g\f[R] \f[I]group\f[R], \f[CR]\-\-group\f[R]=\f[I]group\f[R] Specify the primary group .TP \f[CR]\-G\f[R] \f[I]group\f[R], \f[CR]\-\-supp\-group\f[R]=\f[I]group\f[R] Specify a supplemental group .TP \f[CR]\-h\f[R], \f[CR]\-\-help\f[R] Show a help message. .TP \f[CR]\-\f[R], \f[CR]\-l\f[R], \f[CR]\-\-login\f[R] Make the shell a login shell .TP \f[CR]\-m\f[R], \f[CR]\-p\f[R], \f[CR]\-\-preserve\-environment\f[R] Do not reset environment variables .TP \f[CR]\-P\f[R], \f[CR]\-\-pty\f[R] Create a new pseudo\-terminal when running the shell. .TP \f[CR]\-w\f[R] \f[I]list\f[R], \f[CR]\-\-whitelist\-environment\f[R]=\f[I]list\f[R] Do not reset the environment variables specified by the \f[I]list\f[R]. Multiple variables can be separated by commas. .TP \f[CR]\-s\f[R] \f[I]shell\f[R], \f[CR]\-\-shell\f[R]=\f[I]shell\f[R] Run \f[I]shell\f[R] if \f[CR]/etc/shells\f[R] allows running as that shell instead of the default shell for the user. .TP \f[CR]\-V\f[R], \f[CR]\-\-version\f[R] Show the program version. .SH SEE ALSO sudo(8) sudo-rs-0.2.10/docs/man/su.1.md000064400000000000000000000020101046102023000141040ustar 00000000000000--- title: SU(1) sudo-rs 0.2.10 | sudo-rs --- # NAME `su` - run a shell or command as another user # SYNOPSIS `su` [options] [-] [<*user*> [<*argument*>...]] # OPTIONS `-c` *command*, `--command`=*command* : Pass a single command to the shell with `-c`. `-g` *group*, `--group`=*group* : Specify the primary group `-G` *group*, `--supp-group`=*group* : Specify a supplemental group `-h`, `--help` : Show a help message. `-`, `-l`, `--login` : Make the shell a login shell `-m`, `-p`, `--preserve-environment` : Do not reset environment variables `-P`, `--pty` : Create a new pseudo-terminal when running the shell. `-w` *list*, `--whitelist-environment`=*list* : Do not reset the environment variables specified by the *list*. Multiple variables can be separated by commas. `-s` *shell*, `--shell`=*shell* : Run *shell* if `/etc/shells` allows running as that shell instead of the default shell for the user. `-V`, `--version` : Show the program version. # SEE ALSO [sudo(8)](sudo.8.md) sudo-rs-0.2.10/docs/man/sudo.8.man000064400000000000000000000201371046102023000146230ustar 00000000000000.\" Automatically generated by Pandoc 3.6.3 .\" .TH "SUDO" "8" "" "sudo\-rs 0.2.10" "sudo\-rs" .SH NAME \f[CR]sudo\f[R], \f[CR]sudoedit\f[R] \- execute a command as another user .SH SYNOPSIS \f[CR]sudo\f[R] \f[CR]\-h\f[R] | \f[CR]\-K\f[R] | \f[CR]\-k\f[R] | \f[CR]\-V\f[R] .PD 0 .P .PD \f[CR]sudo\f[R] [\f[CR]\-u\f[R] \f[I]user\f[R]] [\f[CR]\-g\f[R] \f[I]group\f[R]] [\f[CR]\-D\f[R] \f[I]directory\f[R]] [\f[CR]\-BknS\f[R]] [\f[CR]\-i\f[R] | \f[CR]\-s\f[R]] [\f[CR]VAR=value\f[R]] [<\f[I]command\f[R]>] .PD 0 .P .PD \f[CR]sudo\f[R] \f[CR]\-v\f[R] [\f[CR]\-BknS\f[R]] [\f[CR]\-u\f[R] \f[I]user\f[R]] [\f[CR]\-g\f[R] \f[I]group\f[R]] .PD 0 .P .PD \f[CR]sudo\f[R] \f[CR]\-l\f[R] [\f[CR]\-BknS\f[R]] [\f[CR]\-U\f[R] \f[I]user\f[R]] [\f[CR]\-u\f[R] \f[I]user\f[R]] [\f[CR]\-g\f[R] \f[I]group\f[R]] [command [arg \&...]] .PD 0 .P .PD \f[CR]sudo\f[R] \f[CR]\-e\f[R] [\f[CR]\-BknS\f[R]] [\f[CR]\-u\f[R] \f[I]user\f[R]] [\f[CR]\-g\f[R] \f[I]group\f[R]] file \&... .PD 0 .P .PD \f[CR]sudoedit\f[R] [\f[CR]\-BknS\f[R]] [\f[CR]\-u\f[R] \f[I]user\f[R]] [\f[CR]\-g\f[R] \f[I]group\f[R]] file \&... .SH DESCRIPTION \f[CR]sudo\f[R] allows a user that is permitted to do so to execute a \f[I]command\f[R] as another user (for example \f[I]root\f[R]). Permissions are specified by a security policy specified in \f[CR]/etc/sudoers\f[R] (see sudoers(5)). .PP Sudo\-rs is a safety oriented and memory safe re\-implementation of the original sudo implementation by Todd Miller. .PP When a command is run, a session record is stored for that specific session allowing users to run additional commands without having to re\-authenticate. The timeout for session records can be specified in the policy. .PP Some care is taken to pass signals received by sudo\-rs to the child process, even if that process runs in its own pseudo terminal. .PP On systems where sudo is the primary method of gaining superuser privileges, it is imperative to avoid syntax errors in the \f[CR]/etc/sudoers\f[R] file. Changes to this file should be made using the visudo(8) utility which will ensure that no syntax errors are introduced. .SH OPTIONS .TP \f[CR]\-B\f[R], \f[CR]\-\-bell\f[R] Ring the bell as part of the password prompt when a terminal is present. .TP \f[CR]\-D\f[R] \f[I]directory\f[R], \f[CR]\-\-chdir\f[R]=\f[I]directory\f[R] Run the \f[I]command\f[R] in the specified \f[I]directory\f[R] instead of the current working directory. The security policy may return an error if the user does not have the permission to specify the working directory. .TP \f[CR]\-g\f[R] \f[I]group\f[R], \f[CR]\-\-group\f[R]=\f[I]group\f[R] Use this \f[I]group\f[R] as the primary group instead of using the primary group specified in the password database for the target user. .TP \f[CR]\-h\f[R], \f[CR]\-\-help\f[R] Show a help message. .TP \f[CR]\-i\f[R], \f[CR]\-\-login\f[R] Run the shell specified by the target user\[cq]s password database entry as a login shell. This means that login\-specific resource files such as \f[I].profile\f[R], \f[I].bash_profile\f[R] or \f[I].login\f[R] will be read by the shell. If a \f[I]command\f[R] is specified, it is passed to the shell using the \f[CR]\-c\f[R] option. .TP \f[CR]\-K\f[R], \f[CR]\-\-remove\-timestamp\f[R] Removes every cached session record for the user, regardless of where the command is executed. The next time sudo\-rs is run, authentication will take place if the policy requires it. No password is required to run this command. .TP \f[CR]\-k\f[R], \f[CR]\-\-reset\-timestamp\f[R] When used without a command, invalidates the user\[cq]s session record for the current session. The next time sudo\-rs is run, authentication will take place if the policy requires it. .RS .PP When used in conjunction with a \f[I]command\f[R] or an option that may require a password, this option will cause sudo\-rs to ignore the user\[cq]s session record. As a result, authentication will take place if the policy requires it. When used in conjunction with a \f[I]command\f[R] no invalidation of existing session records will take place. .RE .TP \f[CR]\-n\f[R], \f[CR]\-\-non\-interactive\f[R] Avoid prompting the user for input of any kind. If any input is required for the \f[I]command\f[R] to run, sudo\-rs will display an error message and exit. .TP \f[CR]p\f[R], \f[CR]\-\-prompt\f[R]=\f[I]prompt\f[R] Use a custom authentication prompt with optional escape sequences. The following percent (`%') escape sequences are supported: .RS .IP .EX %H expanded to the local host name %h expanded to the local host name without the domain name %p expanded to the name of the user whose password is being requested (this respects the rootpw, targetpw flags) %U expanded to the login name of the user the command will be run as (defaults to root unless the \-u option is also specified) %u expanded to the invoking user\[aq]s login name %% two consecutive \[oq]%\[cq] characters are collapsed into a single \[oq]%\[cq] character .EE .PP The custom prompt will override the default prompt or the one specified by the SUDO_PROMPT environment variable. No \f[I]prompt\f[R] will suppress the prompt provided by PAM, unless the requested \f[I]prompt\f[R] is empty (\f[CR]\[dq]\[dq]\f[R]) .RE .TP \f[CR]\-S\f[R], \f[CR]\-\-stdin\f[R] Read from standard input instead of using the terminal device. .TP \f[CR]\-s\f[R], \f[CR]\-\-shell\f[R] Run the shell specified by the \f[CR]SHELL\f[R] environment variable. If no shell was specified, the shell from the user\[cq]s password database entry will be used instead. If a \f[I]command\f[R] is specified, it is passed to the shell using the \f[CR]\-c\f[R] option. .PP \f[CR]\-e\f[R], \f[CR]sudoedit\f[R] .IP .EX Edit one or more files instead of running a command. In lieu of a path name, the string \[dq]sudoedit\[dq] is used when consulting the security policy. If the user is authorized by the policy, the following steps are taken: 1. Temporary copies are made of the files to be edited with the owner set to the invoking user. 2. The editor specified by the policy is run to edit the temporary files. The sudoers policy uses the SUDO_EDITOR, VISUAL and EDITOR environment variables (in that order). If none of SUDO_EDITOR, VISUAL or EDITOR are set, the first program listed in the editor sudoers(5) option is used. 3. If they have been modified, the content of the temporary files is copied back to the originals and the temporary versions are removed. To help prevent the editing of unauthorized files, the following restrictions are enforced (unless the user is root): * Symbolic links may not be edited. * If any component of the path leading to the file is writable by the invoking user, the file may not be edited. * Users are never allowed to edit device special files. If the specified file does not exist, it will be created. Unlike most commands run by sudo, the editor is run with the invoking user\[aq]s environment unmodified. If the temporary file becomes empty after editing, the user will be prompted before it is installed. .EE .TP \f[CR]\-u\f[R] \f[I]user\f[R], \f[CR]\-\-user\f[R]=\f[I]user\f[R] Run the \f[I]command\f[R] as another user than the default (\f[B]root\f[R]). .TP \f[CR]\-V\f[R], \f[CR]\-\-version\f[R] Display the current version of sudo\-rs. .TP \f[CR]\-v\f[R], \f[CR]\-\-validate\f[R] Update the session record for the current session, authenticating the user if necessary. .TP \f[CR]\-l\f[R], \f[CR]\-\-list\f[R] List user\[cq]s privileges or check a specific command; use twice for longer format .TP \f[CR]\-U\f[R], \f[CR]\-\-other\-user\f[R]=\f[I]user\f[R] Used in list mode, display privileges for another user .TP \f[CR]\-\-\f[R] Indicates the end of the sudo\-rs options and start of the \f[I]command\f[R]. .PP Environment variables to be set for the command may be passed on the command line in the form of VAR=value. Variables passed on the command line are subject to restrictions imposed by the security policy. Variables passed on the command line are subject to the same restrictions as normal environment variables with one important exception: If the command to be run has the SETENV tag set or the command matched is ALL, the user may set variables that would otherwise be forbidden. See sudoers(5) for more information. .SH SEE ALSO su(1), sudoers(5), visudo(8) sudo-rs-0.2.10/docs/man/sudo.8.md000064400000000000000000000161561046102023000144560ustar 00000000000000--- title: SUDO(8) sudo-rs 0.2.10 | sudo-rs --- # NAME `sudo`, `sudoedit` - execute a command as another user # SYNOPSIS `sudo` `-h` | `-K` | `-k` | `-V`\ `sudo` \[`-u` *user*\] \[`-g` *group*\] \[`-D` *directory*\] \[`-BknS`\] \[`-i` | `-s`\] \[`VAR=value`\] \[<*command*>\]\ `sudo` `-v` \[`-BknS`\] \[`-u` *user*\] \[`-g` *group*\]\ `sudo` `-l` \[`-BknS`\] \[`-U` *user*\] \[`-u` *user*\] \[`-g` *group*\] \[command \[arg ...\]\]\ `sudo` `-e` \[`-BknS`\] \[`-u` *user*\] \[`-g` *group*\] file ...\ `sudoedit` \[`-BknS`\] \[`-u` *user*\] \[`-g` *group*\] file ... # DESCRIPTION `sudo` allows a user that is permitted to do so to execute a *command* as another user (for example *root*). Permissions are specified by a security policy specified in `/etc/sudoers` (see sudoers(5)). Sudo-rs is a safety oriented and memory safe re-implementation of the original sudo implementation by Todd Miller. When a command is run, a session record is stored for that specific session allowing users to run additional commands without having to re-authenticate. The timeout for session records can be specified in the policy. Some care is taken to pass signals received by sudo-rs to the child process, even if that process runs in its own pseudo terminal. On systems where sudo is the primary method of gaining superuser privileges, it is imperative to avoid syntax errors in the `/etc/sudoers` file. Changes to this file should be made using the visudo(8) utility which will ensure that no syntax errors are introduced. # OPTIONS `-B`, `--bell` : Ring the bell as part of the password prompt when a terminal is present. `-D` *directory*, `--chdir`=*directory* : Run the *command* in the specified *directory* instead of the current working directory. The security policy may return an error if the user does not have the permission to specify the working directory. `-g` *group*, `--group`=*group* : Use this *group* as the primary group instead of using the primary group specified in the password database for the target user. `-h`, `--help` : Show a help message. `-i`, `--login` : Run the shell specified by the target user's password database entry as a login shell. This means that login-specific resource files such as *.profile*, *.bash_profile* or *.login* will be read by the shell. If a *command* is specified, it is passed to the shell using the `-c` option. `-K`, `--remove-timestamp` : Removes every cached session record for the user, regardless of where the command is executed. The next time sudo-rs is run, authentication will take place if the policy requires it. No password is required to run this command. `-k`, `--reset-timestamp` : When used without a command, invalidates the user's session record for the current session. The next time sudo-rs is run, authentication will take place if the policy requires it. When used in conjunction with a *command* or an option that may require a password, this option will cause sudo-rs to ignore the user's session record. As a result, authentication will take place if the policy requires it. When used in conjunction with a *command* no invalidation of existing session records will take place. `-n`, `--non-interactive` : Avoid prompting the user for input of any kind. If any input is required for the *command* to run, sudo-rs will display an error message and exit. `p`, `--prompt`=*prompt* : Use a custom authentication prompt with optional escape sequences. The following percent (‘%’) escape sequences are supported: %H expanded to the local host name %h expanded to the local host name without the domain name %p expanded to the name of the user whose password is being requested (this respects the rootpw, targetpw flags) %U expanded to the login name of the user the command will be run as (defaults to root unless the -u option is also specified) %u expanded to the invoking user's login name %% two consecutive ‘%’ characters are collapsed into a single ‘%’ character The custom prompt will override the default prompt or the one specified by the SUDO_PROMPT environment variable. No *prompt* will suppress the prompt provided by PAM, unless the requested *prompt* is empty (`""`) `-S`, `--stdin` : Read from standard input instead of using the terminal device. `-s`, `--shell` : Run the shell specified by the `SHELL` environment variable. If no shell was specified, the shell from the user's password database entry will be used instead. If a *command* is specified, it is passed to the shell using the `-c` option. `-e`, `sudoedit` Edit one or more files instead of running a command. In lieu of a path name, the string "sudoedit" is used when consulting the security policy. If the user is authorized by the policy, the following steps are taken: 1. Temporary copies are made of the files to be edited with the owner set to the invoking user. 2. The editor specified by the policy is run to edit the temporary files. The sudoers policy uses the SUDO_EDITOR, VISUAL and EDITOR environment variables (in that order). If none of SUDO_EDITOR, VISUAL or EDITOR are set, the first program listed in the editor sudoers(5) option is used. 3. If they have been modified, the content of the temporary files is copied back to the originals and the temporary versions are removed. To help prevent the editing of unauthorized files, the following restrictions are enforced (unless the user is root): * Symbolic links may not be edited. * If any component of the path leading to the file is writable by the invoking user, the file may not be edited. * Users are never allowed to edit device special files. If the specified file does not exist, it will be created. Unlike most commands run by sudo, the editor is run with the invoking user's environment unmodified. If the temporary file becomes empty after editing, the user will be prompted before it is installed. `-u` *user*, `--user`=*user* : Run the *command* as another user than the default (**root**). `-V`, `--version` : Display the current version of sudo-rs. `-v`, `--validate` : Update the session record for the current session, authenticating the user if necessary. `-l`, `--list` : List user's privileges or check a specific command; use twice for longer format `-U`, `--other-user`=*user* : Used in list mode, display privileges for another user `--` : Indicates the end of the sudo-rs options and start of the *command*. Environment variables to be set for the command may be passed on the command line in the form of VAR=value. Variables passed on the command line are subject to restrictions imposed by the security policy. Variables passed on the command line are subject to the same restrictions as normal environment variables with one important exception: If the command to be run has the SETENV tag set or the command matched is ALL, the user may set variables that would otherwise be forbidden. See [sudoers(5)](sudoers.5.md) for more information. # SEE ALSO [su(1)](su.1.md), [sudoers(5)](sudoers.5.md), [visudo(8)](visudo.8.md) sudo-rs-0.2.10/docs/man/sudoers.5.man000064400000000000000000001240621046102023000153340ustar 00000000000000.\" Automatically generated by Pandoc 3.6.3 .\" .TH "SUDOERS" "5" "" "sudo\-rs 0.2.10" "sudo\-rs" .SH NAME \f[CR]sudoers\f[R] \- sudo\-compatible security configuration .SH DESCRIPTION The \f[CR]sudo\-rs\f[R] policy determines a user\[cq]s sudo privileges. The policy is driven by the \f[I]/etc/sudoers file\f[R]. The policy format is described in detail in the \f[B]SUDOERS FILE FORMAT\f[R] section. .PP The format used by sudo\-rs is a subset of the one used by the sudo\-project as maintained by Todd Miller, but syntax\-compatible. .SS User Authentication The sudoers security policy requires that most users authenticate themselves before they can use sudo. A password is not required if the invoking user is root, if the target user is the same as the invoking user, or if the policy has disabled authentication for the user or command. Unlike \f[CR]su\f[R], when \f[CR]sudo\-rs\f[R] requires authentication, it validates the invoking user\[cq]s credentials, not the target user\[cq]s (or root\[cq]s) credentials. This can be changed via the \f[I]rootpw\f[R] flag, described later. .PP \f[CR]sudo\-rs\f[R] uses per\-user timestamp files for credential caching. Once a user has been authenticated, a record is written containing the user\-ID that was used to authenticate, the terminal session ID, the start time of the session leader (or parent process) and a timestamp (using a monotonic clock if one is available). The user may then use sudo without a password for a short period of time (15 minutes unless overridden by the timestamp_timeout option). By default, \f[CR]sudo\-rs\f[R] uses a separate record for each terminal, which means that a user\[cq]s login sessions are authenticated separately. The timestamp_type option can be used to select the type of timestamp record sudoers will use. .SS Logging By default, \f[CR]sudo\-rs\f[R] logs both successful and unsuccessful attempts (as well as errors). Messages are logged to syslog(3). .SS Command environment Since environment variables can influence program behavior, \f[CR]sudo\-rs\f[R] restricts which variables from the user\[cq]s environment are inherited by the command to be run. .PP In \f[CR]sudo\-rs\f[R], the \f[I]env_reset\f[R] flag cannot be disabled. This causes commands to be executed with a new, minimal environment. The \f[CR]HOME\f[R], \f[CR]MAIL\f[R], \f[CR]SHELL\f[R], \f[CR]LOGNAME\f[R] and \f[CR]USER\f[R] environment variables are initialized based on the target user and the \f[CR]SUDO_*\f[R] variables are set based on the invoking user. Additional variables, such as \f[CR]DISPLAY\f[R], \f[CR]PATH\f[R] and \f[CR]TERM\f[R], are preserved from the invoking user\[cq]s environment if permitted by the \f[I]env_check\f[R] or \f[I]env_keep\f[R] options. A few environment variables are treated specially. If the \f[CR]PATH\f[R] and \f[CR]TERM\f[R] variables are not preserved from the user\[cq]s environment, they will be set to default values. The \f[CR]LOGNAME\f[R] and \f[CR]USER\f[R] are handled as a single entity. If one of them is preserved (or removed) from the user\[cq]s environment, the other will be as well. If \f[CR]LOGNAME\f[R] and \f[CR]USER\f[R] are to be preserved but only one of them is present in the user\[cq]s environment, the other will be set to the same value. This avoids an inconsistent environment where one of the variables describing the user name is set to the invoking user and one is set to the target user. Environment variables with a value beginning with \f[CR]()\f[R] are removed, as they may be interpreted as functions by the bash shell. .PP Environment variables specified by \f[I]env_check\f[R] or \f[I]env_keep\f[R] may include one or more \[cq]\f[I]\[cq] characters which will match zero or more characters. No other wildcard characters are supported. Other sudoers options may influence the command environment, such as \f[R]secure_path*. .PP Variables in the PAM environment may be merged in to the environment. If a variable in the PAM environment is already present in the user\[cq]s environment, the value will only be overridden if the variable was not preserved by \f[CR]sudo\-rs\f[R]. Variables preserved from the invoking user\[cq]s environment by the \f[I]env_keep\f[R] list take precedence over those in the PAM environment. .PP Note that the dynamic linker on most operating systems will remove variables that can control dynamic linking from the environment of set\-user\-ID executables, including sudo. Depending on the operating system this may include \f[CR]_RLD*\f[R], \f[CR]DYLD_*\f[R], \f[CR]LD_*\f[R], \f[CR]LDR_*\f[R], \f[CR]LIBPATH\f[R], \f[CR]SHLIB_PATH\f[R], and others. These type of variables are removed from the environment before sudo even begins execution and, as such, it is not possible for sudo to preserve them. .SS Resource limits sudo uses the operating system\[cq]s native method of setting resource limits for the target user. On Linux systems, resource limits are usually set by the \f[I]pam_limits.so\f[R] PAM module. On some BSD systems, the \f[I]/etc/login.conf\f[R] file specifies resource limits for the user. If there is no system mechanism to set per\-user resource limits, the command will run with the same limits as the invoking user. .SH SUDOERS FILE FORMAT The sudoers file is composed of two types of entries: aliases (basically variables) and user specifications (which specify who may run what). .PP When multiple entries match for a user, they are applied in order. Where there are multiple matches, the last match is used (which is not necessarily the most specific match). .PP The sudoers file grammar will be described below in Extended Backus\-Naur Form (EBNF) borrowed from Todd Miller\[cq]s sudoers documentation. .SS Quick guide to EBNF EBNF is a concise and exact way of describing the grammar of a language. Each EBNF definition is made up of production rules. E.g., .IP .EX symbol ::= definition | alternate1 | alternate2 ... .EE .PP Each production rule references others and thus makes up a grammar for the language. EBNF also contains the following operators, which many readers will recognize from regular expressions. Do not, however, confuse them with \[lq]wildcard\[rq] characters, which have different meanings. .IP .EX ? Means that the preceding symbol (or group of symbols) is optional. That is, it may appear once or not at all. * Means that the preceding symbol (or group of symbols) may appear zero or more times. + Means that the preceding symbol (or group of symbols) may appear one or more times. .EE .PP Parentheses may be used to group symbols together. For clarity, we will use single quotes (\[cq]\[cq]) to designate what is a verbatim character string (as opposed to a symbol name). .SS Aliases There are four kinds of aliases: User_Alias, Runas_Alias, Host_Alias and Cmnd_Alias. .IP .EX Alias ::= \[aq]User_Alias\[aq] User_Alias_Spec (\[aq]:\[aq] User_Alias_Spec)* | \[aq]Runas_Alias\[aq] Runas_Alias_Spec (\[aq]:\[aq] Runas_Alias_Spec)* | \[aq]Host_Alias\[aq] Host_Alias_Spec (\[aq]:\[aq] Host_Alias_Spec)* | \[aq]Cmnd_Alias\[aq] Cmnd_Alias_Spec (\[aq]:\[aq] Cmnd_Alias_Spec)* | \[aq]Cmd_Alias\[aq] Cmnd_Alias_Spec (\[aq]:\[aq] Cmnd_Alias_Spec)* User_Alias ::= NAME User_Alias_Spec ::= User_Alias \[aq]=\[aq] User_List Runas_Alias ::= NAME Runas_Alias_Spec ::= Runas_Alias \[aq]=\[aq] Runas_List Host_Alias ::= NAME Host_Alias_Spec ::= Host_Alias \[aq]=\[aq] Host_List Cmnd_Alias ::= NAME Cmnd_Alias_Spec ::= Cmnd_Alias \[aq]=\[aq] Cmnd_List NAME ::= [A\-Z]([A\-Z][0\-9]_)* .EE .PP Each alias definition is of the form .IP .EX Alias_Type NAME = item1, item2, ... .EE .PP where \f[I]Alias_Type\f[R] is one of User_Alias, Runas_Alias, Host_Alias, or Cmnd_Alias. A NAME is a string of uppercase letters, numbers, and underscore characters (\[cq]_\[cq]). A NAME must start with an uppercase letter. It is possible to put several alias definitions of the same type on a single line, joined by a colon (`:'). E.g., .IP .EX Alias_Type NAME = item1, item2, item3 : NAME = item4, item5 .EE .PP The definitions of what constitutes a valid alias member follow. .IP .EX User_List ::= User | User \[aq],\[aq] User_List User ::= \[aq]!\[aq]* user name | \[aq]!\[aq]* #user\-ID | \[aq]!\[aq]* %group | \[aq]!\[aq]* %#group\-ID | \[aq]!\[aq]* User_Alias .EE .PP A User_List is made up of one or more user names, user\-IDs (prefixed with `#'), system group names and IDs (prefixed with `%' and `%#' respectively) and User_Aliases. Each list item may be prefixed with zero or more `!' operators. An odd number of `!' operators negate the value of the item; an even number just cancel each other out. .IP .EX Runas_List ::= Runas_Member | Runas_Member \[aq],\[aq] Runas_List Runas_Member ::= \[aq]!\[aq]* user name | \[aq]!\[aq]* #user\-ID | \[aq]!\[aq]* %group | \[aq]!\[aq]* %#group\-ID | \[aq]!\[aq]* Runas_Alias .EE .PP A Runas_List is similar to a User_List except that instead of User_Aliases it can contain Runas_Aliases. Note that user names and groups are matched as strings. In other words, two users (groups) with the same user (group) ID are considered to be distinct. If you wish to match all user names with the same user\-ID (e.g., root and toor), you can use a user\-ID instead of a name (\f[CR]#0\f[R] in the example given). .IP .EX Host_List ::= Host | Host \[aq],\[aq] Host_List Host ::= \[aq]!\[aq]* host name | \[aq]!\[aq]* Host_Alias .EE .PP A Host_List is made up of one or more host names. Again, the value of an item may be negated with the `!' operator. .IP .EX Cmnd_List ::= Cmnd | Cmnd \[aq],\[aq] Cmnd_List command name ::= file name | file name args | file name \[aq]\[dq]\[dq]\[aq] Cmnd ::= \[aq]!\[aq]* command name | \[aq]!\[aq]* directory | \[aq]!\[aq]* Cmnd_Alias \[aq]!\[aq]* \[dq]list\[dq] \[aq]!\[aq]* \[dq]sudoedit\[dq] [file name] .EE .PP A Cmnd_List is a list of one or more command names, directories, and other aliases. A command name is a fully qualified file name which may include shell\-style wildcards (see the Wildcards section below). A simple file name allows the user to run the command with any arguments they wish. However, you may also specify command line arguments (which in sudo\-rs may \f[I]not\f[R] include wildcards). Alternately, you can specify \[lq]\[rq] to indicate that the command may only be run without command line arguments. A directory is a fully qualified path name ending in a `/'. When you specify a directory in a Cmnd_List, the user will be able to run any file within that directory (but not in any sub\-directories therein). .PP If a Cmnd has associated command line arguments, then the arguments in the Cmnd must match exactly those given by the user on the command line. Note that the following characters must be escaped with a `\[rs]' if they are used in command arguments: `,', `:', `=', `\[rs]'. .PP There are two commands built into sudo itself: \[lq]list\[rq] and \[lq]sudoedit\[rq]. Unlike other commands, these two must be specified in the sudoers file without a leading path. .PP The \[lq]list\[rq] built\-in can be used to permit a user to list another user\[cq]s privileges with sudo\[cq]s \-U option. For example, \[lq]sudo \-l \-U otheruser\[rq]. A user with the \[lq]list\[rq] privilege is able to list another user\[cq]s privileges even if they don\[cq]t have permission to run commands as that user. By default, only root or a user with the ability to run any command as either root or the specified user on the current host may use the \-U option. No command line arguments may be specified with the \[lq]list\[rq] built\-in. .PP The \[lq]sudoedit\[rq] built\-in is used to permit a user to run sudo with the \-e option (or as sudoedit). It may take command line arguments just as a normal command does. Unlike other commands, \[lq]sudoedit\[rq] is built into sudo itself and must be specified in the sudoers file without a leading path. If a leading path is present, for example /usr/bin/sudoedit, this will not give the user permissions to use sudoedit. If no arguments are provided, \[lq]sudoedit\[rq] will give the user the permission to edit any files; if an argument is present it must be an absolute path name that does not contain symbolic links, or the command will not be matched. .SS Defaults Certain configuration options may be changed from their default values at run\-time via one or more Default_Entry lines. These may affect all users on any host, all users on a specific host, a specific user, a specific command, or commands being run as a specific user. Note that per\-command entries may not include command line arguments. If you need to specify arguments, define a Cmnd_Alias and reference that instead. .IP .EX Default_Type ::= \[aq]Defaults\[aq] | \[aq]Defaults\[aq] \[aq]\[at]\[aq] Host_List | \[aq]Defaults\[aq] \[aq]:\[aq] User_List | \[aq]Defaults\[aq] \[aq]!\[aq] Cmnd_List | \[aq]Defaults\[aq] \[aq]>\[aq] Runas_List Default_Entry ::= Default_Type Parameter_List Parameter_List ::= Parameter | Parameter \[aq],\[aq] Parameter_List Parameter ::= Parameter \[aq]=\[aq] Value | Parameter \[aq]+=\[aq] Value | Parameter \[aq]\-=\[aq] Value | \[aq]!\[aq]* Parameter .EE .PP Parameters may be flags, integer values, strings, or lists. Flags are implicitly boolean and can be turned off via the `!' operator. Some integer, string and list parameters may also be used in a boolean context to disable them. Values may be enclosed in double quotes (\[lq]\[lq]) when they contain multiple words. Special characters may be escaped with a backslash (`\[rs]'). .PP To include a literal backslash character in a command line argument you must escape the backslash twice. For example, to match `\[rs]n' as part of a command line argument, you must use `\[rs]\[rs]\[rs]\[rs]n' in the sudoers file. This is due to there being two levels of escaping, one in the sudoers parser itself and another when command line arguments are matched by the fnmatch(3) function. .PP Lists have two additional assignment operators, \f[I]+=\f[R] and \f[I]\-=\f[R]. These operators are used to add to and delete from a list respectively. It is not an error to use the \-= operator to remove an element that does not exist in a list. .PP Defaults entries are parsed in the following order: generic, host, user, and runas Defaults are processed in the order they appear, with per\-command defaults being processed in a second pass after that. .PP See \f[B]SUDOERS OPTIONS\f[R] for a list of supported Defaults parameters. .SS User specification .IP .EX User_Spec ::= User_List Host_List \[aq]=\[aq] Cmnd_Spec_List \[rs] (\[aq]:\[aq] Host_List \[aq]=\[aq] Cmnd_Spec_List)* Cmnd_Spec_List ::= Cmnd_Spec | Cmnd_Spec \[aq],\[aq] Cmnd_Spec_List Cmnd_Spec ::= Runas_Spec? Chdir_Spec? Tag_Spec* Cmnd Runas_Spec ::= \[aq](\[aq] Runas_List? (\[aq]:\[aq] Runas_List)? \[aq])\[aq] Chdir_Spec ::= \[aq]CWD=directory\[aq] Tag_Spec ::= (\[aq]PASSWD:\[aq] | \[aq]NOPASSWD:\[aq] | \[aq]SETENV:\[aq] | \[aq]NOSETENV:\[aq] \[aq]EXEC:\[aq] | \[aq]NOEXEC\[aq]) AppArmor_Spec ::= \[aq]APPARMOR_PROFILE=profile\[aq] .EE .PP A user specification determines which commands a user may run (and as what user) on specified hosts. By default, commands are run as root, but this can be changed on a per\-command basis. .PP The basic structure of a user specification is \[lq]who where = (as_whom) what\[rq]. Let\[cq]s break that down into its constituent parts: .SS Runas_Spec A Runas_Spec determines the user and/or the group that a command may be run as. A fully\-specified Runas_Spec consists of two Runas_Lists (as defined above) separated by a colon (`:') and enclosed in a set of parentheses. The first Runas_List indicates which users the command may be run as via the \-u option. The second defines a list of groups that may be specified via the \-g option (in addition to any of the target user\[cq]s groups). If both Runas_Lists are specified, the command may be run with any combination of users and groups listed in their respective Runas_Lists. If only the first is specified, the command may be run as any user in the list and, optionally, with any group the target user belongs to. If the first Runas_List is empty but the second is specified, the command may be run as the invoking user with the group set to any listed in the Runas_List. If both Runas_Lists are empty, the command may only be run as the invoking user and the group, if specified, must be one that the invoking user is a member of. If no Runas_Spec is specified, the command may only be run as root and the group, if specified, must be one that root is a member of. .PP A Runas_Spec sets the default for the commands that follow it. What this means is that for the entry: .IP .EX dgb boulder = (operator) /bin/ls, /bin/kill, /usr/bin/lprm .EE .PP The user dgb may run /bin/ls, /bin/kill, and /usr/bin/lprm on the host boulder\[em]but only as operator. E.g., .IP .EX $ sudo \-u operator /bin/ls .EE .PP It is also possible to override a Runas_Spec later on in an entry. If we modify the entry like so: .IP .EX dgb boulder = (operator) /bin/ls, (root) /bin/kill, /usr/bin/lprm .EE .PP Then user dgb is now allowed to run /bin/ls as operator, but /bin/kill and /usr/bin/lprm as root. .PP We can extend this to allow dgb to run /bin/ls with either the user or group set to operator: .IP .EX dgb boulder = (operator : operator) /bin/ls, (root) /bin/kill,\[rs] /usr/bin/lprm .EE .PP Note that while the group portion of the Runas_Spec permits the user to run as command with that group, it does not force the user to do so. If no group is specified on the command line, the command will run with the group listed in the target user\[cq]s password database entry. The following would all be permitted by the sudoers entry above: .IP .EX $ sudo \-u operator /bin/ls $ sudo \-u operator \-g operator /bin/ls $ sudo \-g operator /bin/ls .EE .PP In the following example, user tcm may run commands that access a modem device file with the dialer group. .IP .EX tcm boulder = (:dialer) /usr/bin/tip, /usr/bin/cu,\[rs] /usr/local/bin/minicom .EE .PP Note that in this example only the group will be set, the command still runs as user tcm. E.g. .IP .EX $ sudo \-g dialer /usr/bin/cu .EE .PP Multiple users and groups may be present in a Runas_Spec, in which case the user may select any combination of users and groups via the \-u and \-g options. In this example: .IP .EX alan ALL = (root, bin : operator, system) ALL .EE .PP user alan may run any command as either user root or bin, optionally setting the group to operator or system. .SS Chdir_Spec The working directory that the command will be run in can be specified using the CWD setting. The directory must be a fully\-qualified path name beginning with a `/' or `\[ti]' character, or the special value \[lq]\f[I]\[rq]. A value of \[lq]\f[R]\[rq] indicates that the user may specify the working directory by running sudo with the \-D option. By default, commands are run from the invoking user\[cq]s current working directory, unless the \-i option is given. Path names of the form \[ti]user/path/name are interpreted as being relative to the named user\[cq]s home directory. If the user name is omitted, the path will be relative to the runas user\[cq]s home directory. .SS Tag_Spec A command may have zero or more tags associated with it. The following tag values are supported: PASSWD, NOPASSWD, SETENV, and NOSETENV. Once a tag is set on a Cmnd, subsequent Cmnds in the Cmnd_Spec_List, inherit the tag unless it is overridden by the opposite tag (in other words, PASSWD overrides NOPASSWD and NOSETENV overrides SETENV). .SS EXEC and NOEXEC On Linux systems, the NOEXEC tag can be used to prevent an executable from running further commands itself. .PP In the following example, user aaron may run /usr/bin/more and /usr/bin/vi but shell escapes will be disabled. .IP .EX aaron shanty = NOEXEC: /usr/bin/more, /usr/bin/vi .EE .PP See the \f[I]Preventing shell escapes\f[R] section below for more details on how NOEXEC works and whether or not it suits your purpose. .SS PASSWD and NOPASSWD By default, sudo requires that a user authenticate before running a command. This behavior can be modified via the NOPASSWD tag. Like a Runas_Spec, the NOPASSWD tag sets a default for the commands that follow it in the Cmnd_Spec_List. Conversely, the PASSWD tag can be used to reverse things. For example: .IP .EX queen rushmore = NOPASSWD: /bin/kill, /bin/ls, /usr/bin/lprm .EE .PP would allow the user queen to run /bin/kill, /bin/ls, and /usr/bin/lprm as root on the machine \[lq]rushmore\[rq] without authenticating himself. If we only want queen to be able to run /bin/kill without a password the entry would be: .IP .EX queen rushmore = NOPASSWD: /bin/kill, PASSWD: /bin/ls, /usr/bin/lprm .EE .PP Note, however, that the PASSWD tag has no effect on users who are in the group specified by the exempt_group setting. .PP By default, if the NOPASSWD tag is applied to any of a user\[cq]s entries for the current host, the user will be able to run \[lq]sudo \-l\[rq] without a password. Additionally, a user may only run \[lq]sudo \-v\[rq] without a password if all of the user\[cq]s entries for the current host have the NOPASSWD tag. .SS SETENV and NOSETENV These tags override the value of the setenv flag on a per\-command basis. Note that if SETENV has been set for a command, the user may disable the env_reset flag from the command line via the \-E option. Additionally, environment variables set on the command line are not subject to the restrictions imposed by env_check, env_delete, or env_keep. As such, only trusted users should be allowed to set variables in this manner. If the command matched is ALL, the SETENV tag is implied for that command; this default may be overridden by use of the NOSETENV tag. .SS AppArmor_Spec When sudo\-rs is built with support for AppArmor, sudoers file entries may specify an AppArmor profile that should be used to confine a command. .PP If an AppArmor profile is specified with the command, it will override any default values specified in sudoers. Appropriate profile transition rules must be defined to support the profile change specified for a user. .PP AppArmor profiles can be specified in any way that complies with the rules of \f[CR]aa_change_profile(2)\f[R]. .SS Wildcards sudo allows shell\-style wildcards (aka meta or glob characters) to be used in host names, path names, and command line arguments in the sudoers file. Wildcard matching is done via the glob(3) and fnmatch(3) functions as specified by IEEE Std 1003.1 (\[lq]POSIX.1\[rq]). .IP .EX * Matches any set of zero or more characters (including white space). ? Matches any single character (including white space). [...] Matches any character in the specified range. [!...] Matches any character not in the specified range. \[rs]x For any character \[oq]x\[cq], evaluates to \[oq]x\[cq]. This is used to escape special characters such as: \[oq]*\[cq], \[oq]?\[cq], \[oq][\[cq], and \[oq]]\[cq]. .EE .PP Note that these are not regular expressions. Unlike a regular expression there is no way to match one or more characters within a range. .PP Wildcards in command line arguments are not supported\[em]using these in original versions of sudo was usually a sign of mis\-configuration and consequently sudo\-rs simply forbids using them. .SS Including other files from within sudoers It is possible to include other sudoers files from within the sudoers file currently being parsed using the \f[I]\[at]include\f[R] and \f[I]\[at]includedir\f[R] directives. For compatibility with Todd Miller\[cq]s sudo versions prior to 1.9.1, \f[I]#include\f[R] and \f[I]#includedir\f[R] are also accepted. .PP An include file can be used, for example, to keep a site\-wide sudoers file in addition to a local, per\-machine file. For the sake of this example the site\-wide sudoers file will be /etc/sudoers and the per\-machine one will be /etc/sudoers.local. To include /etc/sudoers.local from within /etc/sudoers one would use the following line in /etc/sudoers: .IP .EX \[at]include /etc/sudoers.local .EE .PP When sudo reaches this line it will suspend processing of the current file (/etc/sudoers) and switch to /etc/sudoers.local. Upon reaching the end of /etc/sudoers.local, the rest of /etc/sudoers will be processed. Files that are included may themselves include other files. A hard limit of 128 nested include files is enforced to prevent include file loops. .PP The path to the include file may contain white space if it is escaped with a backslash (`\[rs]'). Alternately, the entire path may be enclosed in double quotes (\[lq]\[lq]), in which case no escaping is necessary. To include a literal backslash in the path, `\[rs]\[rs]' should be used. If the path to the include file is not fully\-qualified (does not begin with a `/'), it must be located in the same directory as the sudoers file it was included from. For example, if /etc/sudoers contains the line: .IP .EX \[at]include sudoers.local .EE .PP The \[at]includedir directive can be used to create a sudoers.d directory that the system package manager can drop sudoers file rules into as part of package installation. For example, given: .IP .EX \[at]includedir /etc/sudoers.d .EE .PP sudo will suspend processing of the current file and read each file in /etc/sudoers.d, skipping file names that end in `\[ti]' or contain a `.' character to avoid causing problems with package manager or editor temporary/backup files. Files are parsed in sorted lexical order. That is, /etc/sudoers.d/01_first will be parsed before /etc/sudoers.d/10_second. Be aware that because the sorting is lexical, not numeric, /etc/sudoers.d/1_whoops would be loaded after /etc/sudoers.d/10_second. Using a consistent number of leading zeroes in the file names can be used to avoid such problems. After parsing the files in the directory, control returns to the file that contained the \[at]includedir directive. .PP Note that unlike files included via \[at]include, visudo will not edit the files in a \[at]includedir directory unless one of them contains a syntax error. It is still possible to run visudo with the \-f flag to edit the files directly, but this will not catch the redefinition of an alias that is also present in a different file. .SS Other special characters and reserved words The pound sign (`#') is used to indicate a comment (unless it is part of a #include directive or unless it occurs in the context of a user name and is followed by one or more digits, in which case it is treated as a user\-ID). Both the comment character and any text after it, up to the end of the line, are ignored. .PP The reserved word \f[I]ALL\f[R] is a built\-in alias that always causes a match to succeed. It can be used wherever one might otherwise use a Cmnd_Alias, User_Alias, Runas_Alias, or Host_Alias. Attempting to define an alias named ALL will result in a syntax error. Please note that using ALL can be dangerous since in a command context, it allows the user to run any command on the system. .PP An exclamation point (`!') can be used as a logical not operator in a list or alias as well as in front of a Cmnd. This allows one to exclude certain values. For the `!' operator to be effective, there must be something for it to exclude. For example, to match all users except for root one would use: .IP .EX ALL,!root .EE .PP If the ALL, is omitted, as in: .IP .EX !root .EE .PP it would explicitly deny root but not match any other users. This is different from a true \[lq]negation\[rq] operator. .PP Note, however, that using a `!' in conjunction with the built\-in ALL alias to allow a user to run \[lq]all but a few\[rq] commands rarely works as intended (see SECURITY NOTES below). .PP White space between elements in a list as well as special syntactic characters in a User Specification (`=', `:', `(', `)') is optional. .PP The following characters must be escaped with a backslash (`\[rs]') when used as part of a word (e.g., a user name or host name): `!', `=', `:', `,', `(', `)', `\[rs]'. .SS SUDOERS OPTIONS sudo\[cq]s behavior can be modified by Default_Entry lines, as explained earlier. A list of all supported Defaults parameters, grouped by type, are listed below. .SS Boolean Flags: .IP \[bu] 2 noexec .RS 2 .PP If set, all commands run via sudo will behave as if the NOEXEC tag has been set, unless overridden by an EXEC tag. See the description of EXEC and NOEXEC as well as the Preventing shell escapes section at the end of this manual. This flag is off by default. .RE .IP \[bu] 2 noninteractive_auth If set, authentication will be attempted even in non\-interactive mode (when sudo\[cq]s \-n option is specified). This allows authentication methods that don\[cq]t require user interaction to succeed. Authentication methods that require input from the user\[cq]s terminal will still fail. If disabled, authentication will not be attempted in non\-interactive mode. This flag is off by default. .IP \[bu] 2 env_editor .RS 2 .PP If set, visudo will use the value of the SUDO_EDITOR, VISUAL or EDITOR environment variables before falling back on the default editor list. Note that visudo is typically run as root so this flag may allow a user with visudo privileges to run arbitrary commands as root without logging. An alternative is to place a colon\-separated list of \[lq]safe\[rq] editors int the editor setting. visudo will then only use SUDO_EDITOR, VISUAL or EDITOR if they match a value specified in editor. If the env_reset flag is enabled, the SUDO_EDITOR, VISUAL and/or EDITOR environment variables must be present in the env_keep list for the env_editor flag to function when visudo is invoked via sudo. This flag is on by default. .RE .IP \[bu] 2 pwfeedback .RS 2 .PP By default, sudo reads the password like most other Unix programs, by turning off echo until the user hits the return (or enter) key. Some users become confused by this as it appears to them that sudo has hung at this point. When pwfeedback is set, sudo will provide visual feedback when the user presses a key. Note that this does have a security impact as an onlooker may be able to determine the length of the password being entered. This flag is off by default. .RE .IP \[bu] 2 rootpw .RS 2 .PP If set, sudo will prompt for the root password instead of the password of the invoking user when running a command or editing a file. This flag is off by default. .RE .IP \[bu] 2 setenv .RS 2 .PP Allow the user to set environment variables set via the command line that are not subject to the restrictions imposed by env_check, env_delete, or env_keep. As such, only trusted users should be allowed to set variables in this manner. This flag is off by default. .RE .IP \[bu] 2 targetpw .RS 2 .PP If set, sudo will prompt for the password of the user specified by the \-u option (defaults to root) instead of the password of the invoking user when running a command or editing a file. Note that this flag precludes the use of a user\-ID not listed in the passwd database as an argument to the \-u option. This flag is off by default. .RE .IP \[bu] 2 umask_override .RS 2 .PP If set, sudo will set the umask as specified in the sudoers file without modification. This makes it possible to specify a umask in the sudoers file that is more permissive than the user\[cq]s own umask. If umask_override is not set, sudo will set the umask to be the union of the user\[cq]s umask and what is specified in sudoers. This flag is off by default. .RE .IP \[bu] 2 use_pty .RS 2 .PP If set, and sudo is running in a terminal, the command will be run in a pseudo\-terminal (even if no I/O logging is being done). If the sudo process is not attached to a terminal, use_pty has no effect. .PP A malicious program run under sudo may be capable of injecting commands into the user\[cq]s terminal or running a background process that retains access to the user\[cq]s terminal device even after the main program has finished executing. By running the command in a separate pseudo\-terminal, this attack is no longer possible. This flag is on by default. .RE .SS Integers: .IP \[bu] 2 passwd_tries .RS 2 .PP The number of tries a user gets to enter his/her password before sudo logs the failure and exits. The default is 3. .RE .SS Integers that can be used in a boolean context: .IP \[bu] 2 timestamp_timeout .RS 2 .PP Number of minutes that can elapse before sudo will ask for a passwd again. The timeout may include a fractional component if minute granularity is insufficient, for example 2.5. The default is 15. Set this to 0 to always prompt for a password. .RE .IP \[bu] 2 umask .RS 2 .PP File mode creation mask to use when running the command. Negate this option or set it to 0777 to prevent sudo from changing the umask. Unless the umask_override flag is set, the actual umask will be the union of the user\[cq]s umask and the value of the umask setting, which defaults to 0022. This guarantees that sudo never lowers the umask when running a command. .PP If umask is explicitly set, it will override any umask setting in PAM. If umask is not set, the umask specified by PAM will take precedence. The umask setting in PAM is not used for sudoedit, which does not create a new PAM session. .RE .SS Strings .IP \[bu] 2 editor .RS 2 .PP A colon (`:') separated list of editor path names used by \f[B]sudoedit\f[R] and \f[B]visudo\f[R]. For \f[B]sudoedit\f[R], this list is used to find an editor when none of the SUDO_EDITOR, VISUAL or EDITOR environment variables are set to an editor that exists and is executable. For \f[B]visudo\f[R], it is used as a white list of allowed editors; \f[B]visudo\f[R] will choose the editor that matches the user\[cq]s SUDO_EDITOR, VISUAL or EDITOR environment variable if possible, or the first editor in the list that exists and is executable if not. Unless invoked as \f[B]sudoedit\f[R], sudo does not preserve the SUDO_EDITOR, VISUAL or EDITOR environment variables unless they are present in the \f[B]env_keep\f[R] list. The default on Linux is \f[I]/usr/bin/editor\f[R], on FreeBSD \f[I]/usr/vim/vi\f[R]. .RE .SS Strings that can be used in a boolean context: .IP \[bu] 2 apparmor_profile .RS 2 .PP The default AppArmor profile to transition into when executing a command. The default apparmor_profile can be overridden for individual sudoers entries by specifying the APPARMOR_PROFILE option. This option is only available when sudo\-rs is built with AppArmor support. This option is not set by default. .RE .IP \[bu] 2 secure_path .RS 2 .PP If set, sudo will use this value in place of the user\[cq]s PATH environment variable. This option can be used to reset the PATH to a known good value that contains directories for system administrator commands such as /usr/sbin. This option is not set by default. .RE .SS Lists that can be used in a boolean context: .IP \[bu] 2 env_check .RS 2 .PP Environment variables to be removed from the user\[cq]s environment unless they are considered \[lq]safe\[rq]. For all variables except TZ, \[lq]safe\[rq] means that the variable\[cq]s value does not contain any `%' or `/' characters. This can be used to guard against printf\-style format vulnerabilities in poorly\-written programs. The TZ variable is considered unsafe if any of the following are true: .IP .EX • It consists of a fully\-qualified path name, optionally prefixed with a colon (\[oq]:\[cq]), that does not match the location of the zoneinfo directory. • It contains a .. path element. • It contains white space or non\-printable characters. • It is longer than the value of PATH_MAX. .EE .RE .PP The argument may be a double\-quoted, space\-separated list or a single value without double\-quotes. The list can be replaced, added to, deleted from, or disabled by using the =, +=, \-=, and ! operators respectively. Regardless of whether the env_reset option is enabled or disabled, variables specified by env_check will be preserved in the environment if they pass the aforementioned check. The global list of environment variables to check is displayed when sudo is run by root with the \-V option. .IP \[bu] 2 env_keep .RS 2 .PP Environment variables to be preserved in the user\[cq]s environment when the env_reset option is in effect. This allows fine\-grained control over the environment sudo\-spawned processes will receive. The argument may be a double\-quoted, space\-separated list or a single value without double\-quotes. The list can be replaced, added to, deleted from, or disabled by using the =, +=, \-=, and ! operators respectively. The global list of variables to keep is displayed when sudo is run by root with the \-V option. .PP Preserving the HOME environment variable has security implications since many programs use it when searching for configuration or data files. Adding HOME to env_keep may enable a user to run unrestricted commands via sudo and is strongly discouraged. Users wishing to edit files with sudo should run \f[B]sudoedit\f[R] (or \f[B]sudo \-e\f[R]) to get their accustomed editor configuration instead of invoking the editor directly. .RE .SS LOG FORMAT sudo\-rs logs events via syslog(3). .SS FILES .IP .EX /etc/sudoers\-rs List of who can run what (for co\-existence of sudo\-rs and Todd Miller\[aq]s sudo) /etc/sudoers List of who can run what (sudo\-compatible) /run/sudo/ts Directory containing timestamps for the sudoers security policy .EE .SS SECURITY NOTES .SS Limitations of the `!' operator It is generally not effective to \[lq]subtract\[rq] commands from ALL using the `!' operator. A user can trivially circumvent this by copying the desired command to a different name and then executing that. For example: .IP .EX bill ALL = ALL, !SU, !SHELLS .EE .PP Doesn\[cq]t really prevent bill from running the commands listed in SU or SHELLS since he can simply copy those commands to a different name, or use a shell escape from an editor or other program. Therefore, these kind of restrictions should be considered advisory at best (and reinforced by policy). .PP In general, if a user has sudo ALL there is nothing to prevent them from creating their own program that gives them a root shell (or making their own copy of a shell) regardless of any `!' elements in the user specification. .SS Security implications of \f[CR]fast_glob\f[R] sudo\-rs uses \[ga]fast_glob, which further means it is not possible to reliably negate commands where the path name includes globbing (aka wildcard) characters. This is because the Rust library\[cq]s fnmatch function cannot resolve relative paths. While this is typically only an inconvenience for rules that grant privileges, it can result in a security issue for rules that subtract or revoke privileges. .PP For example, given the following sudoers file entry: .IP .EX john ALL = /usr/bin/passwd [a\-zA\-Z0\-9]*, /usr/bin/chsh [a\-zA\-Z0\-9]*,\[rs] /usr/bin/chfn [a\-zA\-Z0\-9]*, !/usr/bin/* root .EE .PP User john can still run /usr/bin/passwd root if fast_glob is enabled by changing to /usr/bin and running ./passwd root instead. .SS Preventing shell escapes Once sudo executes a program, that program is free to do whatever it pleases, including run other programs. This can be a security issue since it is not uncommon for a program to allow shell escapes, which lets a user bypass sudo\[cq]s access control and logging. Common programs that permit shell escapes include shells (obviously), editors, paginators (such as \f[I]less\f[R]), mail, and terminal programs. .PP On Linux, sudo\-rs has sudo\[cq]s \f[B]noexec\f[R] functionality, based on a seccomp() filter. Programs that are run in \f[B]noexec\f[R] mode cannot run other programs. The implementation in sudo\-rs is different than in Todd Miller\[cq]s sudo, and should also work on statically linked binaries. .PP Note that restricting shell escapes is not a panacea. Programs running as root are still capable of many potentially hazardous operations (such as changing or overwriting files) that could lead to unintended privilege escalation. NOEXEC is also not a protection against malicious programs. It doesn\[cq]t prevent mapping memory as executable, nor does it protect against future syscalls that can do an exec() like the proposed \f[CR]io_uring\f[R] exec feature in Linux. And it also doesn\[cq]t protect against honest programs that intentionally or not allow the user to write to /proc/self/mem for the same reasons as that it doesn\[cq]t protect against malicious programs. You should always try out if \f[B]noexec\f[R] indeed prevents shell escapes for the programs it is intended to be used with. .SS Timestamp file checks sudo\-rs will check the ownership of its timestamp directory (/run/sudo/ts by default) and ignore the directory\[cq]s contents if it is not owned by root or if it is writable by a user other than root. .PP While the timestamp directory should be cleared at reboot time, to avoid potential problems, sudo\-rs will ignore timestamp files that date from before the machine booted on systems where the boot time is available. .PP Some systems with graphical desktop environments allow unprivileged users to change the system clock. Since sudo\-rs relies on the system clock for timestamp validation, it may be possible on such systems for a user to run sudo for longer than \f[I]timestamp_timeout\f[R] by setting the clock back. To combat this, \f[CR]sudo\-rs\f[R] uses a monotonic clock (which never moves backwards) for its timestamps if the system supports it. sudo\-rs will not honor timestamps set far in the future. .SS SEE ALSO su(1), fnmatch(3), glob(3), sudo(8), visudo(8) .SS CAVEATS The sudoers file should always be edited by the visudo utility which locks the file and checks for syntax errors. If sudoers contains syntax errors, you may lock yourself out of being able to use sudo. .SS BUGS If you feel you have found a bug in sudo\-rs, please submit a bug report at https://github.com/trifectatechfoundation/sudo\-rs/issues/ .SH AUTHORS This man page is a modified version of the sudoers(5) documentation written by Todd Miller; see https://www.sudo.ws/ for the original. .SS DISCLAIMER sudo\-rs is provided \[lq]AS IS\[rq] and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. sudo-rs-0.2.10/docs/man/sudoers.5.md000064400000000000000000001203071046102023000151570ustar 00000000000000--- title: SUDOERS(5) sudo-rs 0.2.10 | sudo-rs --- # NAME `sudoers` - sudo-compatible security configuration # DESCRIPTION The `sudo-rs` policy determines a user's sudo privileges. The policy is driven by the */etc/sudoers file*. The policy format is described in detail in the **SUDOERS FILE FORMAT** section. The format used by sudo-rs is a subset of the one used by the sudo-project as maintained by Todd Miller, but syntax-compatible. ## User Authentication The sudoers security policy requires that most users authenticate themselves before they can use sudo. A password is not required if the invoking user is root, if the target user is the same as the invoking user, or if the policy has disabled authentication for the user or command. Unlike `su`, when `sudo-rs` requires authentication, it validates the invoking user's credentials, not the target user's (or root's) credentials. This can be changed via the *rootpw* flag, described later. `sudo-rs` uses per-user timestamp files for credential caching. Once a user has been authenticated, a record is written containing the user-ID that was used to authenticate, the terminal session ID, the start time of the session leader (or parent process) and a timestamp (using a monotonic clock if one is available). The user may then use sudo without a password for a short period of time (15 minutes unless overridden by the timestamp_timeout option). By default, `sudo-rs` uses a separate record for each terminal, which means that a user's login sessions are authenticated separately. The timestamp_type option can be used to select the type of timestamp record sudoers will use. ## Logging By default, `sudo-rs` logs both successful and unsuccessful attempts (as well as errors). Messages are logged to syslog(3). ## Command environment Since environment variables can influence program behavior, `sudo-rs` restricts which variables from the user's environment are inherited by the command to be run. In `sudo-rs`, the *env_reset* flag cannot be disabled. This causes commands to be executed with a new, minimal environment. The `HOME`, `MAIL`, `SHELL`, `LOGNAME` and `USER` environment variables are initialized based on the target user and the `SUDO_*` variables are set based on the invoking user. Additional variables, such as `DISPLAY`, `PATH` and `TERM`, are preserved from the invoking user's environment if permitted by the *env_check* or *env_keep* options. A few environment variables are treated specially. If the `PATH` and `TERM` variables are not preserved from the user's environment, they will be set to default values. The `LOGNAME` and `USER` are handled as a single entity. If one of them is preserved (or removed) from the user's environment, the other will be as well. If `LOGNAME` and `USER` are to be preserved but only one of them is present in the user's environment, the other will be set to the same value. This avoids an inconsistent environment where one of the variables describing the user name is set to the invoking user and one is set to the target user. Environment variables with a value beginning with `()` are removed, as they may be interpreted as functions by the bash shell. Environment variables specified by *env_check* or *env_keep* may include one or more ‘*’ characters which will match zero or more characters. No other wildcard characters are supported. Other sudoers options may influence the command environment, such as *secure_path*. Variables in the PAM environment may be merged in to the environment. If a variable in the PAM environment is already present in the user's environment, the value will only be overridden if the variable was not preserved by `sudo-rs`. Variables preserved from the invoking user's environment by the *env_keep* list take precedence over those in the PAM environment. Note that the dynamic linker on most operating systems will remove variables that can control dynamic linking from the environment of set-user-ID executables, including sudo. Depending on the operating system this may include `_RLD*`, `DYLD_*`, `LD_*`, `LDR_*`, `LIBPATH`, `SHLIB_PATH`, and others. These type of variables are removed from the environment before sudo even begins execution and, as such, it is not possible for sudo to preserve them. ## Resource limits sudo uses the operating system's native method of setting resource limits for the target user. On Linux systems, resource limits are usually set by the *pam_limits.so* PAM module. On some BSD systems, the */etc/login.conf* file specifies resource limits for the user. If there is no system mechanism to set per-user resource limits, the command will run with the same limits as the invoking user. # SUDOERS FILE FORMAT The sudoers file is composed of two types of entries: aliases (basically variables) and user specifications (which specify who may run what). When multiple entries match for a user, they are applied in order. Where there are multiple matches, the last match is used (which is not necessarily the most specific match). The sudoers file grammar will be described below in Extended Backus-Naur Form (EBNF) borrowed from Todd Miller's sudoers documentation. ## Quick guide to EBNF EBNF is a concise and exact way of describing the grammar of a language. Each EBNF definition is made up of production rules. E.g., symbol ::= definition | alternate1 | alternate2 ... Each production rule references others and thus makes up a grammar for the language. EBNF also contains the following operators, which many readers will recognize from regular expressions. Do not, however, confuse them with “wildcard” characters, which have different meanings. ? Means that the preceding symbol (or group of symbols) is optional. That is, it may appear once or not at all. * Means that the preceding symbol (or group of symbols) may appear zero or more times. + Means that the preceding symbol (or group of symbols) may appear one or more times. Parentheses may be used to group symbols together. For clarity, we will use single quotes ('') to designate what is a verbatim character string (as opposed to a symbol name). ## Aliases There are four kinds of aliases: User_Alias, Runas_Alias, Host_Alias and Cmnd_Alias. Alias ::= 'User_Alias' User_Alias_Spec (':' User_Alias_Spec)* | 'Runas_Alias' Runas_Alias_Spec (':' Runas_Alias_Spec)* | 'Host_Alias' Host_Alias_Spec (':' Host_Alias_Spec)* | 'Cmnd_Alias' Cmnd_Alias_Spec (':' Cmnd_Alias_Spec)* | 'Cmd_Alias' Cmnd_Alias_Spec (':' Cmnd_Alias_Spec)* User_Alias ::= NAME User_Alias_Spec ::= User_Alias '=' User_List Runas_Alias ::= NAME Runas_Alias_Spec ::= Runas_Alias '=' Runas_List Host_Alias ::= NAME Host_Alias_Spec ::= Host_Alias '=' Host_List Cmnd_Alias ::= NAME Cmnd_Alias_Spec ::= Cmnd_Alias '=' Cmnd_List NAME ::= [A-Z]([A-Z][0-9]_)* Each alias definition is of the form Alias_Type NAME = item1, item2, ... where *Alias_Type* is one of User_Alias, Runas_Alias, Host_Alias, or Cmnd_Alias. A NAME is a string of uppercase letters, numbers, and underscore characters (‘_’). A NAME must start with an uppercase letter. It is possible to put several alias definitions of the same type on a single line, joined by a colon (‘:’). E.g., Alias_Type NAME = item1, item2, item3 : NAME = item4, item5 The definitions of what constitutes a valid alias member follow. User_List ::= User | User ',' User_List User ::= '!'* user name | '!'* #user-ID | '!'* %group | '!'* %#group-ID | '!'* User_Alias A User_List is made up of one or more user names, user-IDs (prefixed with ‘#’), system group names and IDs (prefixed with ‘%’ and ‘%#’ respectively) and User_Aliases. Each list item may be prefixed with zero or more ‘!’ operators. An odd number of ‘!’ operators negate the value of the item; an even number just cancel each other out. Runas_List ::= Runas_Member | Runas_Member ',' Runas_List Runas_Member ::= '!'* user name | '!'* #user-ID | '!'* %group | '!'* %#group-ID | '!'* Runas_Alias A Runas_List is similar to a User_List except that instead of User_Aliases it can contain Runas_Aliases. Note that user names and groups are matched as strings. In other words, two users (groups) with the same user (group) ID are considered to be distinct. If you wish to match all user names with the same user-ID (e.g., root and toor), you can use a user-ID instead of a name (`#0` in the example given). Host_List ::= Host | Host ',' Host_List Host ::= '!'* host name | '!'* Host_Alias A Host_List is made up of one or more host names. Again, the value of an item may be negated with the ‘!’ operator. Cmnd_List ::= Cmnd | Cmnd ',' Cmnd_List command name ::= file name | file name args | file name '""' Cmnd ::= '!'* command name | '!'* directory | '!'* Cmnd_Alias '!'* "list" '!'* "sudoedit" [file name] A Cmnd_List is a list of one or more command names, directories, and other aliases. A command name is a fully qualified file name which may include shell-style wildcards (see the Wildcards section below). A simple file name allows the user to run the command with any arguments they wish. However, you may also specify command line arguments (which in sudo-rs may *not* include wildcards). Alternately, you can specify "" to indicate that the command may only be run without command line arguments. A directory is a fully qualified path name ending in a ‘/’. When you specify a directory in a Cmnd_List, the user will be able to run any file within that directory (but not in any sub-directories therein). If a Cmnd has associated command line arguments, then the arguments in the Cmnd must match exactly those given by the user on the command line. Note that the following characters must be escaped with a ‘\\’ if they are used in command arguments: ‘,’, ‘:’, ‘=’, ‘\\’. There are two commands built into sudo itself: “list” and “sudoedit”. Unlike other commands, these two must be specified in the sudoers file without a leading path. The “list” built-in can be used to permit a user to list another user's privileges with sudo's -U option. For example, “sudo -l -U otheruser”. A user with the “list” privilege is able to list another user's privileges even if they don't have permission to run commands as that user. By default, only root or a user with the ability to run any command as either root or the specified user on the current host may use the -U option. No command line arguments may be specified with the “list” built-in. The “sudoedit” built-in is used to permit a user to run sudo with the -e option (or as sudoedit). It may take command line arguments just as a normal command does. Unlike other commands, “sudoedit” is built into sudo itself and must be specified in the sudoers file without a leading path. If a leading path is present, for example /usr/bin/sudoedit, this will not give the user permissions to use sudoedit. If no arguments are provided, “sudoedit” will give the user the permission to edit any files; if an argument is present it must be an absolute path name that does not contain symbolic links, or the command will not be matched. ## Defaults Certain configuration options may be changed from their default values at run-time via one or more Default_Entry lines. These may affect all users on any host, all users on a specific host, a specific user, a specific command, or commands being run as a specific user. Note that per-command entries may not include command line arguments. If you need to specify arguments, define a Cmnd_Alias and reference that instead. Default_Type ::= 'Defaults' | 'Defaults' '@' Host_List | 'Defaults' ':' User_List | 'Defaults' '!' Cmnd_List | 'Defaults' '>' Runas_List Default_Entry ::= Default_Type Parameter_List Parameter_List ::= Parameter | Parameter ',' Parameter_List Parameter ::= Parameter '=' Value | Parameter '+=' Value | Parameter '-=' Value | '!'* Parameter Parameters may be flags, integer values, strings, or lists. Flags are implicitly boolean and can be turned off via the ‘!’ operator. Some integer, string and list parameters may also be used in a boolean context to disable them. Values may be enclosed in double quotes ("") when they contain multiple words. Special characters may be escaped with a backslash (‘\\’). To include a literal backslash character in a command line argument you must escape the backslash twice. For example, to match ‘\\n’ as part of a command line argument, you must use ‘\\\\\\\\n’ in the sudoers file. This is due to there being two levels of escaping, one in the sudoers parser itself and another when command line arguments are matched by the fnmatch(3) function. Lists have two additional assignment operators, *+=* and *-=*. These operators are used to add to and delete from a list respectively. It is not an error to use the -= operator to remove an element that does not exist in a list. Defaults entries are parsed in the following order: generic, host, user, and runas Defaults are processed in the order they appear, with per-command defaults being processed in a second pass after that. See **SUDOERS OPTIONS** for a list of supported Defaults parameters. ## User specification User_Spec ::= User_List Host_List '=' Cmnd_Spec_List \ (':' Host_List '=' Cmnd_Spec_List)* Cmnd_Spec_List ::= Cmnd_Spec | Cmnd_Spec ',' Cmnd_Spec_List Cmnd_Spec ::= Runas_Spec? Chdir_Spec? Tag_Spec* Cmnd Runas_Spec ::= '(' Runas_List? (':' Runas_List)? ')' Chdir_Spec ::= 'CWD=directory' Tag_Spec ::= ('PASSWD:' | 'NOPASSWD:' | 'SETENV:' | 'NOSETENV:' 'EXEC:' | 'NOEXEC') AppArmor_Spec ::= 'APPARMOR_PROFILE=profile' A user specification determines which commands a user may run (and as what user) on specified hosts. By default, commands are run as root, but this can be changed on a per-command basis. The basic structure of a user specification is “who where = (as_whom) what”. Let's break that down into its constituent parts: ## Runas_Spec A Runas_Spec determines the user and/or the group that a command may be run as. A fully-specified Runas_Spec consists of two Runas_Lists (as defined above) separated by a colon (‘:’) and enclosed in a set of parentheses. The first Runas_List indicates which users the command may be run as via the -u option. The second defines a list of groups that may be specified via the -g option (in addition to any of the target user's groups). If both Runas_Lists are specified, the command may be run with any combination of users and groups listed in their respective Runas_Lists. If only the first is specified, the command may be run as any user in the list and, optionally, with any group the target user belongs to. If the first Runas_List is empty but the second is specified, the command may be run as the invoking user with the group set to any listed in the Runas_List. If both Runas_Lists are empty, the command may only be run as the invoking user and the group, if specified, must be one that the invoking user is a member of. If no Runas_Spec is specified, the command may only be run as root and the group, if specified, must be one that root is a member of. A Runas_Spec sets the default for the commands that follow it. What this means is that for the entry: dgb boulder = (operator) /bin/ls, /bin/kill, /usr/bin/lprm The user dgb may run /bin/ls, /bin/kill, and /usr/bin/lprm on the host boulder—but only as operator. E.g., $ sudo -u operator /bin/ls It is also possible to override a Runas_Spec later on in an entry. If we modify the entry like so: dgb boulder = (operator) /bin/ls, (root) /bin/kill, /usr/bin/lprm Then user dgb is now allowed to run /bin/ls as operator, but /bin/kill and /usr/bin/lprm as root. We can extend this to allow dgb to run /bin/ls with either the user or group set to operator: dgb boulder = (operator : operator) /bin/ls, (root) /bin/kill,\ /usr/bin/lprm Note that while the group portion of the Runas_Spec permits the user to run as command with that group, it does not force the user to do so. If no group is specified on the command line, the command will run with the group listed in the target user's password database entry. The following would all be permitted by the sudoers entry above: $ sudo -u operator /bin/ls $ sudo -u operator -g operator /bin/ls $ sudo -g operator /bin/ls In the following example, user tcm may run commands that access a modem device file with the dialer group. tcm boulder = (:dialer) /usr/bin/tip, /usr/bin/cu,\ /usr/local/bin/minicom Note that in this example only the group will be set, the command still runs as user tcm. E.g. $ sudo -g dialer /usr/bin/cu Multiple users and groups may be present in a Runas_Spec, in which case the user may select any combination of users and groups via the -u and -g options. In this example: alan ALL = (root, bin : operator, system) ALL user alan may run any command as either user root or bin, optionally setting the group to operator or system. ## Chdir_Spec The working directory that the command will be run in can be specified using the CWD setting. The directory must be a fully-qualified path name beginning with a ‘/’ or ‘~’ character, or the special value “*”. A value of “*” indicates that the user may specify the working directory by running sudo with the -D option. By default, commands are run from the invoking user's current working directory, unless the -i option is given. Path names of the form ~user/path/name are interpreted as being relative to the named user's home directory. If the user name is omitted, the path will be relative to the runas user's home directory. ## Tag_Spec A command may have zero or more tags associated with it. The following tag values are supported: PASSWD, NOPASSWD, SETENV, and NOSETENV. Once a tag is set on a Cmnd, subsequent Cmnds in the Cmnd_Spec_List, inherit the tag unless it is overridden by the opposite tag (in other words, PASSWD overrides NOPASSWD and NOSETENV overrides SETENV). ### EXEC and NOEXEC On Linux systems, the NOEXEC tag can be used to prevent an executable from running further commands itself. In the following example, user aaron may run /usr/bin/more and /usr/bin/vi but shell escapes will be disabled. aaron shanty = NOEXEC: /usr/bin/more, /usr/bin/vi See the _Preventing shell escapes_ section below for more details on how NOEXEC works and whether or not it suits your purpose. ### PASSWD and NOPASSWD By default, sudo requires that a user authenticate before running a command. This behavior can be modified via the NOPASSWD tag. Like a Runas_Spec, the NOPASSWD tag sets a default for the commands that follow it in the Cmnd_Spec_List. Conversely, the PASSWD tag can be used to reverse things. For example: queen rushmore = NOPASSWD: /bin/kill, /bin/ls, /usr/bin/lprm would allow the user queen to run /bin/kill, /bin/ls, and /usr/bin/lprm as root on the machine “rushmore” without authenticating himself. If we only want queen to be able to run /bin/kill without a password the entry would be: queen rushmore = NOPASSWD: /bin/kill, PASSWD: /bin/ls, /usr/bin/lprm Note, however, that the PASSWD tag has no effect on users who are in the group specified by the exempt_group setting. By default, if the NOPASSWD tag is applied to any of a user's entries for the current host, the user will be able to run “sudo -l” without a password. Additionally, a user may only run “sudo -v” without a password if all of the user's entries for the current host have the NOPASSWD tag. ### SETENV and NOSETENV These tags override the value of the setenv flag on a per-command basis. Note that if SETENV has been set for a command, the user may disable the env_reset flag from the command line via the -E option. Additionally, environment variables set on the command line are not subject to the restrictions imposed by env_check, env_delete, or env_keep. As such, only trusted users should be allowed to set variables in this manner. If the command matched is ALL, the SETENV tag is implied for that command; this default may be overridden by use of the NOSETENV tag. ## AppArmor_Spec When sudo-rs is built with support for AppArmor, sudoers file entries may specify an AppArmor profile that should be used to confine a command. If an AppArmor profile is specified with the command, it will override any default values specified in sudoers. Appropriate profile transition rules must be defined to support the profile change specified for a user. AppArmor profiles can be specified in any way that complies with the rules of `aa_change_profile(2)`. ## Wildcards sudo allows shell-style wildcards (aka meta or glob characters) to be used in host names, path names, and command line arguments in the sudoers file. Wildcard matching is done via the glob(3) and fnmatch(3) functions as specified by IEEE Std 1003.1 (“POSIX.1”). * Matches any set of zero or more characters (including white space). ? Matches any single character (including white space). [...] Matches any character in the specified range. [!...] Matches any character not in the specified range. \x For any character ‘x’, evaluates to ‘x’. This is used to escape special characters such as: ‘*’, ‘?’, ‘[’, and ‘]’. Note that these are not regular expressions. Unlike a regular expression there is no way to match one or more characters within a range. Wildcards in command line arguments are not supported---using these in original versions of sudo was usually a sign of mis-configuration and consequently sudo-rs simply forbids using them. ## Including other files from within sudoers It is possible to include other sudoers files from within the sudoers file currently being parsed using the *@include* and *@includedir* directives. For compatibility with Todd Miller's sudo versions prior to 1.9.1, *#include* and *#includedir* are also accepted. An include file can be used, for example, to keep a site-wide sudoers file in addition to a local, per-machine file. For the sake of this example the site-wide sudoers file will be /etc/sudoers and the per-machine one will be /etc/sudoers.local. To include /etc/sudoers.local from within /etc/sudoers one would use the following line in /etc/sudoers: @include /etc/sudoers.local When sudo reaches this line it will suspend processing of the current file (/etc/sudoers) and switch to /etc/sudoers.local. Upon reaching the end of /etc/sudoers.local, the rest of /etc/sudoers will be processed. Files that are included may themselves include other files. A hard limit of 128 nested include files is enforced to prevent include file loops. The path to the include file may contain white space if it is escaped with a backslash (‘\\’). Alternately, the entire path may be enclosed in double quotes (""), in which case no escaping is necessary. To include a literal backslash in the path, ‘\\\\’ should be used. If the path to the include file is not fully-qualified (does not begin with a ‘/’), it must be located in the same directory as the sudoers file it was included from. For example, if /etc/sudoers contains the line: @include sudoers.local The @includedir directive can be used to create a sudoers.d directory that the system package manager can drop sudoers file rules into as part of package installation. For example, given: @includedir /etc/sudoers.d sudo will suspend processing of the current file and read each file in /etc/sudoers.d, skipping file names that end in ‘~’ or contain a ‘.’ character to avoid causing problems with package manager or editor temporary/backup files. Files are parsed in sorted lexical order. That is, /etc/sudoers.d/01_first will be parsed before /etc/sudoers.d/10_second. Be aware that because the sorting is lexical, not numeric, /etc/sudoers.d/1_whoops would be loaded after /etc/sudoers.d/10_second. Using a consistent number of leading zeroes in the file names can be used to avoid such problems. After parsing the files in the directory, control returns to the file that contained the @includedir directive. Note that unlike files included via @include, visudo will not edit the files in a @includedir directory unless one of them contains a syntax error. It is still possible to run visudo with the -f flag to edit the files directly, but this will not catch the redefinition of an alias that is also present in a different file. ## Other special characters and reserved words The pound sign (‘#’) is used to indicate a comment (unless it is part of a #include directive or unless it occurs in the context of a user name and is followed by one or more digits, in which case it is treated as a user-ID). Both the comment character and any text after it, up to the end of the line, are ignored. The reserved word *ALL* is a built-in alias that always causes a match to succeed. It can be used wherever one might otherwise use a Cmnd_Alias, User_Alias, Runas_Alias, or Host_Alias. Attempting to define an alias named ALL will result in a syntax error. Please note that using ALL can be dangerous since in a command context, it allows the user to run any command on the system. An exclamation point (‘!’) can be used as a logical not operator in a list or alias as well as in front of a Cmnd. This allows one to exclude certain values. For the ‘!’ operator to be effective, there must be something for it to exclude. For example, to match all users except for root one would use: ALL,!root If the ALL, is omitted, as in: !root it would explicitly deny root but not match any other users. This is different from a true “negation” operator. Note, however, that using a ‘!’ in conjunction with the built-in ALL alias to allow a user to run “all but a few” commands rarely works as intended (see SECURITY NOTES below). White space between elements in a list as well as special syntactic characters in a User Specification (‘=’, ‘:’, ‘(’, ‘)’) is optional. The following characters must be escaped with a backslash (‘\\’) when used as part of a word (e.g., a user name or host name): ‘!’, ‘=’, ‘:’, ‘,’, ‘(’, ‘)’, ‘\\’. ## SUDOERS OPTIONS sudo's behavior can be modified by Default_Entry lines, as explained earlier. A list of all supported Defaults parameters, grouped by type, are listed below. ### Boolean Flags: * noexec If set, all commands run via sudo will behave as if the NOEXEC tag has been set, unless overridden by an EXEC tag. See the description of EXEC and NOEXEC as well as the Preventing shell escapes section at the end of this manual. This flag is off by default. * noninteractive_auth If set, authentication will be attempted even in non-interactive mode (when sudo's -n option is specified). This allows authentication methods that don't require user interaction to succeed. Authentication methods that require input from the user's terminal will still fail. If disabled, authentication will not be attempted in non-interactive mode. This flag is off by default. * env_editor If set, visudo will use the value of the SUDO_EDITOR, VISUAL or EDITOR environment variables before falling back on the default editor list. Note that visudo is typically run as root so this flag may allow a user with visudo privileges to run arbitrary commands as root without logging. An alternative is to place a colon-separated list of “safe” editors int the editor setting. visudo will then only use SUDO_EDITOR, VISUAL or EDITOR if they match a value specified in editor. If the env_reset flag is enabled, the SUDO_EDITOR, VISUAL and/or EDITOR environment variables must be present in the env_keep list for the env_editor flag to function when visudo is invoked via sudo. This flag is on by default. * pwfeedback By default, sudo reads the password like most other Unix programs, by turning off echo until the user hits the return (or enter) key. Some users become confused by this as it appears to them that sudo has hung at this point. When pwfeedback is set, sudo will provide visual feedback when the user presses a key. Note that this does have a security impact as an onlooker may be able to determine the length of the password being entered. This flag is off by default. * rootpw If set, sudo will prompt for the root password instead of the password of the invoking user when running a command or editing a file. This flag is off by default. * setenv Allow the user to set environment variables set via the command line that are not subject to the restrictions imposed by env_check, env_delete, or env_keep. As such, only trusted users should be allowed to set variables in this manner. This flag is off by default. * targetpw If set, sudo will prompt for the password of the user specified by the -u option (defaults to root) instead of the password of the invoking user when running a command or editing a file. Note that this flag precludes the use of a user-ID not listed in the passwd database as an argument to the -u option. This flag is off by default. * umask_override If set, sudo will set the umask as specified in the sudoers file without modification. This makes it possible to specify a umask in the sudoers file that is more permissive than the user's own umask. If umask_override is not set, sudo will set the umask to be the union of the user's umask and what is specified in sudoers. This flag is off by default. * use_pty If set, and sudo is running in a terminal, the command will be run in a pseudo-terminal (even if no I/O logging is being done). If the sudo process is not attached to a terminal, use_pty has no effect. A malicious program run under sudo may be capable of injecting commands into the user's terminal or running a background process that retains access to the user's terminal device even after the main program has finished executing. By running the command in a separate pseudo-terminal, this attack is no longer possible. This flag is on by default. ## Integers: * passwd_tries The number of tries a user gets to enter his/her password before sudo logs the failure and exits. The default is 3. ## Integers that can be used in a boolean context: * timestamp_timeout Number of minutes that can elapse before sudo will ask for a passwd again. The timeout may include a fractional component if minute granularity is insufficient, for example 2.5. The default is 15. Set this to 0 to always prompt for a password. * umask File mode creation mask to use when running the command. Negate this option or set it to 0777 to prevent sudo from changing the umask. Unless the umask_override flag is set, the actual umask will be the union of the user's umask and the value of the umask setting, which defaults to 0022. This guarantees that sudo never lowers the umask when running a command. If umask is explicitly set, it will override any umask setting in PAM. If umask is not set, the umask specified by PAM will take precedence. The umask setting in PAM is not used for sudoedit, which does not create a new PAM session. ## Strings * editor A colon (‘:’) separated list of editor path names used by **sudoedit** and **visudo**. For **sudoedit**, this list is used to find an editor when none of the SUDO_EDITOR, VISUAL or EDITOR environment variables are set to an editor that exists and is executable. For **visudo**, it is used as a white list of allowed editors; **visudo** will choose the editor that matches the user's SUDO_EDITOR, VISUAL or EDITOR environment variable if possible, or the first editor in the list that exists and is executable if not. Unless invoked as **sudoedit**, sudo does not preserve the SUDO_EDITOR, VISUAL or EDITOR environment variables unless they are present in the **env_keep** list. The default on Linux is _/usr/bin/editor_, on FreeBSD _/usr/vim/vi_. ## Strings that can be used in a boolean context: * apparmor_profile The default AppArmor profile to transition into when executing a command. The default apparmor_profile can be overridden for individual sudoers entries by specifying the APPARMOR_PROFILE option. This option is only available when sudo-rs is built with AppArmor support. This option is not set by default. * secure_path If set, sudo will use this value in place of the user's PATH environment variable. This option can be used to reset the PATH to a known good value that contains directories for system administrator commands such as /usr/sbin. This option is not set by default. ## Lists that can be used in a boolean context: * env_check Environment variables to be removed from the user's environment unless they are considered “safe”. For all variables except TZ, “safe” means that the variable's value does not contain any ‘%’ or ‘/’ characters. This can be used to guard against printf-style format vulnerabilities in poorly-written programs. The TZ variable is considered unsafe if any of the following are true: • It consists of a fully-qualified path name, optionally prefixed with a colon (‘:’), that does not match the location of the zoneinfo directory. • It contains a .. path element. • It contains white space or non-printable characters. • It is longer than the value of PATH_MAX. The argument may be a double-quoted, space-separated list or a single value without double-quotes. The list can be replaced, added to, deleted from, or disabled by using the =, +=, -=, and ! operators respectively. Regardless of whether the env_reset option is enabled or disabled, variables specified by env_check will be preserved in the environment if they pass the aforementioned check. The global list of environment variables to check is displayed when sudo is run by root with the -V option. * env_keep Environment variables to be preserved in the user's environment when the env_reset option is in effect. This allows fine-grained control over the environment sudo-spawned processes will receive. The argument may be a double-quoted, space-separated list or a single value without double-quotes. The list can be replaced, added to, deleted from, or disabled by using the =, +=, -=, and ! operators respectively. The global list of variables to keep is displayed when sudo is run by root with the -V option. Preserving the HOME environment variable has security implications since many programs use it when searching for configuration or data files. Adding HOME to env_keep may enable a user to run unrestricted commands via sudo and is strongly discouraged. Users wishing to edit files with sudo should run **sudoedit** (or **sudo -e**) to get their accustomed editor configuration instead of invoking the editor directly. ## LOG FORMAT sudo-rs logs events via syslog(3). ## FILES /etc/sudoers-rs List of who can run what (for co-existence of sudo-rs and Todd Miller's sudo) /etc/sudoers List of who can run what (sudo-compatible) /run/sudo/ts Directory containing timestamps for the sudoers security policy ## SECURITY NOTES ### Limitations of the ‘!’ operator It is generally not effective to “subtract” commands from ALL using the ‘!’ operator. A user can trivially circumvent this by copying the desired command to a different name and then executing that. For example: bill ALL = ALL, !SU, !SHELLS Doesn't really prevent bill from running the commands listed in SU or SHELLS since he can simply copy those commands to a different name, or use a shell escape from an editor or other program. Therefore, these kind of restrictions should be considered advisory at best (and reinforced by policy). In general, if a user has sudo ALL there is nothing to prevent them from creating their own program that gives them a root shell (or making their own copy of a shell) regardless of any ‘!’ elements in the user specification. ### Security implications of `fast_glob` sudo-rs uses `fast_glob, which further means it is not possible to reliably negate commands where the path name includes globbing (aka wildcard) characters. This is because the Rust library's fnmatch function cannot resolve relative paths. While this is typically only an inconvenience for rules that grant privileges, it can result in a security issue for rules that subtract or revoke privileges. For example, given the following sudoers file entry: john ALL = /usr/bin/passwd [a-zA-Z0-9]*, /usr/bin/chsh [a-zA-Z0-9]*,\ /usr/bin/chfn [a-zA-Z0-9]*, !/usr/bin/* root User john can still run /usr/bin/passwd root if fast_glob is enabled by changing to /usr/bin and running ./passwd root instead. ### Preventing shell escapes Once sudo executes a program, that program is free to do whatever it pleases, including run other programs. This can be a security issue since it is not uncommon for a program to allow shell escapes, which lets a user bypass sudo's access control and logging. Common programs that permit shell escapes include shells (obviously), editors, paginators (such as *less*), mail, and terminal programs. On Linux, sudo-rs has sudo's **noexec** functionality, based on a seccomp() filter. Programs that are run in **noexec** mode cannot run other programs. The implementation in sudo-rs is different than in Todd Miller's sudo, and should also work on statically linked binaries. Note that restricting shell escapes is not a panacea. Programs running as root are still capable of many potentially hazardous operations (such as changing or overwriting files) that could lead to unintended privilege escalation. NOEXEC is also not a protection against malicious programs. It doesn't prevent mapping memory as executable, nor does it protect against future syscalls that can do an exec() like the proposed `io_uring` exec feature in Linux. And it also doesn't protect against honest programs that intentionally or not allow the user to write to /proc/self/mem for the same reasons as that it doesn't protect against malicious programs. You should always try out if **noexec** indeed prevents shell escapes for the programs it is intended to be used with. ### Timestamp file checks sudo-rs will check the ownership of its timestamp directory (/run/sudo/ts by default) and ignore the directory's contents if it is not owned by root or if it is writable by a user other than root. While the timestamp directory should be cleared at reboot time, to avoid potential problems, sudo-rs will ignore timestamp files that date from before the machine booted on systems where the boot time is available. Some systems with graphical desktop environments allow unprivileged users to change the system clock. Since sudo-rs relies on the system clock for timestamp validation, it may be possible on such systems for a user to run sudo for longer than *timestamp_timeout* by setting the clock back. To combat this, `sudo-rs` uses a monotonic clock (which never moves backwards) for its timestamps if the system supports it. sudo-rs will not honor timestamps set far in the future. ## SEE ALSO su(1), fnmatch(3), glob(3), sudo(8), visudo(8) ## CAVEATS The sudoers file should always be edited by the visudo utility which locks the file and checks for syntax errors. If sudoers contains syntax errors, you may lock yourself out of being able to use sudo. ## BUGS If you feel you have found a bug in sudo-rs, please submit a bug report at https://github.com/trifectatechfoundation/sudo-rs/issues/ # AUTHORS This man page is a modified version of the sudoers(5) documentation written by Todd Miller; see https://www.sudo.ws/ for the original. ## DISCLAIMER sudo-rs is provided “AS IS” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. sudo-rs-0.2.10/docs/man/visudo.8.man000064400000000000000000000014711046102023000151620ustar 00000000000000.\" Automatically generated by Pandoc 3.6.3 .\" .TH "VISUDO" "8" "" "sudo\-rs 0.2.10" "sudo\-rs" .SH NAME \f[CR]visudo\f[R] \- safely edit the sudoers file .SH SYNOPSIS \f[CR]visudo\f[R] [\f[CR]\-chqsV\f[R]] [[\f[CR]\-f\f[R]] \f[I]sudoers\f[R]] .SH DESCRIPTION \f[CR]visudo\f[R] edits the \f[I]sudoers\f[R] file in a safe manner, similar to vipw(8). .SH OPTIONS .TP \f[CR]\-c\f[R], \f[CR]\-\-check\f[R] Only check if there are errors in the existing sudoers file. .TP \f[CR]\-f\f[R] \f[I]sudoers\f[R], \f[CR]\-\-file\f[R]=\f[I]sudoers\f[R] Instead of editing the default \f[CR]/etc/sudoers\f[R], edit the file specified as \f[I]sudoers\f[R] instead. .TP \f[CR]\-h\f[R], \f[CR]\-\-help\f[R] Show a help message. .TP \f[CR]\-V\f[R], \f[CR]\-\-version\f[R] Display version information and exit. .SH SEE ALSO sudo(8), sudoers(5) sudo-rs-0.2.10/docs/man/visudo.8.md000064400000000000000000000011511046102023000150020ustar 00000000000000--- title: VISUDO(8) sudo-rs 0.2.10 | sudo-rs --- # NAME `visudo` - safely edit the sudoers file # SYNOPSIS `visudo` [`-chqsV`] [[`-f`] *sudoers*] # DESCRIPTION `visudo` edits the *sudoers* file in a safe manner, similar to vipw(8). # OPTIONS `-c`, `--check` : Only check if there are errors in the existing sudoers file. `-f` *sudoers*, `--file`=*sudoers* : Instead of editing the default `/etc/sudoers`, edit the file specified as *sudoers* instead. `-h`, `--help` : Show a help message. `-V`, `--version` : Display version information and exit. # SEE ALSO [sudo(8)](sudo.8.md), sudoers(5) sudo-rs-0.2.10/docs/sudo-cve.md000064400000000000000000000214071046102023000143030ustar 00000000000000# Past sudo CVEs This listing contains security issues originally found in sudo but which could also be relevant for sudo-rs. ## Possibly relevant CVEs / advisories These CVEs/advisories are possibly relevant to sudo-rs: | CVE | Tests | Sudo Advisory / Attack notes | | ---------------------- | ----- | --------------------------------------------------------------------------- | | CVE-1999-0958 [^1] | | Relative path attack (.. attack) | | CVE-1999-1496 [^2] | | Information leakage on which commands exist | | - [^rust] | | https://www.sudo.ws/security/advisories/heap_corruption/ | | CVE-2002-0184 [^rust] | | https://www.sudo.ws/security/advisories/prompt/ | | CVE-2004-1051 [^4] | | https://www.sudo.ws/security/advisories/bash_functions/ | | CVE-2004-1689 [^22] | | https://www.sudo.ws/security/advisories/sudoedit/ | | CVE-2005-1119 [^5] | | Corrupt arbitrary files via a symlink attack | | CVE-2005-1993 [^6] | | https://www.sudo.ws/security/advisories/path_race/ | | CVE-2005-4890 [^7] | | TTY hijacking when a privileged user uses sudo to run unprivileged commands | | - [^9] | | https://www.sudo.ws/security/advisories/cmnd_alias_negation/ | | CVE-2010-0426 [^23] | | https://www.sudo.ws/security/advisories/sudoedit_escalate/ | | CVE-2010-1163 [^23] | | https://www.sudo.ws/security/advisories/sudoedit_escalate2/ | | CVE-2010-1646 [^10] | | https://www.sudo.ws/security/advisories/secure_path/ | | CVE-2010-2956 [^11] | | https://www.sudo.ws/security/advisories/runas_group/ | | CVE-2011-0010 [^12] | | https://www.sudo.ws/security/advisories/runas_group_pw/ | | CVE-2012-0809 [^13] | | https://www.sudo.ws/security/advisories/sudo_debug/ | | CVE-2013-1775 [^14] | | https://www.sudo.ws/security/advisories/epoch_ticket/ | | CVE-2013-1776 [^15] | | https://www.sudo.ws/security/advisories/tty_tickets/ | | CVE-2013-2776 [^15] | | https://www.sudo.ws/security/advisories/tty_tickets/ | | CVE-2013-2777 [^15] | | https://www.sudo.ws/security/advisories/tty_tickets/ | | CVE-2014-9680 [^16] | | https://www.sudo.ws/security/advisories/tz/ | | CVE-2015-5602 [^24] | | https://bugzilla.sudo.ws/show_bug.cgi?id=707 | | CVE-2016-7032 [^17] | | https://www.sudo.ws/security/advisories/noexec_bypass/ | | CVE-2016-7076 [^17] | | https://www.sudo.ws/security/advisories/noexec_wordexp/ | | CVE-2017-1000367 [^18] | | https://www.sudo.ws/security/advisories/linux_tty/ | | CVE-2017-1000368 [^18] | | https://www.sudo.ws/security/advisories/linux_tty/ | | CVE-2019-18634 [^rust] | | https://www.sudo.ws/security/advisories/pwfeedback/ | | CVE-2021-3156 [^21] | | https://www.sudo.ws/security/advisories/unescape_overflow/ | | CVE-2021-23239 [^25] | | https://www.sudo.ws/releases/stable/#1.9.5 | | CVE-2023-22809 [^20] | | https://www.sudo.ws/security/advisories/sudoedit_any/ | | CVE-2023-28486 [^19] | | Syslog messages do not escape control characters | [^1]: All our path checks should only ever be done with absolute paths [^2]: We try to take care to only expose relevant information to the user [^rust]: Our usage of Rust should mostly prevent heap corruption bugs from occurring [^4]: env_reset is always enabled in sudo-rs, additionally we apply filtering to several variables to prevent any additional attack paths [^5]: - [^6]: Sudo-rs uses the suggested realpath function, as it is considered available enough for our target systems [^7]: To prevent attacks, a PTY must be used when running commands within a TTY, which is enabled by default in sudo-rs [^9]: - [^10]: - [^11]: - [^12]: - [^13]: - [^14]: - [^15]: - [^16]: - [^17]: Sudo-rs uses seccomp filtering rather than libc function interception through LD_PRELOAD. [^18]: - [^19]: - [^20]: Sudo-rs doesn't use a "stringly typed" interface between the execution and policy modules. [^21]: Rust memory safety should prevent this, sudo-rs doesn't allow `-s` and `-e` to be combined, and sudo-rs doesn't "unescape" program arguments in the sudoers module [^22]: Reading the changed temporary file back is done by an unprivileged helper process. [^23]: Sudo-rs matches commands based on (canonicalized and absolute) path names, so `sudoedit` never matches; furthermore, invoking `sudo /path/to/sudoedit` will instead run `sudoedit` as the current user. [^24]: Sudo-rs doesn't allow wildcards or symlinks in configuration arguments to sudoedit, and checks that all path components are not writable by the calling user. [^25]: Sudo-rs opens all components of the path to be edited exactly once, and checks that all path components are not writable by the calling user. ## Non-applicable CVEs These CVEs are almost entirely not applicable in the current sudo-rs codebase, mainly because the feature they relate to is not implemented. Sometimes this is done purposefully, because the feature has security implications. Sometimes the feature will be implemented at a later time, these CVEs might become relevant at that time. | CVE | Reason | | -------------- | ----------------------------------------------------------------------------------------------------------- | | CVE-2002-0043 | mail functionality is not implemented, https://www.sudo.ws/security/advisories/postfix/ | | CVE-2005-2959 | env_reset is always enabled / blacklist is not supported, https://www.sudo.ws/security/advisories/bash_env/ | | CVE-2005-4158 | env_reset is always enabled / blacklist is not supported, https://www.sudo.ws/security/advisories/perl_env/ | | CVE-2006-0151 | env_reset is always enabled / blacklist is not supported | | CVE-2007-3149 | Kerberos functionality is not implemented, https://www.sudo.ws/security/advisories/kerberos5/ | | CVE-2009-0034 | The group matching logic does not have this bug, https://www.sudo.ws/security/advisories/group_vector/ | | CVE-2010-0427 | runas_default is not implemented | | CVE-2012-2337 | No host ip-based rule matching is currently implemented, https://www.sudo.ws/security/advisories/netmask/ | | CVE-2012-3440 | Related to Red Hat specific script and not sudo directly | | CVE-2014-0106 | Disabling env_reset is not supported, https://www.sudo.ws/security/advisories/env_add/ | | CVE-2015-8239 | The sha2 digest feature is not implemented | | CVE-2019-14287 | This bug is not present, https://www.sudo.ws/security/advisories/minus_1_uid/ | | CVE-2021-23240 | sudo-rs does not have SELinux support, https://www.sudo.ws/security/advisories/sudoedit_selinux/ | | CVE-2022-43995 | crypt/password backend is not implemented, only PAM | | CVE-2023-27320 | The chroot functionality is not implemented, https://www.sudo.ws/security/advisories/double_free/ | | CVE-2023-28487 | Sudoreplay is not implemented | | CVE-2025-32462 | `sudo -h` is not implemented, https://www.sudo.ws/security/advisories/host_any/ | | CVE-2025-32463 | The chroot functionality is not implemented, https://www.sudo.ws/security/advisories/chroot_bug/ | ## Disputed CVEs While these CVEs are related to sudo, they are disputed as security issues. Either the behavior described in the CVE is intended behavior, or the issue cannot be replicated. | CVE | Notes | | -------------- | ----- | | CVE-2005-1831 | | | CVE-2019-18684 | | | CVE-2019-19234 | | | CVE-2019-19232 | | sudo-rs-0.2.10/docs/undocumented-ogsudo-behavior.md000064400000000000000000000104001046102023000203320ustar 00000000000000This internal document describes ogsudo behavior that's not documented in its man pages. Where possible, this document is formatted as a list of "diffs" on top of version 1.9.5 of the man pages. # [`man sudo`](https://www.sudo.ws/docs/man/1.9.5/sudo.man/) ## [`-D` *directory*, `--chdir` *directory*](https://www.sudo.ws/docs/man/1.9.5/sudo.man/#D) The `--chdir` flag is ignored if its value matches the current working directory. This applies regardless of what the `CWD` policy in the sudoers file specifies for the invoking user. That is, `sudo --chdir=$(pwd) true` is equivalent to `sudo true`. ## [Environment](https://www.sudo.ws/docs/man/1.9.5/sudo.man/#ENVIRONMENT) ### [`SUDO_PS1`](https://www.sudo.ws/docs/man/1.9.5/sudo.man/#SUDO_PS1) The `SUDO_PS1` mechanism has precedence over the `env_keep` and `env_check` mechanisms. If `PS1` is in either of those lists and `PS1` and `SUDO_PS1` are both set in the invoking sure environment, then `PS1` will be set to the value of `SUDO_PS1`. In other words: given `Defaults env_keep = PS1`, `env PS1=a SUDO_PS1=b sudo printenv PS1` prints `b`. ## Miscellaneous This section contains information that can't be placed in any of the existing man page sections. ### Command-Line Interface In the case of short flags that accept a value, the space between the flag and the value is not required. That is, `sudo -u root true` and `sudo -uroot true` are equivalent. # [`man sudoers`](https://www.sudo.ws/docs/man/1.9.5/sudoers.man/) ## [Sudoers file format](https://www.sudo.ws/docs/man/1.9.5/sudoers.man/#SUDOERS_FILE_FORMAT) ### [Aliases](https://www.sudo.ws/docs/man/1.9.5/sudoers.man/#Aliases) The manual states that the allowed syntax for aliases is: > `NAME ::= A-Z*` but that is incorrect; the allowed syntax is actually: `NAME ::= [A-Z]([A-Z][0-9]_)*`. That is, aliases can contain digits and underscores but must start with an uppercase letter. ## [Command environment](https://www.sudo.ws/docs/man/1.9.5/sudoers.man/#Command_environment) ### `VARIABLE=value` matching The manual says: > By default, environment variables are matched by name. However, if the pattern includes an equal > sign (`=`), both the variables name and value must match but does not mention that the equal sign can only be used when surrounded by double quotes. That is, `Defaults env_check = "VARIABLE=value"` is correct syntax. Whereas, `Defaults env_check = VARIABLE=value` is invalid syntax. ### `SUDO_PS1` The manual says: > Environment variables with a value beginning with `()` are removed unless both the name and value > parts are matched by `env_keep` or `env_check`, as they may be interpreted as functions by the > bash shell This does not affect the operation of the `SUDO_PS1` environment variable. That is, `SUDO_PS1="() abc" sudo printenv PS1` prints `() abc` ## [Sudoers options](https://www.sudo.ws/docs/man/1.9.5/sudoers.man/#SUDOERS_OPTIONS) ### `env_keep` The `env_keep` list is not empty by default and contains the following environment variables by name: - `DISPLAY` - `PATH` These variables can be removed from the list using either the override (`=`) or remove (`-=`) operators. ### `env_check` The `env_check` list is not empty by default and contains the following environment variables by name: - `TERM` - `TZ` These variables can be removed from the list using either the override (`=`) or remove (`-=`) operators. ### Applies to both `env_keep` and `env_checek` #### `!` clears the list The manual says that the `!` operator "disables" the list but it would be more accurate to say that it *clears* the list. After clearing the list with `Defaults !env_keep` (`Defaults !env_check`), it's possible to override its contents and/or add items to it. #### Error handling The manual says that the `env_keep` (`env_check`) accepts a list of space-separated variables but does not mention how errors within the list are handled. sudo skips / ignores malformed items and updates the list using only the well-formed items. That is, given `Defaults env_keep += "A.* VARIABLE"` sudo adds `VARIABLE` to the `env_keep` list. #### `SUDO_` variables This is not explicit in the manual: it's not possible to preserve the following variables from the invoking user's environment as they'll be set by sudo. - `SUDO_COMMAND` - `SUDO_GID` - `SUDO_UID` - `SUDO_USER` sudo-rs-0.2.10/get-pam-variant.bash000075500000000000000000000003301046102023000151320ustar 00000000000000#!/usr/bin/env bash # FIXME read headers to find the actually used variant case $(uname) in Linux) echo linuxpam ;; FreeBSD) echo openpam ;; *) echo "Unsupported platform" exit 1 ;; esac sudo-rs-0.2.10/make-lcov-info.bash000075500000000000000000000013011046102023000147440ustar 00000000000000#!/bin/bash set -euo pipefail rustup component add llvm-tools llvm_profdata=$(find "$(rustc --print sysroot)" -name llvm-profdata) profdata="$SUDO_TEST_PROFRAW_DIR"/sudo-rs.profdata $llvm_profdata merge \ -sparse \ "$SUDO_TEST_PROFRAW_DIR"/**/*.profraw \ -o "$profdata" binary="$SUDO_TEST_PROFRAW_DIR"/sudo-rs dockerid=$(docker create sudo-test-rs) docker cp "$dockerid":/usr/bin/sudo "$binary" docker rm "$dockerid" llvm_cov="$(dirname "$llvm_profdata")"/llvm-cov $llvm_cov export \ -format=lcov \ --ignore-filename-regex='/usr/local/cargo/registry' \ --ignore-filename-regex='/rustc' \ --instr-profile="$profdata" \ --object "$binary" \ -path-equivalence=/usr/src/sudo,"$(pwd)" >lcov.info sudo-rs-0.2.10/src/apparmor.rs000064400000000000000000000053021046102023000142560ustar 00000000000000use std::ffi::{c_char, c_int, CStr, CString}; use std::{fs, io, mem}; use crate::cutils::cerr; /// Set the profile for the next exec call if AppArmor is enabled pub fn set_profile_for_next_exec(profile_name: &str) -> io::Result<()> { if apparmor_is_enabled()? { apparmor_prepare_exec(profile_name) } else { // if the sysadmin doesn't have apparmor enabled, fail softly Ok(()) } } fn apparmor_is_enabled() -> io::Result { match fs::read_to_string("/sys/module/apparmor/parameters/enabled") { Ok(enabled) => Ok(enabled.starts_with("Y")), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), Err(e) => Err(e), } } /// Switch the apparmor profile to the given profile on the next exec call fn apparmor_prepare_exec(new_profile: &str) -> io::Result<()> { // SAFETY: Always safe to call unsafe { libc::dlerror() }; // Clear any existing error // SAFETY: Loading a known safe dylib. LD_LIBRARY_PATH is ignored because we are setuid. let handle = unsafe { libc::dlopen(c"libapparmor.so.1".as_ptr(), libc::RTLD_NOW) }; if handle.is_null() { // SAFETY: In case of an error, dlerror returns a valid C string. return Err(io::Error::new(io::ErrorKind::NotFound, unsafe { CStr::from_ptr(libc::dlerror()) .to_string_lossy() .into_owned() })); } // SAFETY: dlsym will either return a function pointer of the right signature or NULL. let aa_change_onexec = unsafe { libc::dlsym(handle, cstr!("aa_change_onexec").as_ptr()) }; if aa_change_onexec.is_null() { // SAFETY: Always safe to call let err = unsafe { libc::dlerror() }; return Err(if err.is_null() { // There was no error in dlsym, but the symbol itself was defined as NULL pointer. // This is still an error for us, but dlerror will not return any error. io::Error::new( io::ErrorKind::Other, "aa_change_onexec symbol is a NULL pointer", ) } else { // SAFETY: In case of an error, dlerror returns a valid C string. io::Error::new(io::ErrorKind::NotFound, unsafe { CStr::from_ptr(err).to_string_lossy().into_owned() }) }); } //SAFETY: aa_change_onexec is non-NULL, so we can cast it into a function pointer let aa_change_onexec: unsafe extern "C" fn(*const c_char) -> c_int = unsafe { mem::transmute(aa_change_onexec) }; let new_profile_cstr = CString::new(new_profile)?; // SAFETY: new_profile_cstr provided by CString ensures a valid ptr cerr(unsafe { aa_change_onexec(new_profile_cstr.as_ptr()) })?; Ok(()) } sudo-rs-0.2.10/src/common/bin_serde.rs000064400000000000000000000075301046102023000156640ustar 00000000000000//! Binary serialization, and an implementation over Unix pipes. use sealed::DeSerializeBytes; use std::{ io::{self, Read, Write}, marker::PhantomData, os::{ fd::{AsFd, BorrowedFd}, unix::net::UnixStream, }, }; mod sealed { pub trait DeSerializeBytes { fn zero_init() -> Self; fn as_mut_ref(&mut self) -> &mut [u8]; } impl DeSerializeBytes for [u8; N] { fn zero_init() -> [u8; N] { [0; N] } fn as_mut_ref(&mut self) -> &mut [u8] { self.as_mut_slice() } } } /// Serialization/deserialization trait using a byte array as storage. pub trait DeSerialize { /// Usually `[u8; std::mem::size_of::()]`. type Bytes: sealed::DeSerializeBytes; fn serialize(&self) -> Self::Bytes; fn deserialize(bytes: Self::Bytes) -> Self; } /// A binary pipe that can send and receive typed messages. /// /// By default, if only one generic is included, /// the types of the [BinPipe::write()] and [BinPipe::read()] messages /// are the same. pub struct BinPipe { sock: UnixStream, _read_marker: PhantomData, _write_marker: PhantomData, } impl BinPipe { /// A pipe abstracting over a [UnixStream] with easier /// binary serialization, to help with the buffer sizes and ser/de steps. /// Uses [UnixStream::pair()]. pub fn pair() -> io::Result<(BinPipe, BinPipe)> { let (first, second) = UnixStream::pair()?; Ok(( BinPipe { sock: first, _read_marker: PhantomData::, _write_marker: PhantomData::, }, // R and W are inverted here since the type of what's written in one // pipe is read in the other, and vice versa. BinPipe { sock: second, _read_marker: PhantomData::, _write_marker: PhantomData::, }, )) } /// Read a `R` from the pipe. pub fn read(&mut self) -> io::Result { let mut bytes = R::Bytes::zero_init(); self.sock.read_exact(bytes.as_mut_ref())?; Ok(R::deserialize(bytes)) } /// Write a `W` to the pipe. pub fn write(&mut self, bytes: &W) -> io::Result<()> { self.sock.write_all(bytes.serialize().as_mut_ref())?; Ok(()) } /// Calls [std::net::TcpStream::set_nonblocking] on the underlying socket. #[cfg(debug_assertions)] pub fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { self.sock.set_nonblocking(nonblocking) } } impl AsFd for BinPipe { fn as_fd(&self) -> BorrowedFd<'_> { self.sock.as_fd() } } impl DeSerialize for i32 { type Bytes = [u8; std::mem::size_of::()]; fn serialize(&self) -> Self::Bytes { self.to_ne_bytes() } fn deserialize(bytes: Self::Bytes) -> Self { Self::from_ne_bytes(bytes) } } #[cfg(test)] mod tests { use super::*; #[test] pub fn single_type() { let (mut tx, mut rx) = BinPipe::pair().unwrap(); tx.write(&42i32).unwrap(); assert_eq!(rx.read().unwrap(), 42); rx.write(&23i32).unwrap(); assert_eq!(tx.read().unwrap(), 23); } impl DeSerialize for u8 { type Bytes = [u8; std::mem::size_of::()]; fn serialize(&self) -> [u8; 1] { self.to_ne_bytes() } fn deserialize(bytes: [u8; 1]) -> Self { Self::from_ne_bytes(bytes) } } #[test] pub fn different_types() { let (mut tx, mut rx) = BinPipe::pair().unwrap(); tx.write(&42i32).unwrap(); assert_eq!(rx.read().unwrap(), 42); rx.write(&23u8).unwrap(); assert_eq!(tx.read().unwrap(), 23); } } sudo-rs-0.2.10/src/common/command.rs000064400000000000000000000135131046102023000153460ustar 00000000000000use std::{ fmt::Display, path::{Path, PathBuf}, }; use crate::system::escape_os_str_lossy; use super::resolve::{canonicalize, resolve_path}; #[derive(Debug, Default)] #[cfg_attr(test, derive(PartialEq))] pub struct CommandAndArguments { pub(crate) command: PathBuf, pub(crate) arguments: Vec, pub(crate) resolved: bool, pub(crate) arg0: Option, } impl Display for CommandAndArguments { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let cmd = escape_os_str_lossy(self.command.as_os_str()); let args = self .arguments .iter() .map(|a| a.escape_default().collect::()) .collect::>() .join(" "); write!(f, "{cmd} {args}") } } // when -i and -s are used, the arguments given to sudo are escaped "except for alphanumerics, underscores, hyphens, and dollar signs." fn escaped(arguments: Vec) -> String { arguments .into_iter() .map(|arg| { arg.chars() .map(|c| match c { '_' | '-' | '$' => c.to_string(), c if c.is_alphanumeric() => c.to_string(), _ => ['\\', c].iter().collect(), }) .collect() }) .collect::>() .join(" ") } //checks whether the Path is actually describing a qualified path (i.e. contains "/") //or just specifying the name of a file (in which case we are going to resolve it via PATH) fn is_qualified(path: impl AsRef) -> bool { path.as_ref().parent() != Some(Path::new("")) } impl CommandAndArguments { pub fn build_from_args(shell: Option, mut arguments: Vec, path: &str) -> Self { let mut resolved = true; let mut command; let mut arg0 = None; if let Some(chosen_shell) = shell { command = chosen_shell; if !arguments.is_empty() { arguments = vec!["-c".to_string(), escaped(arguments)] } } else { command = arguments.first().map(|s| s.into()).unwrap_or_default(); arguments.remove(0); // remember the original binary name before resolving symlinks; this is not // to be used except for setting the `arg0` arg0 = Some(command.clone()); // resolve the command, remembering errors (but not propagating them) if !is_qualified(&command) { match resolve_path(&command, path) { Some(qualified_path) => command = qualified_path, None => resolved = false, } } } // resolve symlinks, even if the command was obtained through a PATH or SHELL // once again, failure to canonicalize should not stop the pipeline match canonicalize(&command) { Ok(canon_path) => command = canon_path, Err(_) => resolved = false, } CommandAndArguments { command, arguments, resolved, arg0, } } } #[cfg(test)] mod test { use super::{escaped, CommandAndArguments}; #[test] fn test_escaped() { let test = |src: &[&str], target: &str| { assert_eq!( &escaped(src.iter().map(|s| s.to_string()).collect()), target ); }; test(&["a", "b", "c"], "a b c"); test(&["a", "b c"], "a b\\ c"); test(&["a", "b-c"], "a b-c"); test(&["a", "b#c"], "a b\\#c"); test(&["1 2 3"], "1\\ 2\\ 3"); test(&["! @ $"], "\\!\\ \\@\\ $"); } #[test] fn test_build_command_and_args() { assert_eq!( CommandAndArguments::build_from_args( None, vec!["/usr/bin/fmt".into(), "hello".into()], "/bin" ), CommandAndArguments { command: "/usr/bin/fmt".into(), arguments: vec!["hello".into()], resolved: true, arg0: Some("/usr/bin/fmt".into()), } ); assert_eq!( CommandAndArguments::build_from_args( None, vec!["fmt".into(), "hello".into()], "/tmp:/usr/bin:/bin" ), CommandAndArguments { command: "/usr/bin/fmt".into(), arguments: vec!["hello".into()], resolved: true, arg0: Some("fmt".into()), } ); assert_eq!( CommandAndArguments::build_from_args( None, vec!["thisdoesnotexist".into(), "hello".into()], "" ), CommandAndArguments { command: "thisdoesnotexist".into(), arguments: vec!["hello".into()], resolved: false, arg0: Some("thisdoesnotexist".into()), } ); assert_eq!( CommandAndArguments::build_from_args( Some("shell".into()), vec!["ls".into(), "hello".into()], "/bin" ), CommandAndArguments { command: "shell".into(), arguments: vec!["-c".into(), "ls hello".into()], resolved: false, arg0: None, } ); } #[test] fn qualified_paths() { use super::is_qualified; assert!(is_qualified("foo/bar")); assert!(is_qualified("a/b/bar")); assert!(is_qualified("a/b//bar")); assert!(is_qualified("/bar")); assert!(is_qualified("/bar/")); assert!(is_qualified("/bar/foo/")); assert!(is_qualified("/")); assert!(is_qualified("")); // don't try to resolve "" assert!(!is_qualified("bar")); } } sudo-rs-0.2.10/src/common/context.rs000064400000000000000000000247531046102023000154240ustar 00000000000000use std::{env, io}; use crate::common::{HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_VALUE_2}; use crate::exec::{RunOptions, Umask}; #[cfg_attr(not(feature = "sudoedit"), allow(unused_imports))] use crate::sudo::{SudoEditOptions, SudoListOptions, SudoRunOptions, SudoValidateOptions}; use crate::sudoers::Sudoers; use crate::system::{audit::sudo_call, Group, Hostname, Process, User}; use super::{ command::CommandAndArguments, resolve::{resolve_shell, resolve_target_user_and_group, CurrentUser}, Error, SudoPath, }; #[derive(Debug)] pub struct Context { // cli options pub launch: LaunchType, pub chdir: Option, pub command: CommandAndArguments, pub target_user: User, pub target_group: Group, pub stdin: bool, pub bell: bool, pub prompt: Option, pub non_interactive: bool, pub use_session_records: bool, // system pub hostname: Hostname, pub current_user: CurrentUser, pub process: Process, // policy pub use_pty: bool, pub noexec: bool, pub umask: Umask, pub noninteractive_auth: bool, // sudoedit #[cfg_attr(not(feature = "sudoedit"), allow(unused))] pub files_to_edit: Vec>, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[repr(u32)] pub enum LaunchType { #[default] Direct = HARDENED_ENUM_VALUE_0, Shell = HARDENED_ENUM_VALUE_1, Login = HARDENED_ENUM_VALUE_2, } impl Context { pub fn from_run_opts( sudo_options: SudoRunOptions, policy: &mut Sudoers, ) -> Result { let hostname = Hostname::resolve(); let current_user = CurrentUser::resolve()?; let (target_user, target_group) = resolve_target_user_and_group(&sudo_options.user, &sudo_options.group, ¤t_user)?; let launch = if sudo_options.login { LaunchType::Login } else if sudo_options.shell { LaunchType::Shell } else { LaunchType::Direct }; let shell = resolve_shell(launch, ¤t_user, &target_user); let override_path = policy.search_path(&hostname, ¤t_user, &target_user); let command = { let system_path; let path = if let Some(path) = override_path { path } else { system_path = env::var("PATH").unwrap_or_default(); system_path.as_ref() }; sudo_call(&target_user, &target_group, || { CommandAndArguments::build_from_args(shell, sudo_options.positional_args, path) })? }; let prompt = sudo_options.prompt.or_else(|| env::var("SUDO_PROMPT").ok()); Ok(Context { hostname, command, current_user, target_user, target_group, use_session_records: !sudo_options.reset_timestamp, launch, chdir: sudo_options.chdir, stdin: sudo_options.stdin, bell: sudo_options.bell, prompt, non_interactive: sudo_options.non_interactive, process: Process::new(), use_pty: true, noexec: false, umask: Umask::Preserve, noninteractive_auth: false, files_to_edit: vec![], }) } #[cfg(feature = "sudoedit")] pub fn from_edit_opts(sudo_options: SudoEditOptions) -> Result { use std::path::Path; let hostname = Hostname::resolve(); let current_user = CurrentUser::resolve()?; let (target_user, target_group) = resolve_target_user_and_group(&sudo_options.user, &sudo_options.group, ¤t_user)?; // resolve file arguments; if something can't be resolved, don't add it to the "edit" list let resolved_args = sudo_call(&target_user, &target_group, || { sudo_options.positional_args.iter().map(|arg| { let path = Path::new(arg); let absolute_path; crate::common::resolve::canonicalize_newfile(if path.is_absolute() { path } else { absolute_path = Path::new(".").join(path); &absolute_path }) .map_err(|_| arg) .and_then(|path| path.into_os_string().into_string().map_err(|_| arg)) }) })?; let files_to_edit = resolved_args .clone() .map(|path| path.ok().map(SudoPath::from_cli_string)) .collect(); // if a path resolved to something that isn't in UTF-8, it means it isn't in the sudoers file // as well and so we treat it "as is" wrt. the policy lookup and fail if the user is allowed // by the policy to edit that file. this is to prevent leaking information. let arguments = resolved_args .map(|arg| match arg { Ok(arg) => arg, Err(arg) => arg.to_owned(), }) .collect(); // TODO: the more Rust way of doing things would be to create an alternative for sudoedit instead; // but a stringly typed interface feels the most decent thing to do (if we can pull it off) // since "sudoedit" really is like a builtin command to sudo. We may want to be a bit 'better' than // ogsudo in the future. let command = CommandAndArguments { command: std::path::PathBuf::from("sudoedit"), arguments, ..Default::default() }; Ok(Context { hostname, command, current_user, target_user, target_group, use_session_records: !sudo_options.reset_timestamp, launch: Default::default(), chdir: sudo_options.chdir, stdin: sudo_options.stdin, bell: sudo_options.bell, prompt: sudo_options.prompt, non_interactive: sudo_options.non_interactive, process: Process::new(), use_pty: true, noexec: false, umask: Umask::Preserve, noninteractive_auth: false, files_to_edit, }) } pub fn from_validate_opts(sudo_options: SudoValidateOptions) -> Result { let hostname = Hostname::resolve(); let current_user = CurrentUser::resolve()?; let (target_user, target_group) = resolve_target_user_and_group(&sudo_options.user, &sudo_options.group, ¤t_user)?; Ok(Context { hostname, command: Default::default(), current_user, target_user, target_group, use_session_records: !sudo_options.reset_timestamp, launch: Default::default(), chdir: None, stdin: sudo_options.stdin, bell: sudo_options.bell, prompt: sudo_options.prompt, non_interactive: sudo_options.non_interactive, process: Process::new(), use_pty: true, noexec: false, umask: Umask::Preserve, noninteractive_auth: false, files_to_edit: vec![], }) } pub fn from_list_opts( sudo_options: SudoListOptions, policy: &mut Sudoers, ) -> Result { let hostname = Hostname::resolve(); let current_user = CurrentUser::resolve()?; let (target_user, target_group) = resolve_target_user_and_group(&sudo_options.user, &sudo_options.group, ¤t_user)?; let override_path = policy.search_path(&hostname, ¤t_user, &target_user); let command = if sudo_options.positional_args.is_empty() { Default::default() } else { let system_path; let path = if let Some(path) = override_path { path } else { system_path = env::var("PATH").unwrap_or_default(); system_path.as_ref() }; sudo_call(&target_user, &target_group, || { CommandAndArguments::build_from_args(None, sudo_options.positional_args, path) })? }; Ok(Context { hostname, command, current_user, target_user, target_group, use_session_records: !sudo_options.reset_timestamp, launch: Default::default(), chdir: None, stdin: sudo_options.stdin, bell: sudo_options.bell, prompt: sudo_options.prompt, non_interactive: sudo_options.non_interactive, process: Process::new(), use_pty: true, noexec: false, umask: Umask::Preserve, noninteractive_auth: false, files_to_edit: vec![], }) } pub(crate) fn try_as_run_options(&self) -> io::Result> { Ok(RunOptions { command: if self.command.resolved { &self.command.command } else { return Err(io::ErrorKind::NotFound.into()); }, arguments: &self.command.arguments, arg0: self.command.arg0.as_deref(), chdir: self.chdir.as_deref(), is_login: self.launch == LaunchType::Login, user: &self.target_user, group: &self.target_group, umask: self.umask, use_pty: self.use_pty, noexec: self.noexec, }) } } #[cfg(test)] mod tests { use crate::{common::resolve::CurrentUser, sudo::SudoAction, system::Hostname}; use super::Context; #[test] fn test_build_run_context() { let mut options = SudoAction::try_parse_from(["sudo", "echo", "hello"]) .unwrap() .try_into_run() .ok() .unwrap(); let current_user = CurrentUser::resolve().unwrap(); options.user = Some(current_user.name.clone()); let context = Context::from_run_opts(options, &mut Default::default()).unwrap(); if cfg!(target_os = "linux") { // this assumes /bin is a symlink on /usr/bin, like it is on modern Debian/Ubuntu assert_eq!(context.command.command.to_str().unwrap(), "/usr/bin/echo"); } else { assert_eq!(context.command.command.to_str().unwrap(), "/bin/echo"); } assert_eq!(context.command.arguments, ["hello"]); assert_eq!(context.hostname, Hostname::resolve()); assert_eq!(context.target_user.uid, current_user.uid); } } sudo-rs-0.2.10/src/common/error.rs000064400000000000000000000104401046102023000150550ustar 00000000000000use crate::{pam::PamError, system::Hostname}; use std::{borrow::Cow, fmt, path::PathBuf}; use super::{SudoPath, SudoString}; #[derive(Debug)] pub enum Error { Silent, NotAllowed { username: SudoString, command: Cow<'static, str>, hostname: Hostname, other_user: Option, }, SelfCheck, CommandNotFound(PathBuf), InvalidCommand(PathBuf), ChDirNotAllowed { chdir: SudoPath, command: PathBuf, }, UserNotFound(String), GroupNotFound(String), Authorization(String), InteractionRequired, EnvironmentVar(Vec), Configuration(String), Options(String), Pam(PamError), Io(Option, std::io::Error), MaxAuthAttempts(u16), PathValidation(PathBuf), StringValidation(String), #[cfg(feature = "apparmor")] AppArmor(String, std::io::Error), } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Error::Silent => Ok(()), Error::NotAllowed { username, command, hostname, other_user, } => { if let Some(other_user) = other_user { write!( f, "Sorry, user {username} is not allowed to execute '{command}' as {other_user} on {hostname}.", ) } else { write!( f, "Sorry, user {username} may not run {command} on {hostname}.", ) } } Error::SelfCheck => { f.write_str("sudo must be owned by uid 0 and have the setuid bit set") } Error::CommandNotFound(p) => write!(f, "'{}': command not found", p.display()), Error::InvalidCommand(p) => write!(f, "'{}': invalid command", p.display()), Error::UserNotFound(u) => write!(f, "user '{u}' not found"), Error::GroupNotFound(g) => write!(f, "group '{g}' not found"), Error::Authorization(u) => write!(f, "I'm sorry {u}. I'm afraid I can't do that"), Error::InteractionRequired => write!(f, "interactive authentication is required"), Error::EnvironmentVar(vs) => { write!( f, "you are not allowed to set the following environment variables:" )?; let mut sep = ""; for v in vs { write!(f, "{sep} {v}")?; sep = ","; } Ok(()) } Error::Configuration(e) => write!(f, "invalid configuration: {e}"), Error::Options(e) => write!(f, "{e}"), Error::Pam(e) => write!(f, "{e}"), Error::Io(location, e) => { if let Some(path) = location { write!(f, "cannot execute '{}': {e}", path.display()) } else { write!(f, "IO error: {e}") } } Error::MaxAuthAttempts(num) => { write!(f, "Maximum {num} incorrect authentication attempts") } Error::ChDirNotAllowed { chdir, command } => write!( f, "you are not allowed to use '--chdir {}' with '{}'", chdir.display(), command.display() ), Error::StringValidation(string) => { write!(f, "invalid string: {string:?}") } Error::PathValidation(path) => { write!(f, "invalid path: {path:?}") } #[cfg(feature = "apparmor")] Error::AppArmor(profile, e) => { write!(f, "unable to change AppArmor profile to {profile}: {e}") } } } } impl From for Error { fn from(err: PamError) -> Self { Error::Pam(err) } } impl From for Error { fn from(err: std::io::Error) -> Self { Error::Io(None, err) } } impl Error { /// Returns `true` if the error is [`Silent`]. /// /// [`Silent`]: Error::Silent #[must_use] pub fn is_silent(&self) -> bool { matches!(self, Self::Silent) } } sudo-rs-0.2.10/src/common/mod.rs000064400000000000000000000017711046102023000145120ustar 00000000000000#![forbid(unsafe_code)] pub use command::CommandAndArguments; pub use context::Context; pub use error::Error; pub use path::SudoPath; pub use string::SudoString; pub mod bin_serde; pub mod command; pub mod context; pub mod error; mod path; pub mod resolve; mod string; // Hardened enum values used for critical enums to mitigate attacks like Rowhammer. // See for example https://arxiv.org/pdf/2309.02545.pdf // The values are copied from https://github.com/sudo-project/sudo/commit/7873f8334c8d31031f8cfa83bd97ac6029309e4f#diff-b8ac7ab4c3c4a75aed0bb5f7c5fd38b9ea6c81b7557f775e46c6f8aa115e02cd pub const HARDENED_ENUM_VALUE_0: u32 = 0x52a2925; // 0101001010100010100100100101 pub const HARDENED_ENUM_VALUE_1: u32 = 0xad5d6da; // 1010110101011101011011011010 pub const HARDENED_ENUM_VALUE_2: u32 = 0x69d61fc8; // 1101001110101100001111111001000 pub const HARDENED_ENUM_VALUE_3: u32 = 0x1629e037; // 0010110001010011110000000110111 pub const HARDENED_ENUM_VALUE_4: u32 = 0x1fc8d3ac; // 11111110010001101001110101100 sudo-rs-0.2.10/src/common/path.rs000064400000000000000000000052711046102023000146660ustar 00000000000000use std::{ ffi::OsString, ops, os::unix::prelude::OsStrExt, path::{Path, PathBuf}, str, }; use super::{Error, SudoString}; /// A `PathBuf` guaranteed to not contain null bytes and be UTF-8 encoded #[derive(Clone, Debug, PartialEq)] #[cfg_attr(test, derive(Eq))] pub struct SudoPath { inner: String, } impl SudoPath { pub fn new(path: PathBuf) -> Result { let bytes = path.as_os_str().as_bytes(); if bytes.contains(&0) { return Err(Error::PathValidation(path)); } // check this through a reference so we can return `path` in the error case if str::from_utf8(bytes).is_err() { return Err(Error::PathValidation(path)); } Ok(Self { // NOTE(unwrap): UTF-8 encoding is checked above inner: path.into_os_string().into_string().unwrap(), }) } pub fn from_cli_string(cli_string: impl Into) -> Self { Self::new(cli_string.into().into()) .expect("strings that come in from CLI should not have interior null bytes") } /// Resolve the use of a '~' that occurs in this `SudoPathBuf`; based on the sudoers context pub fn expand_tilde_in_path(&self, default_username: &SudoString) -> Result { if let Some(prefix) = self.inner.strip_prefix('~') { let (username, relpath) = prefix.split_once('/').unwrap_or((prefix, "")); let username = if username.is_empty() { default_username.clone() } else { SudoString::new(username.to_string()).unwrap() }; let home_dir = crate::system::User::from_name(username.as_cstr()) .ok() .flatten() .ok_or(Error::UserNotFound(username.to_string()))? .home; let path = home_dir.join(relpath); Self::new(path) } else { Ok(self.clone()) } } } impl From for PathBuf { fn from(value: SudoPath) -> Self { value.inner.into() } } impl AsRef for SudoPath { fn as_ref(&self) -> &Path { self.inner.as_ref() } } impl ops::Deref for SudoPath { type Target = Path; fn deref(&self) -> &Self::Target { self.as_ref() } } impl TryFrom for SudoPath { type Error = Error; fn try_from(value: String) -> Result { Self::new(value.into()) } } impl From for OsString { fn from(value: SudoPath) -> Self { value.inner.into() } } #[cfg(test)] impl From<&'_ str> for SudoPath { fn from(value: &'_ str) -> Self { Self::new(value.into()).unwrap() } } sudo-rs-0.2.10/src/common/resolve.rs000064400000000000000000000302541046102023000154100ustar 00000000000000use crate::system::interface::UserId; use crate::system::{Group, User}; use core::fmt; use std::{ env, ffi::CStr, fs, io, ops, os::unix::prelude::MetadataExt, path::{Path, PathBuf}, str::FromStr, }; use super::SudoString; use super::{context::LaunchType, Error}; #[derive(PartialEq, Debug)] enum NameOrId<'a, T: FromStr> { Name(&'a SudoString), Id(T), } impl<'a, T: FromStr> NameOrId<'a, T> { pub fn parse(input: &'a SudoString) -> Option { if input.is_empty() { None } else if let Some(stripped) = input.strip_prefix('#') { stripped.parse::().ok().map(|id| Self::Id(id)) } else { Some(Self::Name(input)) } } } #[derive(Clone)] pub struct CurrentUser { inner: User, } impl From for User { fn from(value: CurrentUser) -> Self { value.inner } } impl fmt::Debug for CurrentUser { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("CurrentUser").field(&self.inner).finish() } } impl ops::Deref for CurrentUser { type Target = User; fn deref(&self) -> &Self::Target { &self.inner } } impl CurrentUser { #[cfg(test)] pub fn fake(user: User) -> Self { Self { inner: user } } pub fn resolve() -> Result { Ok(Self { inner: User::real()?.ok_or(Error::UserNotFound("current user".to_string()))?, }) } } #[derive(Clone, Debug)] pub struct AuthUser(User); impl AuthUser { pub fn from_current_user(user: CurrentUser) -> Self { Self(user.inner) } pub fn resolve_root_for_rootpw() -> Result { Ok(Self( User::from_uid(UserId::ROOT)?.ok_or(Error::UserNotFound("root".to_string()))?, )) } pub fn from_user_for_targetpw(user: User) -> Self { Self(user) } } impl ops::Deref for AuthUser { type Target = User; fn deref(&self) -> &Self::Target { &self.0 } } type Shell = Option; pub(super) fn resolve_shell( launch_type: LaunchType, current_user: &User, target_user: &User, ) -> Shell { match launch_type { LaunchType::Login => Some(target_user.shell.clone()), LaunchType::Shell => Some( env::var("SHELL") .map(|s| s.into()) .unwrap_or_else(|_| current_user.shell.clone()), ), LaunchType::Direct => None, } } pub(crate) fn resolve_target_user_and_group( target_user_name_or_id: &Option, target_group_name_or_id: &Option, current_user: &CurrentUser, ) -> Result<(User, Group), Error> { // resolve user name or # to a user let mut target_user = resolve_from_name_or_id(target_user_name_or_id, User::from_name, User::from_uid)?; // resolve group name or # to a group let mut target_group = resolve_from_name_or_id(target_group_name_or_id, Group::from_name, Group::from_gid)?; match (&target_user_name_or_id, &target_group_name_or_id) { // when -g is specified, but -u is not specified default -u to the current user (None, Some(_)) => { target_user = Some(current_user.clone().into()); } // when -u is specified but -g is not specified, default -g to the primary group of the specified user (Some(_), None) => { if let Some(user) = &target_user { target_group = Some(user.primary_group()?); } } // when no -u or -g is specified, default to root:root (None, None) => { target_user = User::from_name(cstr!("root"))?; target_group = Group::from_name(if cfg!(target_os = "linux") { cstr!("root") } else { cstr!("wheel") })?; } _ => {} } match (target_user, target_group) { (Some(user), Some(group)) => { // resolve success Ok((user, group)) } // group name or id not found (Some(_), None) => Err(Error::GroupNotFound( target_group_name_or_id .as_deref() .unwrap_or_default() .to_string(), )), // user (and maybe group) name or id not found _ => Err(Error::UserNotFound( target_user_name_or_id .as_deref() .unwrap_or_default() .to_string(), )), } } fn resolve_from_name_or_id( input: &Option, from_name: impl FnOnce(&CStr) -> Result, E>, from_id: impl FnOnce(I) -> Result, E>, ) -> Result, E> where I: FromStr, { match input.as_ref().and_then(NameOrId::parse) { Some(NameOrId::Name(name)) => from_name(name.as_cstr()), Some(NameOrId::Id(id)) => from_id(id), None => Ok(None), } } /// Check whether a path points to a regular file and any executable flag is set pub(crate) fn is_valid_executable(path: &PathBuf) -> bool { if path.is_file() { match fs::metadata(path) { Ok(meta) => meta.mode() & 0o111 != 0, _ => false, } } else { false } } /// Resolve a executable name based in the PATH environment variable /// When resolving a path, this code checks whether the target file is /// a regular file and has any executable bits set. It does not specifically /// check for user, group, or others' executable bit. pub(crate) fn resolve_path(command: &Path, path: &str) -> Option { // Depending on the security policy, the user's PATH environment variable may be modified, // replaced, or passed unchanged to the program that sudo executes. path.split(':') .map(Path::new) // ignore all relative paths ("", "." or "./") .filter(|path| path.is_absolute()) // construct a possible executable absolute path candidate .map(|path| path.join(command)) // check whether the candidate is a regular file and any executable flag is set .find(is_valid_executable) } #[cfg(test)] mod tests { use std::path::PathBuf; use crate::common::resolve::CurrentUser; use crate::system::ROOT_GROUP_NAME; use super::{is_valid_executable, resolve_path, resolve_target_user_and_group, NameOrId}; #[test] fn test_resolve_path() { // Assume any linux distro has utilities in this PATH let path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; assert!(is_valid_executable( &resolve_path(&PathBuf::from("yes"), path).unwrap() )); assert!(is_valid_executable( &resolve_path(&PathBuf::from("whoami"), path).unwrap() )); assert!(is_valid_executable( &resolve_path(&PathBuf::from("env"), path).unwrap() )); assert_eq!( resolve_path(&PathBuf::from("thisisnotonyourfs"), path), None ); assert_eq!(resolve_path(&PathBuf::from("thisisnotonyourfs"), "."), None); } #[test] fn test_cwd_resolve_path() { // We modify the path to contain ".", which is supposed to be ignored let path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:."; let cwd = std::env::current_dir().unwrap(); // we filter for executable files, so it is most likely going to pick one of the shell // scripts in the project's root let some_file = cwd .read_dir() .unwrap() .filter_map(|entry| entry.ok()) .find_map(|entry| { let pathbuf = PathBuf::from(entry.file_name()); is_valid_executable(&pathbuf).then_some(pathbuf) }) .unwrap(); assert_eq!(resolve_path(&some_file, path), None); } #[test] fn test_name_or_id() { assert_eq!(NameOrId::::parse(&"".into()), None); assert_eq!( NameOrId::::parse(&"mies".into()), Some(NameOrId::Name(&"mies".into())) ); assert_eq!( NameOrId::::parse(&"1337".into()), Some(NameOrId::Name(&"1337".into())) ); assert_eq!( NameOrId::::parse(&"#1337".into()), Some(NameOrId::Id(1337)) ); assert_eq!(NameOrId::::parse(&"#-1".into()), None); } #[test] fn test_resolve_target_user_and_group() { let current_user = CurrentUser::resolve().unwrap(); // fallback to root let (user, group) = resolve_target_user_and_group(&None, &None, ¤t_user).unwrap(); assert_eq!(user.name, "root"); assert_eq!(group.name.unwrap(), ROOT_GROUP_NAME); // unknown user let result = resolve_target_user_and_group(&Some("non_existing_ghost".into()), &None, ¤t_user); assert!(result.is_err()); // unknown user let result = resolve_target_user_and_group(&None, &Some("non_existing_ghost".into()), ¤t_user); assert!(result.is_err()); // fallback to current user when different group specified let (user, group) = resolve_target_user_and_group(&None, &Some(ROOT_GROUP_NAME.into()), ¤t_user) .unwrap(); assert_eq!(user.name, current_user.name); assert_eq!(group.name.unwrap(), ROOT_GROUP_NAME); // fallback to current users group when no group specified let (user, group) = resolve_target_user_and_group(&Some(current_user.name.clone()), &None, ¤t_user) .unwrap(); assert_eq!(user.name, current_user.name); assert_eq!(group.gid, current_user.gid); } } /// Resolve symlinks in all the directories leading up to a file, but /// not the file itself; this allows sudo to specify a precise policy with /// tools like busybox or pgrep (which is a symlink to pgrep on systems) /// This function will check for existence. pub fn canonicalize>(path: P) -> io::Result { let reconstructed_path = canonicalize_newfile(path)?; // access the object to generate the regular error if it does not exist let _ = fs::metadata(&reconstructed_path)?; Ok(reconstructed_path) } /// Resolve symlinks in all the directories leading up to a file, but /// not the file itself; this allows us to keep symlinks as is, and will /// also work on non-existing files. pub fn canonicalize_newfile>(path: P) -> io::Result { let path = path.as_ref(); let Some(parent) = path.parent() else { // path is "/" or a prefix return Ok(path.to_path_buf()); }; let canon_path = fs::canonicalize(parent)?; let reconstructed_path = if let Some(file_name) = path.file_name() { canon_path.join(file_name) } else { canon_path }; Ok(reconstructed_path) } #[cfg(test)] mod test { use super::canonicalize; use std::path::Path; #[test] fn canonicalization() { assert_eq!(canonicalize("/").unwrap(), Path::new("/")); assert!(canonicalize("").is_err()); if cfg!(any(target_os = "linux")) { // this test REQUIRES /usr/bin/unxz to be a symlink for /usr/bin/xz or /usr/bin/busybox assert!(Path::new("/bin").is_symlink()); assert!(Path::new("/usr/bin/unxz").is_symlink()); assert_eq!( canonicalize("/usr/bin/unxz").unwrap(), Path::new("/usr/bin/unxz") ); // this assumes /bin is a symlink on /usr/bin, like it is on modern Debian/Ubuntu assert_eq!( canonicalize("/bin/unxz").unwrap(), Path::new("/usr/bin/unxz") ); } else if cfg!(target_os = "freebsd") { // this test REQUIRES /usr/bin/pkill to be a symlink assert!(Path::new("/usr/bin/pkill").is_symlink()); assert_eq!( canonicalize("/usr/bin/pkill").unwrap(), Path::new("/usr/bin/pkill") ); assert_eq!(canonicalize("/bin/pkill").unwrap(), Path::new("/bin/pkill")); } else { panic!( "canonicalization test not yet adapted for {}", std::env::consts::OS ); } } } sudo-rs-0.2.10/src/common/string.rs000064400000000000000000000063761046102023000152470ustar 00000000000000use core::fmt; use std::{ ffi::{CStr, OsString}, ops, }; use crate::common::Error; const NULL_BYTE: char = '\0'; const NULL_BYTE_UTF8_LEN: usize = NULL_BYTE.len_utf8(); /// A UTF-8 encoded string with no interior null bytes /// /// This type can be converted into a C (null-terminated) string at no cost #[derive(Clone, PartialEq, Eq)] pub struct SudoString { inner: String, } impl SudoString { pub fn new(mut string: String) -> Result { if string.as_bytes().contains(&0) { return Err(Error::StringValidation(string)); } string.push(NULL_BYTE); Ok(Self { inner: string }) } pub fn from_cli_string(cli_string: impl Into) -> Self { Self::new(cli_string.into()) .expect("strings that come in from CLI should not have interior null bytes") } pub fn as_cstr(&self) -> &CStr { CStr::from_bytes_with_nul(self.inner.as_bytes()).unwrap() } pub fn as_str(&self) -> &str { self } } impl Default for SudoString { fn default() -> Self { Self { inner: NULL_BYTE.into(), } } } #[cfg(test)] impl From<&'_ str> for SudoString { fn from(value: &'_ str) -> Self { SudoString::try_from(value.to_string()).unwrap() } } impl TryFrom for SudoString { type Error = Error; fn try_from(value: String) -> Result { Self::new(value) } } impl From for String { fn from(value: SudoString) -> Self { let mut s = value.inner; s.pop(); s } } impl From for OsString { fn from(value: SudoString) -> Self { let mut s = value.inner; s.pop(); OsString::from(s) } } impl ops::Deref for SudoString { type Target = str; fn deref(&self) -> &Self::Target { let num_bytes = self.inner.len(); &self.inner[..num_bytes - NULL_BYTE_UTF8_LEN] } } impl fmt::Debug for SudoString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s: &str = self; fmt::Debug::fmt(s, f) } } impl fmt::Display for SudoString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self) } } impl PartialEq for SudoString { fn eq(&self, other: &str) -> bool { let s: &str = self; s == other } } impl PartialEq<&'_ str> for SudoString { fn eq(&self, other: &&str) -> bool { let s: &str = self; s == *other } } #[cfg(test)] mod tests { use std::ffi::CString; use super::*; #[test] fn null_byte_is_utf8_encoded_as_a_single_byte() { assert_eq!(1, NULL_BYTE_UTF8_LEN) } #[test] fn sanity_check() { let expected = "hello"; let s = SudoString::new("hello".to_string()).unwrap(); assert_eq!(expected, &*s); } #[test] fn cstr_conversion() { let expected = "hello"; let cstr = CString::from_vec_with_nul((expected.to_string() + "\0").into_bytes()).unwrap(); let s = SudoString::new(expected.to_string()).unwrap(); assert_eq!(&*cstr, s.as_cstr()); } #[test] fn rejects_string_that_contains_interior_null() { assert!(SudoString::new("he\0llo".to_string()).is_err()); } } sudo-rs-0.2.10/src/cutils/mod.rs000064400000000000000000000114431046102023000145220ustar 00000000000000use std::{ ffi::{CStr, OsStr, OsString}, os::{ fd::{AsRawFd, BorrowedFd}, unix::prelude::OsStrExt, }, }; pub fn cerr>(res: Int) -> std::io::Result { match res.try_into() { Ok(-1) => Err(std::io::Error::last_os_error()), _ => Ok(res), } } extern "C" { #[cfg_attr( any(target_os = "macos", target_os = "ios", target_os = "freebsd"), link_name = "__error" )] #[cfg_attr( any(target_os = "openbsd", target_os = "netbsd", target_os = "android"), link_name = "__errno" )] #[cfg_attr(target_os = "linux", link_name = "__errno_location")] fn errno_location() -> *mut libc::c_int; } pub fn set_errno(no: libc::c_int) { // SAFETY: errno_location is a thread-local pointer to an integer, so we are the only writers unsafe { *errno_location() = no }; } pub fn sysconf(name: libc::c_int) -> Option { set_errno(0); // SAFETY: sysconf will always respond with 0 or -1 for every input cerr(unsafe { libc::sysconf(name) }).ok() } /// Create a Rust string copy from a C string pointer /// WARNING: This uses `to_string_lossy` so should not be used for data where /// information loss is unacceptable (use `os_string_from_ptr` instead) /// /// # Safety /// This function assumes that the pointer is either a null pointer or that /// it points to a valid NUL-terminated C string. pub unsafe fn string_from_ptr(ptr: *const libc::c_char) -> String { if ptr.is_null() { String::new() } else { // SAFETY: the function contract says that CStr::from_ptr is safe let cstr = unsafe { CStr::from_ptr(ptr) }; cstr.to_string_lossy().to_string() } } /// Create an `OsString` copy from a C string pointer. /// /// # Safety /// This function assumes that the pointer is either a null pointer or that /// it points to a valid NUL-terminated C string. pub unsafe fn os_string_from_ptr(ptr: *const libc::c_char) -> OsString { if ptr.is_null() { OsString::new() } else { // SAFETY: the function contract says that CStr::from_ptr is safe let cstr = unsafe { CStr::from_ptr(ptr) }; OsStr::from_bytes(cstr.to_bytes()).to_owned() } } fn fstat_mode_set(fildes: &BorrowedFd, mask: libc::mode_t) -> bool { // The Rust standard library doesn't have FileTypeExt on Std{in,out,err}, so we // can't just use FileTypeExt::is_char_device and have to resort to libc::fstat. let mut maybe_stat = std::mem::MaybeUninit::::uninit(); // SAFETY: we are passing fstat a pointer to valid memory if unsafe { libc::fstat(fildes.as_raw_fd(), maybe_stat.as_mut_ptr()) } == 0 { // SAFETY: if `fstat` returned 0, maybe_stat will be initialized let mode = unsafe { maybe_stat.assume_init() }.st_mode; // To complicate matters further, the S_ISCHR macro isn't in libc as well. (mode & libc::S_IFMT) == mask } else { false } } /// Rust's standard library IsTerminal just directly calls isatty, which /// we don't want since this performs IOCTL calls on them and file descriptors are under /// the control of the user; so this checks if they are a character device first. pub fn safe_isatty(fildes: BorrowedFd) -> bool { let is_char_device = fstat_mode_set(&fildes, libc::S_IFCHR); if is_char_device { // SAFETY: isatty will return 0 or 1 unsafe { libc::isatty(fildes.as_raw_fd()) != 0 } } else { false } } /// Check whether the file descriptor is a pipe pub fn is_fifo(fildes: BorrowedFd) -> bool { fstat_mode_set(&fildes, libc::S_IFIFO) } #[allow(clippy::undocumented_unsafe_blocks)] #[cfg(test)] mod test { use super::{os_string_from_ptr, string_from_ptr}; #[test] fn miri_test_str_to_ptr() { let strp = |ptr| unsafe { string_from_ptr(ptr) }; assert_eq!(strp(std::ptr::null()), ""); assert_eq!(strp("\0".as_ptr() as *const libc::c_char), ""); assert_eq!(strp("hello\0".as_ptr() as *const libc::c_char), "hello"); } #[test] fn miri_test_os_str_to_ptr() { let strp = |ptr| unsafe { os_string_from_ptr(ptr) }; assert_eq!(strp(std::ptr::null()), ""); assert_eq!(strp("\0".as_ptr() as *const libc::c_char), ""); assert_eq!(strp("hello\0".as_ptr() as *const libc::c_char), "hello"); } #[test] fn test_tty() { use crate::system::term::Pty; use std::fs::File; use std::os::fd::{AsFd, BorrowedFd}; assert!(!super::safe_isatty(File::open("/bin/sh").unwrap().as_fd())); assert!(!super::safe_isatty(unsafe { BorrowedFd::borrow_raw(-837492) })); let pty = Pty::open().unwrap(); assert!(super::safe_isatty(pty.leader.as_fd())); assert!(super::safe_isatty(pty.follower.as_fd())); } } sudo-rs-0.2.10/src/defaults/mod.rs000064400000000000000000000161551046102023000150330ustar 00000000000000#![forbid(unsafe_code)] pub type SettingsModifier = Box; pub enum ListMode { Set, Add, Del, } #[repr(u32)] pub enum SettingKind { Flag(SettingsModifier) = crate::common::HARDENED_ENUM_VALUE_0, Integer(fn(&str) -> Option) = crate::common::HARDENED_ENUM_VALUE_1, Text(fn(&str) -> Option) = crate::common::HARDENED_ENUM_VALUE_2, List(fn(ListMode, Vec) -> SettingsModifier) = crate::common::HARDENED_ENUM_VALUE_3, } mod settings_dsl; use settings_dsl::{ defaults, emit, has_standard_negator, ifdef, initializer_of, modifier_of, referent_of, result_of, storage_of, }; pub const SYSTEM_EDITOR: &str = if cfg!(target_os = "linux") { "/usr/bin/editor" } else { "/usr/bin/vi" }; defaults! { always_query_group_plugin = false #ignored always_set_home = false #ignored env_reset = true #ignored fqdn = false #ignored ignore_dot = true #ignored lecture = false #ignored mailerpath = None (!= None) #ignored mail_badpass = true #ignored match_group_by_gid = false #ignored use_pty = true visiblepw = false #ignored pwfeedback = false rootpw = false targetpw = false noexec = false noninteractive_auth = false insults = false #ignored setenv = false apparmor_profile = None (!= None) umask = 0o022 (!= 0o777) {octal_mode} umask_override = false passwd_tries = 3 [0..=1000] secure_path = None (!= None) verifypw = all (!= never) [all, always, any, never] #ignored passwd_timeout = (5*60) (!= 0) {fractional_minutes} timestamp_timeout = (15*60) (!= 0) {fractional_minutes} editor = SYSTEM_EDITOR env_editor = true env_keep = ["COLORS", "DISPLAY", "HOSTNAME", "KRB5CCNAME", "LS_COLORS", "PATH", "PS1", "PS2", "XAUTHORITY", "XAUTHORIZATION", "XDG_CURRENT_DESKTOP"] env_check = ["COLORTERM", "LANG", "LANGUAGE", "LC_*", "LINGUAS", "TERM", "TZ"] env_delete = ["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", "*=()*"] #ignored } fn octal_mode(input: &str) -> Option { ::from_str_radix(input.strip_prefix('0')?, 8) .ok() .map(Into::into) } /// A custom parser to parse seconds as fractional "minutes", the format used by /// passwd_timeout and timestamp_timeout. fn fractional_minutes(input: &str) -> Option { if let Some((integral, fractional)) = input.split_once('.') { // - 'input' is maximally 18 characters, making fractional.len() at most 17; // 1e17 < 2**63, so the definition of 'shift' will not overflow. // - for the same reason, if both parses in the definition of 'seconds' succeed, // we will have constructed an integer < 1e17. //- 1e17 * 60 = 6e18 < 9e18 < 2**63, so the final line also will not overflow let shift = 10i64.pow(fractional.len().try_into().ok()?); let seconds = integral.parse::().ok()? * shift + fractional.parse::().ok()?; Some(seconds * 60 / shift) } else { input.parse::().ok()?.checked_mul(60) } } #[cfg(test)] mod test { use super::*; #[allow(clippy::bool_assert_comparison)] #[test] fn check() { let mut def = Settings::default(); assert_eq! { def.always_query_group_plugin, false }; assert_eq! { def.always_set_home, false }; assert_eq! { def.env_reset, true }; assert_eq! { def.mail_badpass, true }; assert_eq! { def.match_group_by_gid, false }; assert_eq! { def.use_pty, true }; assert_eq! { def.visiblepw, false }; assert_eq! { def.env_editor, true }; assert_eq! { def.passwd_tries, 3 }; assert_eq! { def.secure_path, None }; assert_eq! { def.env_check, ["COLORTERM", "LANG", "LANGUAGE", "LC_*", "LINGUAS", "TERM", "TZ"].iter().map(|s| s.to_string()).collect() }; assert_eq! { def.verifypw, enums::verifypw::all }; negate("env_check").unwrap()(&mut def); negate("env_reset").unwrap()(&mut def); negate("secure_path").unwrap()(&mut def); negate("verifypw").unwrap()(&mut def); assert_eq! { def.always_query_group_plugin, false }; assert_eq! { def.always_set_home, false }; assert_eq! { def.env_reset, false }; assert_eq! { def.mail_badpass, true }; assert_eq! { def.match_group_by_gid, false }; assert_eq! { def.use_pty, true }; assert_eq! { def.visiblepw, false }; assert_eq! { def.env_editor, true }; assert_eq! { def.passwd_tries, 3 }; assert_eq! { def.secure_path, None }; assert! { def.env_check.is_empty() }; assert_eq! { def.verifypw, enums::verifypw::never }; let SettingKind::Flag(f) = set("env_reset").unwrap() else { panic!() }; f(&mut def); let SettingKind::Text(f) = set("secure_path").unwrap() else { panic!() }; f("/bin").unwrap()(&mut def); let SettingKind::Integer(f) = set("passwd_tries").unwrap() else { panic!() }; f("5").unwrap()(&mut def); let SettingKind::Text(f) = set("verifypw").unwrap() else { panic!() }; f("any").unwrap()(&mut def); let SettingKind::Integer(f) = set("timestamp_timeout").unwrap() else { panic!() }; f("25.25").unwrap()(&mut def); assert_eq! { def.always_query_group_plugin, false }; assert_eq! { def.always_set_home, false }; assert_eq! { def.env_reset, true }; assert_eq! { def.mail_badpass, true }; assert_eq! { def.match_group_by_gid, false }; assert_eq! { def.use_pty, true }; assert_eq! { def.visiblepw, false }; assert_eq! { def.env_editor, true }; assert_eq! { def.passwd_tries, 5 }; assert_eq! { def.timestamp_timeout, 25*60 + 60/4 }; assert_eq! { def.secure_path, Some("/bin".into()) }; assert! { def.env_check.is_empty() }; assert_eq! { def.verifypw, enums::verifypw::any }; assert!(set("notanoption").is_none()); assert!(f("notanoption").is_none()); } } sudo-rs-0.2.10/src/defaults/settings_dsl.rs000064400000000000000000000165371046102023000167620ustar 00000000000000macro_rules! storage_of { ($id:ident, true) => { bool }; ($id:ident, false) => { bool }; ($id:ident, [ $($value: expr),* ]) => { std::collections::HashSet }; ($id:ident, $(=int $check: expr;)+ $_: expr) => { i64 }; ($id:ident, $(=enum $k: ident;)+ $_: ident) => { $crate::defaults::enums::$id }; ($id:ident, None) => { Option> }; ($id:ident, $_: expr) => { Box }; } macro_rules! referent_of { ($id:ident, true) => { bool }; ($id:ident, false) => { bool }; ($id:ident, [ $($value: expr),* ]) => { &std::collections::HashSet }; ($id:ident, $(=int $check: expr;)+ $_: expr) => { i64 }; ($id:ident, $(=enum $k: ident;)+ $_: ident) => { $crate::defaults::enums::$id }; ($id:ident, None) => { Option<&str> }; ($id:ident, $_: expr) => { &str }; } macro_rules! initializer_of { ($id:ident, true) => { true }; ($id:ident, false) => { false }; ($id:ident, [ $($value: expr),* ]) => { [$($value),*].into_iter().map(|s: &str| s.to_string()).collect::>() }; ($id:ident, $(=int $check: expr;)+ $value: expr) => { $value }; ($id:ident, $(=enum $k: ident;)+ $value: ident) => { $crate::defaults::enums::$id::$value }; ($id:ident, None) => { None }; ($id:ident, $value: expr) => { $value.into() }; ($id:ident, $($_: tt)*) => { return None }; } macro_rules! result_of { ($id:expr, true) => { $id }; ($id:expr, false) => { $id }; ($id:expr, [ $($value: expr),* ]) => { &$id }; ($id:expr, $(=value $k: expr;)+ $_: expr) => { $id }; ($id:expr, None) => { $id.as_deref() }; ($id:expr, $_: expr) => { $id.as_ref() }; } macro_rules! modifier_of { ($id:ident, true) => { $crate::defaults::SettingKind::Flag(Box::new(move |obj: &mut Settings| obj.$id = true)) }; ($id:ident, false) => { $crate::defaults::SettingKind::Flag(Box::new(move |obj: &mut Settings| obj.$id = true)) }; ($id:ident, [ $($value: expr),* ]) => { $crate::defaults::SettingKind::List(|mode, list| { Box::new(move |obj: &mut Settings| match mode { ListMode::Set => obj.$id = list.into_iter().collect(), ListMode::Add => obj.$id.extend(list), ListMode::Del => { for key in list { obj.$id.remove(&key); } } }) }) }; ($id:ident, =int $first:literal ..= $last: literal $(@ $radix: literal)?; $value: expr) => { #[allow(clippy::from_str_radix_10)] $crate::defaults::SettingKind::Integer(|text| { i64::from_str_radix(text, 10$(*0 + $radix)?) .ok() .filter(|val| ($first ..= $last).contains(val)) .map(|i| { Box::new(move |obj: &mut Settings| obj.$id = i) as SettingsModifier }) }) }; ($id:ident, =int $fn: expr; $value: expr) => { $crate::defaults::SettingKind::Integer(|text| { $fn(&text).map(|i| { Box::new(move |obj: &mut Settings| obj.$id = i) as SettingsModifier }) }) }; ($id:ident, $(=int $check: expr;)+ $value: expr) => { compile_error!("bla") }; ($id:ident, $(=enum $key: ident;)+ $value: ident) => { $crate::defaults::SettingKind::Text(|key| match key { $( stringify!($key) => { Some(Box::new(move |obj: &mut Settings| obj.$id = $crate::defaults::enums::$id::$key)) }, )* _ => None, }) }; ($id:ident, None) => { $crate::defaults::SettingKind::Text(|text| { let text = text.into(); Some(Box::new(move |obj: &mut Settings| obj.$id = Some(text))) }) }; ($id:ident, $value: expr) => { $crate::defaults::SettingKind::Text(|text| { let text = text.into(); Some(Box::new(move |obj: &mut Settings| obj.$id = text)) }) }; } macro_rules! has_standard_negator { (true) => { true }; (false) => { true }; ([$($_: expr),*]) => { true }; ($($_: tt)*) => { false }; } // this macro allows us to help the compiler generate more efficient code in 'fn negate' // and enables the way 'fn set' is made macro_rules! ifdef { (; $then: expr; $else: expr) => { $else }; ($($_: expr)+; $then: expr; $else: expr) => { $then }; } macro_rules! emit { (ignored; $($def: tt)*) => { }; ( ; $($def: tt)*) => { $($def)* }; } macro_rules! defaults { ($($name:ident = $value:tt $((!= $negate:tt))? $([$($key:ident),*])? $([$first:literal ..= $last:literal$(; radix: $radix: expr)?])? $({$fn: expr})? $(#$attribute:ident)?)*) => { #[allow(non_camel_case_types)] mod enums { $($( #[derive(Clone,Copy,Debug,Default)] #[cfg_attr(test, derive(PartialEq, Eq))] pub enum $name { #[default] $($key),* } )?)* } #[derive(Clone)] pub struct Settings { $($name: storage_of!($name, $(=int $fn;)?$(=int $first;)?$($(=enum $key;)*)? $value)),* } // we add setters to make sure the settings-object is read only, and to generate 'unused variable' warnings impl Settings { $( emit! { $($attribute)?; pub fn $name(&self) -> referent_of!($name, $(=int $fn;)?$(=int $first;)?$($(=enum $key;)*)? $value) { result_of!(self.$name, $(=value $fn;)?$(=value $first;)?$($(=value $key;)*)? $value) } } )* } impl Default for Settings { #[allow(unused_parens)] fn default() -> Self { Self { $($name: initializer_of!($name, $(=int $fn;)?$(=int $first;)?$($(=enum $key;)*)? $value)),* } } } #[allow(clippy::diverging_sub_expression)] #[allow(unreachable_code)] pub fn negate(name: &str) -> Option { match name { $( stringify!($name) if ifdef!($($negate)?; true; has_standard_negator!($value)) => { let value = ifdef!($($negate)?; // this setting has an explicit negation; use that initializer_of!($name, $(=int $fn;)?$(=int $first;)?$($(=enum $key;)*)? $($negate)?); // for bool and sets, false/empty works (for other types this is dead code) Default::default() ); Some(Box::new(move |obj: &mut Settings| obj.$name = value)) }, )* _ => None } } pub fn set(name: &str) -> Option { match name { $( stringify!($name) => Some(modifier_of!($name, $(=int $fn;)?$(=int $first ..= $last $(@ $radix)?;)?$($(=enum $key;)*)? $value)), )* _ => None, } } }; } pub(super) use defaults; pub(super) use emit; pub(super) use has_standard_negator; pub(super) use ifdef; pub(super) use initializer_of; pub(super) use modifier_of; pub(super) use referent_of; pub(super) use result_of; pub(super) use storage_of; sudo-rs-0.2.10/src/exec/event.rs000064400000000000000000000200351046102023000145020ustar 00000000000000use std::{ fmt::Debug, io, os::fd::{AsFd, AsRawFd, RawFd}, }; use libc::{c_short, pollfd, POLLIN, POLLOUT}; use crate::common::{HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1}; use crate::{cutils::cerr, log::dev_debug}; pub(super) trait Process: Sized { /// IO Events that this process should handle. type Event: Copy + Debug; /// Reason why the event loop should break. /// /// See [`EventRegistry::set_break`] for more information. type Break; /// Reason why the event loop should exit. /// /// See [`EventRegistry::set_exit`] for more information. type Exit; /// Handle the corresponding event. fn on_event(&mut self, event: Self::Event, registry: &mut EventRegistry); } #[repr(u32)] enum Status { Continue = HARDENED_ENUM_VALUE_0, Stop(StopReason) = HARDENED_ENUM_VALUE_1, } impl Status { fn is_break(&self) -> bool { matches!(self, Self::Stop(StopReason::Break(_))) } fn take_stop(&mut self) -> Option> { // If the status ends up to be `Continue`, we are replacing it by another `Continue`. let status = std::mem::replace(self, Self::Continue); match status { Status::Continue => None, Status::Stop(reason) => Some(reason), } } fn take_exit(&mut self) -> Option { match self.take_stop()? { reason @ StopReason::Break(_) => { // Replace back the status because it was not an `Exit`. *self = Self::Stop(reason); None } StopReason::Exit(exit_reason) => Some(exit_reason), } } } pub(super) enum StopReason { Break(T::Break), Exit(T::Exit), } #[derive(PartialEq, Eq, Hash, Ord, PartialOrd, Clone, Copy)] struct EventId(usize); pub(super) struct EventHandle { id: EventId, should_poll: bool, } impl EventHandle { /// Ignore the event associated with this handle, meaning that the file descriptor for this /// event will not be polled anymore for that specific event. pub(super) fn ignore(&mut self, registry: &mut EventRegistry) { if self.should_poll { if let Some(poll_fd) = registry.poll_fds.get_mut(self.id.0) { poll_fd.should_poll = false; self.should_poll = false; } } } /// Stop ignoring the event associated with this handle, meaning that the file descriptor for /// this event will be polled for that specific event. pub(super) fn resume(&mut self, registry: &mut EventRegistry) { if !self.should_poll { if let Some(poll_fd) = registry.poll_fds.get_mut(self.id.0) { poll_fd.should_poll = true; self.should_poll = true; } } } /// Is this event handle ready to be processed? pub(super) fn is_active(&self) -> bool { self.should_poll } } /// The kind of event that will be monitored for a file descriptor. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum PollEvent { /// Data may be read without blocking. Readable, /// Data may be written without blocking. Writable, } struct PollFd { // FIXME ensure that the fd is not closed while the event registry is still open raw_fd: RawFd, event_flags: c_short, should_poll: bool, event: T::Event, } /// A type able to register file descriptors to be polled. pub(super) struct EventRegistry { poll_fds: Vec>, status: Status, } impl EventRegistry { /// Create a new and empty registry.. pub(super) const fn new() -> Self { Self { poll_fds: Vec::new(), status: Status::Continue, } } /// Set the `fd` descriptor to be polled for `poll_event` events and produce `event` when `fd` is /// ready. pub(super) fn register_event( &mut self, fd: &F, poll_event: PollEvent, event_fn: impl Fn(PollEvent) -> T::Event, ) -> EventHandle { let id = EventId(self.poll_fds.len()); self.poll_fds.push(PollFd { raw_fd: fd.as_fd().as_raw_fd(), event_flags: match poll_event { PollEvent::Readable => POLLIN, PollEvent::Writable => POLLOUT, }, should_poll: true, event: event_fn(poll_event), }); EventHandle { id, should_poll: true, } } /// Poll the file descriptors of that are not being ignored and return the ID of the /// descriptors that are ready to be read or written. /// /// Calling this function will block until one of the file descriptors in the set is ready. fn poll(&mut self) -> io::Result> { let (mut ids, mut fds): (Vec, Vec) = self .poll_fds .iter() .enumerate() .filter_map(|(index, poll_fd)| { poll_fd.should_poll.then_some({ ( EventId(index), pollfd { fd: poll_fd.raw_fd, events: poll_fd.event_flags, revents: 0, }, ) }) }) .unzip(); // Don't call poll if there are no file descriptors to be polled. if ids.is_empty() { return Ok(ids); } // SAFETY: `poll` expects a pointer to an array of file descriptors (first argument), // the length of which is indicated by the second argument; the third argument being -1 // denotes an infinite timeout. // FIXME: we should set either a timeout or use ppoll when available. cerr(unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, -1) })?; // Remove the ids that correspond to file descriptors that were not ready. for (i, fd) in fds.iter().enumerate().rev() { let events = fd.events & fd.revents; if !((events & POLLIN != 0) || (events & POLLOUT != 0)) { ids.remove(i); } } Ok(ids) } /// Stop the event loop when the current event has been handled and set a reason for it. /// /// This means that the event loop will stop even if other events are ready. pub(super) fn set_break(&mut self, reason: T::Break) { self.status = Status::Stop(StopReason::Break(reason)); } /// Stop the event loop when the events that are ready by now have been handled and set a /// reason for it. pub(super) fn set_exit(&mut self, reason: T::Exit) { self.status = Status::Stop(StopReason::Exit(reason)); } /// Return whether a break reason has been set already. pub(super) fn got_break(&self) -> bool { self.status.is_break() } /// Run the event loop over this registry using `process` to handle the produced events. /// /// The event loop will continue indefinitely unless you call [`EventRegistry::set_break`] or /// [`EventRegistry::set_exit`]. #[track_caller] pub(super) fn event_loop(mut self, process: &mut T) -> StopReason { let mut event_queue = Vec::with_capacity(self.poll_fds.len()); loop { // FIXME: maybe we should return the IO error instead. if let Ok(ids) = self.poll() { for EventId(index) in ids { let event = self.poll_fds[index].event; dev_debug!("event {event:?} is ready"); event_queue.push(event); } for event in event_queue.drain(..) { process.on_event(event, &mut self); if let Some(reason) = self.status.take_exit() { return StopReason::Exit(reason); } } } if let Some(reason) = self.status.take_stop() { return reason; } } } } sudo-rs-0.2.10/src/exec/io_util.rs000064400000000000000000000011331046102023000150230ustar 00000000000000use std::io; /// Return `true` if the IO error is an interruption. pub(super) fn was_interrupted(err: &io::Error) -> bool { // ogsudo checks against `EINTR` and `EAGAIN`. matches!( err.kind(), io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock ) } /// Call `f` repeatedly until it succeeds or it encounters a non-interruption error. pub(super) fn retry_while_interrupted(mut f: impl FnMut() -> io::Result) -> io::Result { loop { match f() { Err(err) if was_interrupted(&err) => {} result => return result, } } } sudo-rs-0.2.10/src/exec/mod.rs000064400000000000000000000232201046102023000141370ustar 00000000000000mod event; mod io_util; mod no_pty; #[cfg(target_os = "linux")] mod noexec; mod use_pty; use std::{ borrow::Cow, env, ffi::{c_int, OsStr}, io, os::unix::ffi::OsStrExt, os::unix::process::CommandExt, path::Path, process::Command, time::Duration, }; use crate::{ common::{ bin_serde::BinPipe, HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_VALUE_2, }, exec::no_pty::exec_no_pty, log::{dev_info, dev_warn, user_error}, system::{ _exit, interface::ProcessId, kill, killpg, mark_fds_as_cloexec, set_target_user, signal::{consts::*, signal_name, SignalNumber, SignalSet}, term::UserTerm, wait::{Wait, WaitError, WaitOptions}, Group, User, }, }; use self::{ event::{EventRegistry, Process}, io_util::was_interrupted, use_pty::{exec_pty, SIGCONT_BG, SIGCONT_FG}, }; #[cfg(target_os = "linux")] use self::noexec::SpawnNoexecHandler; #[cfg(not(target_os = "linux"))] enum SpawnNoexecHandler {} #[cfg(not(target_os = "linux"))] impl SpawnNoexecHandler { fn spawn(self) {} } #[derive(Debug, Copy, Clone)] #[cfg_attr(test, derive(PartialEq))] #[repr(u32)] pub enum Umask { /// Keep the umask of the parent process. Preserve = HARDENED_ENUM_VALUE_0, /// Mask out more of the permission bits in the new umask. Extend(libc::mode_t) = HARDENED_ENUM_VALUE_1, /// Override the umask of the parent process entirely with the given umask. Override(libc::mode_t) = HARDENED_ENUM_VALUE_2, } pub struct RunOptions<'a> { pub command: &'a Path, pub arguments: &'a [String], pub arg0: Option<&'a Path>, pub chdir: Option<&'a Path>, pub is_login: bool, pub user: &'a User, pub group: &'a Group, pub umask: Umask, pub use_pty: bool, pub noexec: bool, } /// Based on `ogsudo`s `exec_pty` function. /// /// Returns the [`ExitReason`] of the command and a function that restores the default handler for /// signals once its called. pub fn run_command( options: RunOptions<'_>, env: impl IntoIterator, impl AsRef)>, ) -> io::Result { // FIXME: should we pipe the stdio streams? let qualified_path = options.command; let mut command = Command::new(qualified_path); // reset env and set filtered environment command.args(options.arguments).env_clear().envs(env); // set the arg0 to the requested string // TODO: this mechanism could perhaps also be used to set the arg0 for login shells, as below if let Some(arg0) = options.arg0 { command.arg0(arg0); } if options.is_login { // signal to the operating system that the command is a login shell by prefixing "-" let mut process_name = qualified_path .file_name() .map(|osstr| osstr.as_bytes().to_vec()) .unwrap_or_default(); process_name.insert(0, b'-'); command.arg0(OsStr::from_bytes(&process_name)); } let spawn_noexec_handler = if options.noexec { #[cfg(not(target_os = "linux"))] return Err(io::Error::new( io::ErrorKind::Other, "NOEXEC is currently only supported on Linux", )); #[cfg(target_os = "linux")] Some(noexec::add_noexec_filter(&mut command)) } else { None }; // Decide if the pwd should be changed. `--chdir` takes precedence over `-i`. let path = options .chdir .map(|chdir| chdir.to_owned()) .or_else(|| options.is_login.then(|| options.user.home.clone().into())) .clone(); // set target user and groups set_target_user(&mut command, options.user.clone(), options.group.clone()); // change current directory if necessary. if let Some(path) = path { let is_chdir = options.chdir.is_some(); // SAFETY: Chdir as used internally by set_current_dir is async-signal-safe. The logger we // use is also async-signal-safe. unsafe { command.pre_exec(move || { if let Err(err) = env::set_current_dir(&path) { user_error!("unable to change directory to {}: {}", path.display(), err); if is_chdir { return Err(err); } } Ok(()) }); } } // SAFETY: Umask is async-signal-safe. unsafe { let umask = options.umask; command.pre_exec(move || { match umask { Umask::Preserve => {} Umask::Extend(umask) => { // The only options to get the existing umask are overwriting it or // parsing a /proc file. Given that this is a single-threaded context, // overwrite it with a safe value is fine and the simpler option. let existing_umask = libc::umask(0o777); libc::umask(existing_umask | umask); } Umask::Override(umask) => { libc::umask(umask); } } Ok(()) }); } let sudo_pid = ProcessId::new(std::process::id() as i32); if options.use_pty { match UserTerm::open() { Ok(user_tty) => exec_pty(sudo_pid, spawn_noexec_handler, command, user_tty), Err(err) => { dev_info!("Could not open user's terminal, not allocating a pty: {err}"); exec_no_pty(sudo_pid, spawn_noexec_handler, command) } } } else { exec_no_pty(sudo_pid, spawn_noexec_handler, command) } } /// Exit reason for the command executed by sudo. #[derive(Debug)] pub enum ExitReason { Code(i32), Signal(i32), } fn exec_command( mut command: Command, original_set: Option, mut errpipe_tx: BinPipe, ) -> ! { // Restore the signal mask now that the handlers have been setup. if let Some(set) = original_set { if let Err(err) = set.set_mask() { dev_warn!("cannot restore signal mask: {err}"); } } if let Err(err) = mark_fds_as_cloexec() { dev_warn!("failed to close the universe: {err}"); // Send the error to the monitor using the pipe. if let Some(error_code) = err.raw_os_error() { errpipe_tx.write(&error_code).ok(); } // We call `_exit` instead of `exit` to avoid flushing the parent's IO streams by accident. _exit(1); } let err = command.exec(); dev_warn!("failed to execute command: {err}"); // If `exec` returns, it means that executing the command failed. Send the error to the // monitor using the pipe. if let Some(error_code) = err.raw_os_error() { errpipe_tx.write(&error_code).ok(); } // We call `_exit` instead of `exit` to avoid flushing the parent's IO streams by accident. _exit(1); } // Kill the process with increasing urgency. // // Based on `terminate_command`. fn terminate_process(pid: ProcessId, use_killpg: bool) { let kill_fn = if use_killpg { killpg } else { kill }; kill_fn(pid, SIGHUP).ok(); kill_fn(pid, SIGTERM).ok(); std::thread::sleep(Duration::from_secs(2)); kill_fn(pid, SIGKILL).ok(); } trait HandleSigchld: Process { const OPTIONS: WaitOptions; fn on_exit(&mut self, exit_code: c_int, registry: &mut EventRegistry); fn on_term(&mut self, signal: SignalNumber, registry: &mut EventRegistry); fn on_stop(&mut self, signal: SignalNumber, registry: &mut EventRegistry); } fn handle_sigchld( handler: &mut T, registry: &mut EventRegistry, child_name: &'static str, child_pid: ProcessId, ) { let status = loop { match child_pid.wait(T::OPTIONS) { Err(WaitError::Io(err)) if was_interrupted(&err) => {} // This only happens if we receive `SIGCHLD` but there's no status update from the // monitor. Err(WaitError::Io(err)) => { return dev_info!("cannot wait for {child_pid} ({child_name}): {err}"); } // This only happens if the monitor exited and any process already waited for the // monitor. Err(WaitError::NotReady) => { return dev_info!("{child_pid} ({child_name}) has no status report"); } Ok((_pid, status)) => break status, } }; if let Some(exit_code) = status.exit_status() { dev_info!("{child_pid} ({child_name}) exited with status code {exit_code}"); handler.on_exit(exit_code, registry) } else if let Some(signal) = status.stop_signal() { dev_info!( "{child_pid} ({child_name}) was stopped by {}", signal_fmt(signal), ); handler.on_stop(signal, registry) } else if let Some(signal) = status.term_signal() { dev_info!( "{child_pid} ({child_name}) was terminated by {}", signal_fmt(signal), ); handler.on_term(signal, registry) } else if status.did_continue() { dev_info!("{child_pid} ({child_name}) continued execution"); } else { dev_warn!("unexpected wait status for {child_pid} ({child_name})") } } fn signal_fmt(signal: SignalNumber) -> Cow<'static, str> { match signal_name(signal) { name @ Cow::Owned(_) => match signal { SIGCONT_BG => "SIGCONT_BG".into(), SIGCONT_FG => "SIGCONT_FG".into(), _ => name, }, name => name, } } const fn cond_fmt<'a>(cond: bool, true_s: &'a str, false_s: &'a str) -> &'a str { if cond { true_s } else { false_s } } const fn opt_fmt(cond: bool, s: &str) -> &str { cond_fmt(cond, s, "") } sudo-rs-0.2.10/src/exec/no_pty.rs000064400000000000000000000227641046102023000147040ustar 00000000000000use std::{ffi::c_int, io, process::Command}; use super::{ event::PollEvent, event::{EventRegistry, Process, StopReason}, io_util::was_interrupted, terminate_process, ExitReason, HandleSigchld, }; use crate::{ common::bin_serde::BinPipe, system::signal::{ consts::*, register_handlers, SignalHandler, SignalHandlerBehavior, SignalNumber, SignalSet, SignalStream, }, }; use crate::{ exec::{exec_command, handle_sigchld, signal_fmt, SpawnNoexecHandler}, log::{dev_error, dev_info, dev_warn}, system::{ fork, getpgid, getpgrp, interface::ProcessId, kill, killpg, term::{Terminal, UserTerm}, wait::WaitOptions, ForkResult, }, }; pub(super) fn exec_no_pty( sudo_pid: ProcessId, spawn_noexec_handler: Option, command: Command, ) -> io::Result { // FIXME (ogsudo): Initialize the policy plugin's session here. // Block all the signals until we are done setting up the signal handlers so we don't miss // SIGCHLD. let original_set = match SignalSet::full().and_then(|set| set.block()) { Ok(original_set) => Some(original_set), Err(err) => { dev_warn!("cannot block signals: {err}"); None } }; // FIXME (ogsudo): Some extra config happens here if selinux is available. // Use a pipe to get the IO error if `exec` fails. let (errpipe_tx, errpipe_rx) = BinPipe::pair()?; // SAFETY: There should be no other threads at this point. let ForkResult::Parent(command_pid) = unsafe { fork() }.map_err(|err| { dev_warn!("unable to fork command process: {err}"); err })? else { exec_command(command, original_set, errpipe_tx); }; if let Some(spawner) = spawn_noexec_handler { spawner.spawn(); } dev_info!("executed command with pid {command_pid}"); let mut registry = EventRegistry::new(); let mut closure = ExecClosure::new(command_pid, sudo_pid, errpipe_rx, &mut registry)?; // Restore the signal mask now that the handlers have been setup. if let Some(set) = original_set { if let Err(err) = set.set_mask() { dev_warn!("cannot restore signal mask: {err}"); } } let command_exit_reason = match registry.event_loop(&mut closure) { StopReason::Break(err) => return Err(err), StopReason::Exit(reason) => reason, }; // Restore signal handlers drop(closure.signal_handlers); Ok(command_exit_reason) } struct ExecClosure { command_pid: Option, sudo_pid: ProcessId, parent_pgrp: ProcessId, errpipe_rx: BinPipe, signal_stream: &'static SignalStream, signal_handlers: [SignalHandler; ExecClosure::SIGNALS.len()], } impl ExecClosure { const SIGNALS: [SignalNumber; 12] = [ SIGINT, SIGQUIT, SIGTSTP, SIGTERM, SIGHUP, SIGALRM, SIGPIPE, SIGUSR1, SIGUSR2, SIGCHLD, SIGCONT, SIGWINCH, ]; fn new( command_pid: ProcessId, sudo_pid: ProcessId, errpipe_rx: BinPipe, registry: &mut EventRegistry, ) -> io::Result { registry.register_event(&errpipe_rx, PollEvent::Readable, |_| ExecEvent::ErrPipe); let signal_stream = SignalStream::init()?; registry.register_event(signal_stream, PollEvent::Readable, |_| ExecEvent::Signal); let signal_handlers = register_handlers(Self::SIGNALS)?; Ok(Self { command_pid: Some(command_pid), errpipe_rx, sudo_pid, parent_pgrp: getpgrp(), signal_stream, signal_handlers, }) } /// Decides if the signal sent by the process with `signaler_pid` PID is self-terminating. /// /// A signal is self-terminating if `signaler_pid`: /// - is the same PID of the command, or /// - is in the process group of the command and either sudo or the command is the leader. fn is_self_terminating(&self, signaler_pid: ProcessId) -> bool { if signaler_pid.is_valid() { if Some(signaler_pid) == self.command_pid { return true; } if let Ok(signaler_pgrp) = getpgid(signaler_pid) { if Some(signaler_pgrp) == self.command_pid || signaler_pgrp == self.sudo_pid { return true; } } } false } /// Suspend the main process. fn suspend_parent(&self, signal: SignalNumber) { let mut opt_tty = UserTerm::open().ok(); let mut opt_pgrp = None; if let Some(tty) = opt_tty.as_ref() { if let Ok(saved_pgrp) = tty.tcgetpgrp() { // Save the terminal's foreground process group so we can restore it after resuming // if needed. opt_pgrp = Some(saved_pgrp); } else { opt_tty.take(); } } if let Some(saved_pgrp) = opt_pgrp { // This means that the command was stopped trying to access the terminal. If the // terminal has a different foreground process group and we own the terminal, we give // it to the command and let it continue. if let SIGTTOU | SIGTTIN = signal { if saved_pgrp == self.parent_pgrp { if let Some(command_pgrp) = self.command_pid.and_then(|pid| getpgid(pid).ok()) { if command_pgrp != self.parent_pgrp && opt_tty .as_ref() .is_some_and(|tty| tty.tcsetpgrp_nobg(command_pgrp).is_ok()) { if let Err(err) = killpg(command_pgrp, SIGCONT) { dev_warn!("cannot send SIGCONT to command ({command_pgrp}): {err}"); } return; } } } } } let sigtstp_handler = if signal == SIGTSTP { SignalHandler::register(signal, SignalHandlerBehavior::Default) .map_err(|err| dev_warn!("cannot set handler for {}: {err}", signal_fmt(signal))) .ok() } else { None }; if let Err(err) = kill(self.sudo_pid, signal) { dev_warn!( "cannot send {} to {} (sudo): {err}", signal_fmt(signal), self.sudo_pid ); } drop(sigtstp_handler); if let Some(saved_pgrp) = opt_pgrp { // Restore the foreground process group after resuming. if saved_pgrp != self.parent_pgrp { if let Some(tty) = opt_tty { tty.tcsetpgrp_nobg(saved_pgrp).ok(); } } } } fn on_signal(&mut self, registry: &mut EventRegistry) { let info = match self.signal_stream.recv() { Ok(info) => info, Err(err) => { dev_error!("sudo could not receive signal: {err}"); return; } }; dev_info!("received{}", info); let Some(command_pid) = self.command_pid else { dev_info!("command was terminated, ignoring signal"); return; }; match info.signal() { SIGCHLD => handle_sigchld(self, registry, "command", command_pid), signal => { // FIXME: we should handle SIGWINCH here if we want to support I/O plugins that // react on window change events. if let Some(pid) = info.signaler_pid() { if self.is_self_terminating(pid) { // Skip the signal if it was sent by the user and it is self-terminating. return; } } if signal == SIGALRM { terminate_process(command_pid, false); } else { kill(command_pid, signal).ok(); } } } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ExecEvent { Signal, ErrPipe, } impl Process for ExecClosure { type Event = ExecEvent; type Break = io::Error; type Exit = ExitReason; fn on_event(&mut self, event: Self::Event, registry: &mut EventRegistry) { match event { ExecEvent::Signal => self.on_signal(registry), ExecEvent::ErrPipe => { match self.errpipe_rx.read() { Err(err) if was_interrupted(&err) => { /* Retry later */ } Err(err) => registry.set_break(err), Ok(error_code) => { // Received error code from the command, forward it to the parent. registry.set_break(io::Error::from_raw_os_error(error_code)); } } } } } } impl HandleSigchld for ExecClosure { const OPTIONS: WaitOptions = WaitOptions::new().all().untraced().no_hang(); fn on_exit(&mut self, exit_code: c_int, registry: &mut EventRegistry) { registry.set_exit(ExitReason::Code(exit_code)); self.command_pid = None; } fn on_term(&mut self, signal: SignalNumber, registry: &mut EventRegistry) { registry.set_exit(ExitReason::Signal(signal)); self.command_pid = None; } fn on_stop(&mut self, signal: SignalNumber, _registry: &mut EventRegistry) { self.suspend_parent(signal); } } sudo-rs-0.2.10/src/exec/noexec.rs000064400000000000000000000430311046102023000146430ustar 00000000000000// On Linux we can use a seccomp() filter to disable exec. #![allow(non_upper_case_globals)] #![cfg_attr(not(target_arch = "x86_64"), allow(unused))] use std::alloc::{handle_alloc_error, GlobalAlloc, Layout}; use std::ffi::c_void; use std::mem::{align_of, size_of, zeroed}; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; use std::process::Command; use std::ptr::{self, addr_of}; use std::{cmp, io, thread}; use libc::{ c_int, c_uint, c_ulong, close, cmsghdr, iovec, msghdr, prctl, recvmsg, seccomp_data, seccomp_notif, seccomp_notif_resp, seccomp_notif_sizes, sendmsg, sock_filter, sock_fprog, syscall, SYS_execve, SYS_execveat, SYS_seccomp, __errno_location, BPF_ABS, BPF_ALU, BPF_AND, BPF_JEQ, BPF_JMP, BPF_JUMP, BPF_K, BPF_LD, BPF_RET, BPF_STMT, BPF_W, CMSG_DATA, CMSG_FIRSTHDR, CMSG_LEN, CMSG_SPACE, EACCES, ENOENT, MSG_TRUNC, PR_SET_NO_NEW_PRIVS, SCM_RIGHTS, SECCOMP_FILTER_FLAG_NEW_LISTENER, SECCOMP_GET_NOTIF_SIZES, SECCOMP_RET_ALLOW, SECCOMP_RET_KILL_PROCESS, SECCOMP_SET_MODE_FILTER, SECCOMP_USER_NOTIF_FLAG_CONTINUE, SOL_SOCKET, }; const SECCOMP_RET_USER_NOTIF: c_uint = 0x7fc00000; const SECCOMP_IOCTL_NOTIF_RECV: c_ulong = 0xc0502100; const SECCOMP_IOCTL_NOTIF_SEND: c_ulong = 0xc0182101; // from /usr/include/linux/audit.h, converted using bindgen const __AUDIT_ARCH_64BIT: u32 = 0x80000000; const __AUDIT_ARCH_LE: u32 = 0x40000000; const AUDIT_ARCH_AARCH64: u32 = libc::EM_AARCH64 as u32 | __AUDIT_ARCH_64BIT | __AUDIT_ARCH_LE; const AUDIT_ARCH_ARM: u32 = libc::EM_ARM as u32 | __AUDIT_ARCH_LE; const AUDIT_ARCH_I386: u32 = libc::EM_386 as u32 | __AUDIT_ARCH_LE; const AUDIT_ARCH_MIPS: u32 = libc::EM_MIPS as u32; const AUDIT_ARCH_MIPSEL: u32 = libc::EM_MIPS as u32 | __AUDIT_ARCH_LE; const AUDIT_ARCH_MIPS64: u32 = libc::EM_MIPS as u32 | __AUDIT_ARCH_64BIT; const AUDIT_ARCH_MIPSEL64: u32 = libc::EM_MIPS as u32 | __AUDIT_ARCH_64BIT | __AUDIT_ARCH_LE; const AUDIT_ARCH_PPC: u32 = libc::EM_PPC as u32; const AUDIT_ARCH_PPC64: u32 = libc::EM_PPC64 as u32 | __AUDIT_ARCH_64BIT; const AUDIT_ARCH_PPC64LE: u32 = libc::EM_PPC64 as u32 | __AUDIT_ARCH_64BIT | __AUDIT_ARCH_LE; const AUDIT_ARCH_RISCV32: u32 = EM_RISCV as u32 | __AUDIT_ARCH_LE; const AUDIT_ARCH_RISCV64: u32 = EM_RISCV as u32 | __AUDIT_ARCH_64BIT | __AUDIT_ARCH_LE; const AUDIT_ARCH_S390X: u32 = libc::EM_S390 as u32 | __AUDIT_ARCH_64BIT; const AUDIT_ARCH_X86_64: u32 = libc::EM_X86_64 as u32 | __AUDIT_ARCH_64BIT | __AUDIT_ARCH_LE; // workaround: libc doesn't have EM_RISCV yet, so we define it ourselves // see: https://github.com/rust-lang/libc/issues/4603 const EM_RISCV: u16 = 243; /// # Safety /// /// You must follow the rules the Linux man page specifies for the chosen /// seccomp operation. unsafe fn seccomp(operation: c_uint, flags: c_uint, args: *mut T) -> c_int { // SAFETY: By function invariant. unsafe { syscall(SYS_seccomp, operation, flags, args) as c_int } } struct NotifyAllocs { req: *mut seccomp_notif, req_size: usize, resp: *mut seccomp_notif_resp, } /// Linux reserves the right to demand the memory for an object of type T /// to be over-allocated; this function ensures that happens. fn alloc_dynamic(runtime_size: u16) -> (*mut T, usize) { // FIXME put this in a const block once the MSRV has been bumped enough assert!(size_of::() > 0); let layout = Layout::from_size_align( cmp::max(runtime_size.into(), size_of::()), align_of::(), ) .unwrap(); // SAFETY: We assert that T is bigger than 0 bytes and as such the computed layout is also // bigger. let ptr = unsafe { std::alloc::System.alloc_zeroed(layout).cast::() }; if ptr.is_null() { handle_alloc_error(layout); } (ptr, layout.size()) } fn alloc_notify_allocs() -> NotifyAllocs { let mut sizes = seccomp_notif_sizes { seccomp_notif: 0, seccomp_notif_resp: 0, seccomp_data: 0, }; // SAFETY: A valid seccomp_notif_sizes pointer is passed in if unsafe { seccomp(SECCOMP_GET_NOTIF_SIZES, 0, &mut sizes) } == -1 { panic!( "failed to get sizes for seccomp unotify data structures: {}", io::Error::last_os_error(), ); } let (req, req_size) = alloc_dynamic::(sizes.seccomp_notif); let (resp, _) = alloc_dynamic::(sizes.seccomp_notif_resp); NotifyAllocs { req, req_size, resp, } } /// Returns 'None' if the ioctl failed with E_NOENT, 'Some(())' if it succeeded. /// This aborts the program in any other situation. /// /// # Safety /// /// `ioctl(fd, request, ptr)` must be safe to call unsafe fn ioctl(fd: RawFd, request: libc::c_ulong, ptr: *mut T) -> Option<()> { // SAFETY: By function contract if unsafe { libc::ioctl(fd, request as _, ptr) } == -1 { // SAFETY: Trivial if unsafe { *__errno_location() } == ENOENT { None } else { // SAFETY: Not actually unsafe unsafe { libc::abort(); } } } else { Some(()) } } /// # Safety /// /// The argument must be a valid seccomp_unotify fd. unsafe fn handle_notifications(notify_fd: OwnedFd) -> ! { let NotifyAllocs { req, req_size, resp, } = alloc_notify_allocs(); // SAFETY: See individual SAFETY comments let handle_syscall = |create_response: fn(&mut _)| unsafe { // SECCOMP_IOCTL_NOTIF_RECV expects the target struct to be zeroed // SAFETY: req is at least req_size bytes big. std::ptr::write_bytes(req.cast::(), 0, req_size); // SAFETY: A valid pointer to a seccomp_notify is passed in; notify_fd is valid. ioctl(notify_fd.as_raw_fd(), SECCOMP_IOCTL_NOTIF_RECV, req)?; // Allow the first execve call as this is sudo itself starting the target executable. // SAFETY: resp is a valid pointer to a seccomp_notify_resp. (*resp).id = (*req).id; create_response(&mut *resp); // SAFETY: A valid pointer to a seccomp_notify_resp is passed in; notify_fd is valid. ioctl(notify_fd.as_raw_fd(), SECCOMP_IOCTL_NOTIF_SEND, resp) }; loop { if handle_syscall(|resp| { resp.val = 0; resp.error = 0; resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE as _ }) .is_some() { break; } } loop { handle_syscall(|resp| { resp.val = 0; resp.error = -EACCES; resp.flags = 0; }); } } //We must use vectored reads with ancillary data. // //NOTE: some day we can witch to using send/recv_vectored_with_ancillary; see: // - https://doc.rust-lang.org/std/os/unix/net/struct.UnixDatagram.html#method.recv_vectored_with_ancillary // - https://doc.rust-lang.org/std/os/unix/net/struct.UnixDatagram.html#method.send_vectored_with_ancillary // but this is (at the time of writing) unstable. #[repr(C)] union SingleRightAnciliaryData { // SAFETY: Not actually unsafe #[allow(clippy::undocumented_unsafe_blocks)] // Clippy doesn't understand the safety comment buf: [u8; unsafe { CMSG_SPACE(size_of::() as u32) as usize }], _align: cmsghdr, } /// Receives a raw file descriptor from the provided UnixStream fn receive_fd(rx_fd: UnixStream) -> RawFd { let mut data = [0u8; 1]; let mut iov = iovec { iov_base: &mut data as *mut [u8; 1] as *mut c_void, iov_len: 1, }; // SAFETY: msghdr can be zero-initialized let mut msg: msghdr = unsafe { zeroed() }; msg.msg_name = ptr::null_mut(); msg.msg_namelen = 0; msg.msg_iov = &mut iov; msg.msg_iovlen = 1; // SAFETY: SingleRightAnciliaryData can be zero-initialized. let mut control: SingleRightAnciliaryData = unsafe { zeroed() }; // SAFETY: The buf field is valid when zero-initialized. msg.msg_controllen = unsafe { control.buf.len() as _ }; msg.msg_control = &mut control as *mut _ as *mut libc::c_void; // SAFETY: A valid socket fd and a valid initialized msghdr are passed in. if unsafe { recvmsg(rx_fd.as_raw_fd(), &mut msg, 0) } == -1 { panic!("failed to recvmsg: {}", io::Error::last_os_error()); } if msg.msg_flags & MSG_TRUNC == MSG_TRUNC { unreachable!("unexpected internal error in seccomp filter"); } // SAFETY: The kernel correctly initializes everything on recvmsg for this to be safe. unsafe { let cmsgp = CMSG_FIRSTHDR(&msg); if cmsgp.is_null() || (*cmsgp).cmsg_len != CMSG_LEN(size_of::() as u32) as _ || (*cmsgp).cmsg_level != SOL_SOCKET || (*cmsgp).cmsg_type != SCM_RIGHTS { unreachable!("unexpected response from Linux kernel"); } CMSG_DATA(cmsgp).cast::().read() } } fn send_fd(tx_fd: UnixStream, notify_fd: RawFd) -> io::Result<()> { let mut data = [0u8; 1]; let mut iov = iovec { iov_base: &mut data as *mut [u8; 1] as *mut c_void, iov_len: 1, }; // SAFETY: msghdr can be zero-initialized let mut msg: msghdr = unsafe { zeroed() }; msg.msg_name = ptr::null_mut(); msg.msg_namelen = 0; msg.msg_iov = &mut iov; msg.msg_iovlen = 1; // SAFETY: SingleRightAnciliaryData can be zero-initialized. let mut control: SingleRightAnciliaryData = unsafe { zeroed() }; // SAFETY: The buf field is valid when zero-initialized. msg.msg_controllen = unsafe { control.buf.len() as _ }; msg.msg_control = &mut control as *mut _ as *mut _; // SAFETY: msg.msg_control is correctly initialized and this follows // the contract of the various CMSG_* macros. unsafe { let cmsgp = CMSG_FIRSTHDR(&msg); (*cmsgp).cmsg_level = SOL_SOCKET; (*cmsgp).cmsg_type = SCM_RIGHTS; (*cmsgp).cmsg_len = CMSG_LEN(size_of::() as u32) as _; ptr::write(CMSG_DATA(cmsgp).cast::(), notify_fd); } // SAFETY: A valid socket fd and a valid initialized msghdr are passed in. if unsafe { sendmsg(tx_fd.as_raw_fd(), &msg, 0) } == -1 { Err(io::Error::last_os_error()) } else { Ok(()) } } pub(crate) struct SpawnNoexecHandler(UnixStream); impl SpawnNoexecHandler { pub(super) fn spawn(self) { thread::spawn(move || { let notify_fd = receive_fd(self.0); // SAFETY: notify_fd is a valid seccomp_unotify fd. unsafe { handle_notifications(OwnedFd::from_raw_fd(notify_fd)) }; }); } } // BPF filtering is only supported (according to man seccomp) on the following architectures // that are realistic on Linux. const HOST_ARCH: u32 = if cfg!(target_arch = "aarch64") { AUDIT_ARCH_AARCH64 } else if cfg!(target_arch = "arm") { AUDIT_ARCH_ARM } else if cfg!(target_arch = "mips") { if cfg!(target_endian = "little") { AUDIT_ARCH_MIPSEL } else { AUDIT_ARCH_MIPS } } else if cfg!(target_arch = "mips64") { if cfg!(target_endian = "little") { AUDIT_ARCH_MIPSEL64 } else { AUDIT_ARCH_MIPS64 } } else if cfg!(target_arch = "powerpc") { AUDIT_ARCH_PPC } else if cfg!(target_arch = "powerpc64") { if cfg!(target_endian = "little") { AUDIT_ARCH_PPC64LE } else { AUDIT_ARCH_PPC64 } } else if cfg!(target_arch = "riscv32") { AUDIT_ARCH_RISCV32 } else if cfg!(target_arch = "riscv64") { AUDIT_ARCH_RISCV64 } else if cfg!(target_arch = "s390x") { AUDIT_ARCH_S390X } else if cfg!(target_arch = "x86") { AUDIT_ARCH_I386 } else if cfg!(target_arch = "x86_64") { AUDIT_ARCH_X86_64 } else { 0 // this will filter out all syscalls }; // For x86-64 and aarch64 systems, it's possible to encounter them // running in multi-arch mode. const GUEST_ARCH: u32 = if cfg!(target_arch = "aarch64") { AUDIT_ARCH_ARM } else if cfg!(target_arch = "riscv64") { AUDIT_ARCH_RISCV32 } else if cfg!(target_arch = "x86_64") { AUDIT_ARCH_I386 } else { HOST_ARCH }; /// syscall numbers for the guest architecture according to the Linux syscall table const SYS_execve_x86: i64 = 11; const SYS_execve_arm: i64 = 11; const SYS_execve_x32: i64 = 520; const SYS_execve_rv32: i64 = 221; const SYS_execveat_x86: i64 = 358; const SYS_execveat_arm: i64 = 387; const SYS_execveat_x32: i64 = 545; const SYS_execveat_rv32: i64 = 281; const GUEST_SYSCALL: (i64, i64) = if cfg!(target_arch = "aarch64") { (SYS_execve_arm, SYS_execveat_arm) } else if cfg!(target_arch = "riscv64") { (SYS_execve_rv32, SYS_execveat_rv32) } else if cfg!(target_arch = "x86_64") { (SYS_execve_x86, SYS_execveat_x86) } else { (SYS_execve as _, SYS_execveat as _) // fallback }; // Bit that is set on syscalls when using the X32 ABI; see man seccomp. const __X32_SYSCALL_BIT: u32 = 0x40000000; pub(crate) fn add_noexec_filter(command: &mut Command) -> SpawnNoexecHandler { let (tx_fd, rx_fd) = UnixStream::pair().unwrap(); // wrap tx_fd so it can be moved into the closure let mut tx_fd = Some(tx_fd); // SAFETY: See individual SAFETY comments unsafe { // SAFETY: The closure only calls async-signal-safe functions. command.pre_exec(move || { let tx_fd = tx_fd.take().unwrap(); // FIXME replace with offset_of!(seccomp_data, nr) once MSRV is bumped to 1.77 // SAFETY: seccomp_data can be safely zero-initialized. let dummy: seccomp_data = zeroed(); let nr_offset = (&dummy.nr) as *const _ as usize - &dummy as *const _ as usize; let arch_offset = (&dummy.arch) as *const _ as usize - &dummy as *const _ as usize; // SAFETY: libc unnecessarily marks these functions as unsafe #[rustfmt::skip] let exec_filter = [ // Load architecture number into the accumulator BPF_STMT((BPF_LD | BPF_ABS) as _, arch_offset as _), // Check if we are any of the recognized architectures BPF_JUMP((BPF_JMP | BPF_JEQ | BPF_K) as _, HOST_ARCH as _, 7, 0), BPF_JUMP((BPF_JMP | BPF_JEQ | BPF_K) as _, GUEST_ARCH as _, 1, 0), // Not a recognized architecture, forbid all syscalls BPF_STMT((BPF_RET | BPF_K) as _, SECCOMP_RET_KILL_PROCESS as _), // Guest architecture section // Load syscall number into the accumulator BPF_STMT((BPF_LD | BPF_W | BPF_ABS) as _, nr_offset as _), // Jump to user notify for execve/execveat BPF_JUMP((BPF_JMP | BPF_JEQ | BPF_K) as _, GUEST_SYSCALL.0 as _, 2, 0), BPF_JUMP((BPF_JMP | BPF_JEQ | BPF_K) as _, GUEST_SYSCALL.1 as _, 1, 0), // Allow non-matching syscalls BPF_STMT((BPF_RET | BPF_K) as _, SECCOMP_RET_ALLOW), // Notify sudo about execve/execveat syscall BPF_STMT((BPF_RET | BPF_K) as _, SECCOMP_RET_USER_NOTIF as _), // Host architecture section // Load syscall number into the accumulator BPF_STMT((BPF_LD | BPF_W | BPF_ABS) as _, nr_offset as _), // Unset the X32_SYSCALL bit (only necessary on x86_64) #[cfg(target_arch = "x86_64")] BPF_STMT((BPF_ALU | BPF_AND | BPF_K) as _, !__X32_SYSCALL_BIT), // On x86-64 only: check the x32 "design error" syscall numbers #[cfg(target_arch = "x86_64")] BPF_JUMP((BPF_JMP | BPF_JEQ | BPF_K) as _, SYS_execve_x32 as _, 4, 0), #[cfg(target_arch = "x86_64")] BPF_JUMP((BPF_JMP | BPF_JEQ | BPF_K) as _, SYS_execveat_x32 as _, 3, 0), // Jump to user notify for execve/execveat BPF_JUMP((BPF_JMP | BPF_JEQ | BPF_K) as _, SYS_execve as _, 2, 0), BPF_JUMP((BPF_JMP | BPF_JEQ | BPF_K) as _, SYS_execveat as _, 1, 0), // Allow non-matching syscalls BPF_STMT((BPF_RET | BPF_K) as _, SECCOMP_RET_ALLOW), // Notify sudo about execve/execveat syscall BPF_STMT((BPF_RET | BPF_K) as _, SECCOMP_RET_USER_NOTIF as _), ]; // this is used since we can't yet use "let exec_filter: [sock_filter; _] above" const fn check_type(_arr: &[sock_filter; N]) {} check_type(&exec_filter); let exec_fprog = sock_fprog { len: exec_filter.len() as u16, filter: addr_of!(exec_filter) as *mut sock_filter, }; // SAFETY: Trivially safe as it doesn't touch any memory. // SECCOMP_SET_MODE_FILTER will fail unless the process has // CAP_SYS_ADMIN or the no_new_privs bit is set. if prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1 { return Err(io::Error::last_os_error()); } // While the man page warns against using seccomp_unotify as security // mechanism, the TOCTOU problem that is described there isn't // relevant here. We only SECCOMP_USER_NOTIF_FLAG_CONTINUE the first // execve which is done by ourself and thus trusted. // SAFETY: Passes a valid sock_fprog as argument. let notify_fd = seccomp( SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER as _, addr_of!(exec_fprog).cast_mut(), ); if notify_fd < 0 { return Err(io::Error::last_os_error()); } send_fd(tx_fd, notify_fd)?; // SAFETY: Nothing will access the notify_fd after this call. close(notify_fd); Ok(()) }); } SpawnNoexecHandler(rx_fd) } sudo-rs-0.2.10/src/exec/use_pty/backchannel.rs000064400000000000000000000221301046102023000173000ustar 00000000000000use std::{ ffi::c_int, io, mem::size_of, os::fd::{AsFd, BorrowedFd}, }; use crate::{ common::bin_serde::{BinPipe, DeSerialize}, exec::signal_fmt, system::interface::ProcessId, }; use super::CommandStatus; type Prefix = u8; type ParentData = c_int; type MonitorData = c_int; const PREFIX_LEN: usize = size_of::(); const PARENT_DATA_LEN: usize = size_of::(); const MONITOR_DATA_LEN: usize = size_of::(); pub(super) struct BackchannelPair { pub(super) parent: ParentBackchannel, pub(super) monitor: MonitorBackchannel, } impl BackchannelPair { pub(super) fn new() -> io::Result { let (sock1, sock2) = BinPipe::pair()?; #[cfg(debug_assertions)] { sock1.set_nonblocking(true)?; sock2.set_nonblocking(true)?; } Ok(Self { parent: ParentBackchannel { socket: sock1, #[cfg(debug_assertions)] nonblocking_asserts: false, }, monitor: MonitorBackchannel { socket: sock2, #[cfg(debug_assertions)] nonblocking_asserts: false, }, }) } } pub(super) enum ParentMessage { IoError(c_int), CommandStatus(CommandStatus), CommandPid(ProcessId), ShortRead, } impl ParentMessage { const LEN: usize = PREFIX_LEN + PARENT_DATA_LEN; const IO_ERROR: Prefix = 0; const CMD_STAT_EXIT: Prefix = 1; const CMD_STAT_TERM: Prefix = 2; const CMD_STAT_STOP: Prefix = 3; const CMD_PID: Prefix = 4; const SHORT_READ: Prefix = 5; fn from_parts(prefix: Prefix, data: ParentData) -> Self { match prefix { Self::IO_ERROR => Self::IoError(data), Self::CMD_STAT_EXIT => Self::CommandStatus(CommandStatus::Exit(data)), Self::CMD_STAT_TERM => Self::CommandStatus(CommandStatus::Term(data)), Self::CMD_STAT_STOP => Self::CommandStatus(CommandStatus::Stop(data)), Self::CMD_PID => Self::CommandPid(ProcessId::new(data)), Self::SHORT_READ => Self::ShortRead, _ => unreachable!(), } } fn to_parts(&self) -> (Prefix, ParentData) { let prefix = match self { ParentMessage::IoError(_) => Self::IO_ERROR, ParentMessage::CommandStatus(CommandStatus::Exit(_)) => Self::CMD_STAT_EXIT, ParentMessage::CommandStatus(CommandStatus::Term(_)) => Self::CMD_STAT_TERM, ParentMessage::CommandStatus(CommandStatus::Stop(_)) => Self::CMD_STAT_STOP, ParentMessage::CommandPid(_) => Self::CMD_PID, ParentMessage::ShortRead => Self::SHORT_READ, }; let data = match self { ParentMessage::IoError(data) => *data, ParentMessage::CommandPid(data) => data.inner(), ParentMessage::CommandStatus(status) => match status { CommandStatus::Exit(data) | CommandStatus::Term(data) | CommandStatus::Stop(data) => *data, }, ParentMessage::ShortRead => 0, }; (prefix, data) } } impl TryFrom for ParentMessage { type Error = io::Error; fn try_from(err: io::Error) -> Result { err.raw_os_error() .map(Self::IoError) .or_else(|| (err.kind() == io::ErrorKind::UnexpectedEof).then_some(Self::ShortRead)) .ok_or(err) } } impl From for ParentMessage { fn from(status: CommandStatus) -> Self { Self::CommandStatus(status) } } impl DeSerialize for ParentMessage { type Bytes = [u8; ParentMessage::LEN]; fn serialize(&self) -> Self::Bytes { let mut buf = [0; ParentMessage::LEN]; let (prefix_buf, data_buf) = buf.split_at_mut(PREFIX_LEN); let (prefix, data) = self.to_parts(); prefix_buf.copy_from_slice(&prefix.to_ne_bytes()); data_buf.copy_from_slice(&data.to_ne_bytes()); buf } fn deserialize(buf: Self::Bytes) -> Self { let (prefix_buf, data_buf) = buf.split_at(PREFIX_LEN); let prefix = Prefix::from_ne_bytes(prefix_buf.try_into().unwrap()); let data = MonitorData::from_ne_bytes(data_buf.try_into().unwrap()); ParentMessage::from_parts(prefix, data) } } /// A socket use for communication between the monitor and the parent process. pub(super) struct ParentBackchannel { socket: BinPipe, #[cfg(debug_assertions)] nonblocking_asserts: bool, } impl ParentBackchannel { /// Send a [`MonitorMessage`]. /// /// Calling this method will block until the socket is ready for writing. #[track_caller] pub(super) fn send(&mut self, event: &MonitorMessage) -> io::Result<()> { self.socket.write(event).map_err(|err| { #[cfg(debug_assertions)] if self.nonblocking_asserts { assert_ne!(err.kind(), io::ErrorKind::WouldBlock); } err }) } /// Receive a [`ParentMessage`]. /// /// Calling this method will block until the socket is ready for reading. #[track_caller] pub(super) fn recv(&mut self) -> io::Result { let msg = self.socket.read().map_err(|err| { #[cfg(debug_assertions)] if self.nonblocking_asserts { assert_ne!(err.kind(), io::ErrorKind::WouldBlock); } err })?; Ok(msg) } pub(super) fn set_nonblocking_asserts(&mut self, _doit: bool) { #[cfg(debug_assertions)] { self.nonblocking_asserts = _doit; } } } impl AsFd for ParentBackchannel { fn as_fd(&self) -> BorrowedFd<'_> { self.socket.as_fd() } } /// Different messages exchanged between the monitor and the parent process using a [`ParentBackchannel`]. #[derive(PartialEq, Eq)] pub(super) enum MonitorMessage { Edge, Signal(c_int), } impl MonitorMessage { const LEN: usize = PREFIX_LEN + MONITOR_DATA_LEN; const EDGE_CMD: Prefix = 0; const SIGNAL: Prefix = 1; fn from_parts(prefix: Prefix, data: MonitorData) -> Self { match prefix { Self::EDGE_CMD => Self::Edge, Self::SIGNAL => Self::Signal(data), _ => unreachable!(), } } fn to_parts(&self) -> (Prefix, MonitorData) { let prefix = match self { MonitorMessage::Edge => Self::EDGE_CMD, MonitorMessage::Signal(_) => Self::SIGNAL, }; let data = match self { MonitorMessage::Edge => 0, MonitorMessage::Signal(data) => *data, }; (prefix, data) } } impl std::fmt::Debug for MonitorMessage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Edge => "Edge".fmt(f), &Self::Signal(signal) => write!(f, "Signal({})", signal_fmt(signal)), } } } impl DeSerialize for MonitorMessage { type Bytes = [u8; MonitorMessage::LEN]; fn serialize(&self) -> Self::Bytes { let mut buf = [0; MonitorMessage::LEN]; let (prefix_buf, data_buf) = buf.split_at_mut(PREFIX_LEN); let (prefix, data) = self.to_parts(); prefix_buf.copy_from_slice(&prefix.to_ne_bytes()); data_buf.copy_from_slice(&data.to_ne_bytes()); buf } fn deserialize(bytes: Self::Bytes) -> Self { let (prefix_buf, data_buf) = bytes.split_at(PREFIX_LEN); let prefix = Prefix::from_ne_bytes(prefix_buf.try_into().unwrap()); let data = MonitorData::from_ne_bytes(data_buf.try_into().unwrap()); MonitorMessage::from_parts(prefix, data) } } /// A socket use for communication between the monitor and the parent process. pub(super) struct MonitorBackchannel { socket: BinPipe, #[cfg(debug_assertions)] nonblocking_asserts: bool, } impl MonitorBackchannel { /// Send a [`ParentMessage`]. /// /// Calling this method will block until the socket is ready for writing. #[track_caller] pub(super) fn send(&mut self, event: &ParentMessage) -> io::Result<()> { self.socket.write(event).map_err(|err| { #[cfg(debug_assertions)] if self.nonblocking_asserts { assert_ne!(err.kind(), io::ErrorKind::WouldBlock); } err }) } /// Receive a [`MonitorMessage`]. /// /// Calling this method will block until the socket is ready for reading. #[track_caller] pub(super) fn recv(&mut self) -> io::Result { let msg = self.socket.read().map_err(|err| { #[cfg(debug_assertions)] if self.nonblocking_asserts { assert_ne!(err.kind(), io::ErrorKind::WouldBlock); } err })?; Ok(msg) } pub(super) fn set_nonblocking_assertions(&mut self, _doit: bool) { #[cfg(debug_assertions)] { self.nonblocking_asserts = _doit; } } } impl AsFd for MonitorBackchannel { fn as_fd(&self) -> BorrowedFd<'_> { self.socket.as_fd() } } sudo-rs-0.2.10/src/exec/use_pty/mod.rs000064400000000000000000000006361046102023000156350ustar 00000000000000mod backchannel; mod monitor; mod parent; mod pipe; use std::ffi::c_int; pub(super) use parent::exec_pty; use crate::system::signal::SignalNumber; /// Continue running in the foreground pub(super) const SIGCONT_FG: SignalNumber = -2; /// Continue running in the background pub(super) const SIGCONT_BG: SignalNumber = -3; enum CommandStatus { Exit(c_int), Term(SignalNumber), Stop(SignalNumber), } sudo-rs-0.2.10/src/exec/use_pty/monitor.rs000064400000000000000000000362531046102023000165510ustar 00000000000000use std::{convert::Infallible, ffi::c_int, io, process::Command}; use crate::exec::{opt_fmt, signal_fmt}; use crate::system::signal::{ consts::*, register_handlers, SignalHandler, SignalHandlerBehavior, SignalNumber, SignalSet, SignalStream, }; use crate::{ common::bin_serde::BinPipe, exec::{ event::{EventRegistry, Process}, exec_command, io_util::{retry_while_interrupted, was_interrupted}, use_pty::backchannel::{MonitorBackchannel, MonitorMessage, ParentMessage}, }, }; use crate::{ exec::{ event::{PollEvent, StopReason}, use_pty::{SIGCONT_BG, SIGCONT_FG}, }, log::{dev_error, dev_info, dev_warn}, }; use crate::{ exec::{handle_sigchld, terminate_process, HandleSigchld}, system::{ _exit, fork, getpgid, getpgrp, interface::ProcessId, kill, setpgid, setsid, term::{PtyFollower, Terminal}, wait::{Wait, WaitError, WaitOptions}, ForkResult, }, }; use super::CommandStatus; pub(super) fn exec_monitor( pty_follower: PtyFollower, command: Command, foreground: bool, backchannel: &mut MonitorBackchannel, original_set: Option, ) -> io::Result { // SIGTTIN and SIGTTOU are ignored here but the docs state that it shouldn't // be possible to receive them in the first place. Investigate match SignalHandler::register(SIGTTIN, SignalHandlerBehavior::Ignore) { Ok(handler) => handler.forget(), Err(err) => dev_warn!("cannot set handler for SIGTTIN: {err}"), } match SignalHandler::register(SIGTTOU, SignalHandlerBehavior::Ignore) { Ok(handler) => handler.forget(), Err(err) => dev_warn!("cannot set handler for SIGTTOU: {err}"), } // Start a new terminal session with the monitor as the leader. setsid().map_err(|err| { dev_warn!("cannot start a new session: {err}"); err })?; // Set the follower side of the pty as the controlling terminal for the session. pty_follower.make_controlling_terminal().map_err(|err| { dev_warn!("cannot set the controlling terminal: {err}"); err })?; // Use a pipe to get the IO error if `exec_command` fails. let (errpipe_tx, errpipe_rx) = BinPipe::pair()?; // Wait for the parent to give us green light before spawning the command. This avoids race // conditions when the command exits quickly. let event = retry_while_interrupted(|| backchannel.recv()).map_err(|err| { dev_warn!("cannot receive green light from parent: {err}"); err })?; // Given that `UnixStream` delivers messages in order it shouldn't be possible to // receive an event different to `Edge` at the beginning. debug_assert_eq!(event, MonitorMessage::Edge); // FIXME (ogsudo): Some extra config happens here if selinux is available. // SAFETY: There should be no other threads at this point. let ForkResult::Parent(command_pid) = unsafe { fork() }.map_err(|err| { dev_warn!("unable to fork command process: {err}"); err })? else { drop(errpipe_rx); // FIXME (ogsudo): Do any additional configuration that needs to be run after `fork` but before `exec` let command_pid = ProcessId::new(std::process::id() as i32); setpgid(ProcessId::new(0), command_pid).ok(); // Wait for the monitor to set us as the foreground group for the pty if we are in the // foreground. if foreground { while !pty_follower.tcgetpgrp().is_ok_and(|pid| pid == command_pid) { std::thread::yield_now(); } } // Done with the pty follower. drop(pty_follower); exec_command(command, original_set, errpipe_tx) }; // Send the command's PID to the parent. if let Err(err) = backchannel.send(&ParentMessage::CommandPid(command_pid)) { dev_warn!("cannot send command PID to parent: {err}"); } let mut registry = EventRegistry::new(); let mut closure = MonitorClosure::new( command_pid, pty_follower, errpipe_rx, backchannel, &mut registry, )?; // Restore the signal mask now that the handlers have been setup. if let Some(set) = original_set { if let Err(err) = set.set_mask() { dev_warn!("cannot restore signal mask: {err}"); } } // Set the foreground group for the pty follower. if foreground { if let Err(err) = closure.pty_follower.tcsetpgrp(closure.command_pgrp) { dev_error!( "cannot set foreground progess group to {} (command): {err}", closure.command_pgrp ); } } // FIXME (ogsudo): Here's where the signal mask is removed because the handlers for the signals // have been setup after initializing the closure. // Start the event loop. let reason = registry.event_loop(&mut closure); // Terminate the command if it's not terminated. if let Some(command_pid) = closure.command_pid { terminate_process(command_pid, true); loop { match command_pid.wait(WaitOptions::new()) { Err(WaitError::Io(err)) if was_interrupted(&err) => {} _ => break, } } } // Take the controlling tty so the command's children don't receive SIGHUP when we exit. if let Err(err) = closure.pty_follower.tcsetpgrp(closure.monitor_pgrp) { dev_error!( "cannot set foreground process group to {} (monitor): {err}", closure.monitor_pgrp ); } // Disable nonblocking assetions as we will not poll the backchannel anymore. closure.backchannel.set_nonblocking_assertions(false); match reason { StopReason::Break(err) => match err.try_into() { Ok(msg) => { if let Err(err) = closure.backchannel.send(&msg) { dev_warn!("cannot send message over backchannel: {err}") } } Err(err) => { dev_warn!("socket error `{err:?}` cannot be converted to a message") } }, StopReason::Exit(command_status) => { if let Err(err) = closure.backchannel.send(&command_status.into()) { dev_warn!("cannot send message over backchannel: {err}") } } } // Wait for the parent to give us red light before shutting down. This avoids missing // output when the monitor exits too quickly. let event = retry_while_interrupted(|| backchannel.recv()).map_err(|err| { dev_warn!("cannot receive red light from parent: {err}"); err })?; debug_assert_eq!(event, MonitorMessage::Edge); // FIXME (ogsudo): The tty is restored here if selinux is available. // We call `_exit` instead of `exit` to avoid flushing the parent's IO streams by accident. _exit(1); } struct MonitorClosure<'a> { /// The command PID. /// /// This is `Some` iff the process is still running. command_pid: Option, command_pgrp: ProcessId, monitor_pgrp: ProcessId, pty_follower: PtyFollower, errpipe_rx: BinPipe, backchannel: &'a mut MonitorBackchannel, signal_stream: &'static SignalStream, _signal_handlers: [SignalHandler; MonitorClosure::SIGNALS.len()], } impl<'a> MonitorClosure<'a> { const SIGNALS: [SignalNumber; 8] = [ SIGINT, SIGQUIT, SIGTSTP, SIGTERM, SIGHUP, SIGUSR1, SIGUSR2, SIGCHLD, ]; fn new( command_pid: ProcessId, pty_follower: PtyFollower, errpipe_rx: BinPipe, backchannel: &'a mut MonitorBackchannel, registry: &mut EventRegistry, ) -> io::Result { // Store the pgid of the monitor. let monitor_pgrp = getpgrp(); // Register the callback to receive the IO error if the command fails to execute. registry.register_event(&errpipe_rx, PollEvent::Readable, |_| { MonitorEvent::ReadableErrPipe }); // Enable nonblocking assertions as we will poll this inside the event loop. backchannel.set_nonblocking_assertions(true); // Register the callback to receive events from the backchannel registry.register_event(backchannel, PollEvent::Readable, |_| { MonitorEvent::ReadableBackchannel }); let signal_stream = SignalStream::init()?; registry.register_event(signal_stream, PollEvent::Readable, |_| MonitorEvent::Signal); let signal_handlers = register_handlers(Self::SIGNALS)?; // Put the command in its own process group. let command_pgrp = command_pid; if let Err(err) = setpgid(command_pid, command_pgrp) { dev_warn!("cannot set process group ID for process: {err}"); }; Ok(Self { command_pid: Some(command_pid), command_pgrp, monitor_pgrp, pty_follower, errpipe_rx, backchannel, signal_stream, _signal_handlers: signal_handlers, }) } /// Based on `mon_backchannel_cb` fn read_backchannel(&mut self, registry: &mut EventRegistry) { match self.backchannel.recv() { Err(err) => { // We can try later if receive is interrupted. if err.kind() != io::ErrorKind::Interrupted { // There's something wrong with the backchannel, break the event loop. dev_warn!("cannot read from backchannel: {err}"); registry.set_break(err); } } Ok(event) => { match event { // We shouldn't receive this event at this point in the protocol MonitorMessage::Edge => unreachable!(), // Forward signal to the command. MonitorMessage::Signal(signal) => { if let Some(command_pid) = self.command_pid { self.send_signal(signal, command_pid, true) } } } } } } fn read_errpipe(&mut self, registry: &mut EventRegistry) { match self.errpipe_rx.read() { Err(err) if was_interrupted(&err) => { /* Retry later */ } Err(err) => registry.set_break(err), Ok(error_code) => { // Received error code from the command, forward it to the parent. registry.set_break(io::Error::from_raw_os_error(error_code)); } } } /// Send a signal to the command. fn send_signal(&self, signal: c_int, command_pid: ProcessId, from_parent: bool) { dev_info!( "sending {}{} to command", signal_fmt(signal), opt_fmt(from_parent, " from parent"), ); // FIXME: We should call `killpg` instead of `kill`. match signal { SIGALRM => { terminate_process(command_pid, false); } SIGCONT_FG => { // Continue with the command as the foreground process group if let Err(err) = self.pty_follower.tcsetpgrp(self.command_pgrp) { dev_error!( "cannot set the foreground process group to {} (command): {err}", self.command_pgrp ); } kill(command_pid, SIGCONT).ok(); } SIGCONT_BG => { // Continue with the monitor as the foreground process group if let Err(err) = self.pty_follower.tcsetpgrp(self.monitor_pgrp) { dev_error!( "cannot set the foreground process group to {} (monitor): {err}", self.monitor_pgrp ); } kill(command_pid, SIGCONT).ok(); } signal => { // Send the signal to the command. kill(command_pid, signal).ok(); } } } fn on_signal(&mut self, registry: &mut EventRegistry) { let info = match self.signal_stream.recv() { Ok(info) => info, Err(err) => { dev_error!("could not receive signal: {err}"); return; } }; dev_info!("monitor received{}", info); // Don't do anything if the command has terminated already let Some(command_pid) = self.command_pid else { dev_info!("command was terminated, ignoring signal"); return; }; match info.signal() { SIGCHLD => handle_sigchld(self, registry, "command", command_pid), signal => { if let Some(pid) = info.signaler_pid() { if is_self_terminating(pid, command_pid, self.command_pgrp) { // Skip the signal if it was sent by the user and it is self-terminating. return; } } self.send_signal(signal, command_pid, false) } } } } /// Decides if the signal sent by the process with `signaler_pid` PID is self-terminating. /// /// A signal is self-terminating if `signaler_pid`: /// - is the same PID of the command, or /// - is in the process group of the command and the command is the leader. fn is_self_terminating( signaler_pid: ProcessId, command_pid: ProcessId, command_pgrp: ProcessId, ) -> bool { if signaler_pid.is_valid() { if signaler_pid == command_pid { return true; } if let Ok(grp_leader) = getpgid(signaler_pid) { if grp_leader == command_pgrp { return true; } } } false } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MonitorEvent { Signal, ReadableErrPipe, ReadableBackchannel, } impl Process for MonitorClosure<'_> { type Event = MonitorEvent; type Break = io::Error; type Exit = CommandStatus; fn on_event(&mut self, event: Self::Event, registry: &mut EventRegistry) { match event { MonitorEvent::Signal => self.on_signal(registry), MonitorEvent::ReadableErrPipe => self.read_errpipe(registry), MonitorEvent::ReadableBackchannel => self.read_backchannel(registry), } } } impl HandleSigchld for MonitorClosure<'_> { const OPTIONS: WaitOptions = WaitOptions::new().untraced().no_hang(); fn on_exit(&mut self, exit_code: c_int, registry: &mut EventRegistry) { registry.set_exit(CommandStatus::Exit(exit_code)); self.command_pid = None; } fn on_term(&mut self, signal: c_int, registry: &mut EventRegistry) { registry.set_exit(CommandStatus::Term(signal)); self.command_pid = None; } fn on_stop(&mut self, signal: c_int, _registry: &mut EventRegistry) { // Save the foreground process group ID so we can restore it later. if let Ok(pgrp) = self.pty_follower.tcgetpgrp() { if pgrp != self.monitor_pgrp { self.command_pgrp = pgrp; } } self.backchannel .send(&CommandStatus::Stop(signal).into()) .ok(); } } sudo-rs-0.2.10/src/exec/use_pty/parent.rs000064400000000000000000000652761046102023000163620ustar 00000000000000use std::collections::VecDeque; use std::ffi::c_int; use std::io; use std::process::{Command, Stdio}; use crate::exec::event::{EventHandle, EventRegistry, PollEvent, Process, StopReason}; use crate::exec::use_pty::monitor::exec_monitor; use crate::exec::use_pty::SIGCONT_FG; use crate::exec::{cond_fmt, handle_sigchld, signal_fmt, terminate_process, HandleSigchld}; use crate::exec::{ io_util::retry_while_interrupted, use_pty::backchannel::{BackchannelPair, MonitorMessage, ParentBackchannel, ParentMessage}, ExitReason, SpawnNoexecHandler, }; use crate::log::{dev_error, dev_info, dev_warn}; use crate::system::signal::{ consts::*, register_handlers, SignalHandler, SignalHandlerBehavior, SignalNumber, SignalSet, SignalStream, }; use crate::system::term::{Pty, PtyFollower, PtyLeader, TermSize, Terminal, UserTerm}; use crate::system::wait::WaitOptions; use crate::system::{chown, fork, getpgrp, kill, killpg, ForkResult, Group, User, _exit}; use crate::system::{getpgid, interface::ProcessId}; use super::pipe::Pipe; use super::{CommandStatus, SIGCONT_BG}; pub(in crate::exec) fn exec_pty( sudo_pid: ProcessId, spawn_noexec_handler: Option, mut command: Command, user_tty: UserTerm, ) -> io::Result { // Allocate a pseudoterminal. let pty = get_pty()?; // Create backchannels to communicate with the monitor. let mut backchannels = BackchannelPair::new().map_err(|err| { dev_error!("cannot create backchannel: {err}"); err })?; // We don't want to receive SIGTTIN/SIGTTOU match SignalHandler::register(SIGTTIN, SignalHandlerBehavior::Ignore) { Ok(handler) => handler.forget(), Err(err) => dev_warn!("cannot set handler for SIGTTIN: {err}"), } match SignalHandler::register(SIGTTOU, SignalHandlerBehavior::Ignore) { Ok(handler) => handler.forget(), Err(err) => dev_warn!("cannot set handler for SIGTTOU: {err}"), } // FIXME (ogsudo): Initialize the policy plugin's session here by calling // `policy_init_session`. // FIXME (ogsudo): initializes ttyblock sigset here by calling `init_ttyblock` // Fetch the parent process group so we can signals to it. let parent_pgrp = getpgrp(); // Set all the IO streams for the command to the follower side of the pty. let clone_follower = || -> io::Result { pty.follower.try_clone().map_err(|err| { dev_error!("cannot clone pty follower: {err}"); err }) }; command.stdin(clone_follower()?); command.stdout(clone_follower()?); command.stderr(clone_follower()?); let mut registry = EventRegistry::::new(); // Pipe data between both terminals let mut tty_pipe = Pipe::new( user_tty, pty.leader, &mut registry, ParentEvent::Tty, ParentEvent::Pty, ); let user_tty = tty_pipe.left_mut(); // Check if we are the foreground process let mut foreground = user_tty .tcgetpgrp() .is_ok_and(|tty_pgrp| tty_pgrp == parent_pgrp); dev_info!( "sudo is running in the {}", cond_fmt(foreground, "foreground", "background") ); // FIXME: maybe all these boolean flags should be on a dedicated type. // Whether we're running on a pipeline let mut pipeline = false; // Whether the command should be executed in the background (this is not the `-b` flag) let mut exec_bg = false; // Whether the user's terminal is in raw mode or not. let mut term_raw = false; // Check if we are part of a pipeline. // FIXME: Here's where we should intercept the IO streams if we want to implement IO logging. // FIXME: ogsudo creates pipes for the IO streams and uses events to read from the strams to // the pipes. Investigate why. if !io::stdin().is_terminal() { dev_info!("stdin is not a terminal, command will inherit it"); pipeline = true; command.stdin(Stdio::inherit()); if foreground && parent_pgrp != sudo_pid { // If sudo is not the process group leader and stdin is not a terminal we might be // running as a background job via a shell script. Starting in the foreground would // change the terminal mode. exec_bg = true; } } if !io::stdout().is_terminal() { dev_info!("stdout is not a terminal, command will inherit it"); pipeline = true; command.stdout(Stdio::inherit()); } if !io::stderr().is_terminal() { dev_info!("stderr is not a terminal, command will inherit it"); command.stderr(Stdio::inherit()); } // If there is another process later in the pipeline, don't interfere // with its access to the Tty if io::stdout().is_pipe() { foreground = false; } // Copy terminal settings from `/dev/tty` to the pty. if let Err(err) = user_tty.copy_to(&pty.follower) { dev_error!("cannot copy terminal settings to pty: {err}"); foreground = false; } // Start in raw mode unless we're part of a pipeline or backgrounded. if foreground && !pipeline && !exec_bg { // Clearer this way that set_raw_mode only conditionally runs #[allow(clippy::collapsible_if)] if user_tty.set_raw_mode(false).is_ok() { term_raw = true; } } let tty_size = tty_pipe.left().get_size().map_err(|err| { dev_error!("cannot get terminal size: {err}"); err })?; // Block all the signals until we are done setting up the signal handlers so we don't miss // SIGCHLD. let original_set = match SignalSet::full().and_then(|set| set.block()) { Ok(original_set) => Some(original_set), Err(err) => { dev_warn!("cannot block signals: {err}"); None } }; if !foreground { tty_pipe.disable_input(&mut registry); } // SAFETY: There should be no other threads at this point. let ForkResult::Parent(monitor_pid) = (unsafe { fork() }).map_err(|err| { dev_error!("cannot fork monitor process: {err}"); err })? else { // Close the file descriptors that we don't access drop(tty_pipe); drop(backchannels.parent); // If `exec_monitor` returns, it means we failed to execute the command somehow. match exec_monitor( pty.follower, command, foreground && !pipeline && !exec_bg, &mut backchannels.monitor, original_set, ) { Ok(exec_output) => match exec_output {}, Err(err) => { // Disable nonblocking assertions as we will not poll the backchannel anymore. backchannels.monitor.set_nonblocking_assertions(true); match err.try_into() { Ok(msg) => { if let Err(err) = backchannels.monitor.send(&msg) { dev_error!("cannot send status to parent: {err}"); } } Err(err) => { dev_warn!("execution error {err:?} cannot be send over backchannel") } } } } // We call `_exit` instead of `exit` to avoid flushing the parent's IO streams by accident. _exit(1); }; if let Some(spawner) = spawn_noexec_handler { spawner.spawn(); } // Close the file descriptors that we don't access drop(pty.follower); drop(backchannels.monitor); // Send green light to the monitor after closing the follower. retry_while_interrupted(|| backchannels.parent.send(&MonitorMessage::Edge)).map_err(|err| { dev_error!("cannot send green light to monitor: {err}"); err })?; let mut closure = ParentClosure::new( monitor_pid, sudo_pid, parent_pgrp, backchannels.parent, tty_pipe, tty_size, foreground, term_raw, &mut registry, )?; // Restore the signal mask now that the handlers have been setup. if let Some(set) = original_set { if let Err(err) = set.set_mask() { dev_warn!("cannot restore signal mask: {err}"); } } let exit_reason = closure.run(registry); // FIXME (ogsudo): Retry if `/dev/tty` is revoked. // Flush the terminal closure.tty_pipe.right().set_nonblocking()?; closure.tty_pipe.flush_left().ok(); // Restore the terminal settings if closure.term_raw { // Only restore the terminal if sudo is the foreground process. if let Ok(pgrp) = closure.tty_pipe.left().tcgetpgrp() { if pgrp == closure.parent_pgrp { match closure.tty_pipe.left_mut().restore(false) { Ok(()) => closure.term_raw = false, Err(err) => dev_warn!("cannot restore terminal settings: {err}"), } } } } // Restore signal handlers drop(closure.signal_handlers); exit_reason } fn get_pty() -> io::Result { let tty_gid = Group::from_name(cstr!("tty")) .unwrap_or(None) .map(|group| group.gid); let pty = Pty::open().map_err(|err| { dev_error!("cannot allocate pty: {err}"); io::Error::new(io::ErrorKind::NotFound, "unable to open pty") })?; let euid = User::effective_uid(); let gid = tty_gid.unwrap_or(User::effective_gid()); chown(&pty.path, euid, gid).map_err(|err| { dev_error!("cannot change owner for pty: {err}"); err })?; Ok(pty) } struct ParentClosure { // The monitor PID. // /// This is `Some` iff the process is still running. monitor_pid: Option, sudo_pid: ProcessId, parent_pgrp: ProcessId, command_pid: Option, tty_pipe: Pipe, tty_size: TermSize, foreground: bool, term_raw: bool, backchannel: ParentBackchannel, message_queue: VecDeque, backchannel_write_handle: EventHandle, signal_stream: &'static SignalStream, signal_handlers: [SignalHandler; ParentClosure::SIGNALS.len()], } impl ParentClosure { const SIGNALS: [SignalNumber; 11] = [ SIGINT, SIGQUIT, SIGTSTP, SIGTERM, SIGHUP, SIGALRM, SIGUSR1, SIGUSR2, SIGCHLD, SIGCONT, SIGWINCH, ]; #[allow(clippy::too_many_arguments)] fn new( monitor_pid: ProcessId, sudo_pid: ProcessId, parent_pgrp: ProcessId, mut backchannel: ParentBackchannel, tty_pipe: Pipe, tty_size: TermSize, foreground: bool, term_raw: bool, registry: &mut EventRegistry, ) -> io::Result { // Enable nonblocking assertions as we will poll this inside the event loop. backchannel.set_nonblocking_asserts(true); registry.register_event(&backchannel, PollEvent::Readable, ParentEvent::Backchannel); let mut backchannel_write_handle = registry.register_event(&backchannel, PollEvent::Writable, ParentEvent::Backchannel); // Ignore write events on the backchannel as we don't want to poll it for writing if there // are no messages in the queue. backchannel_write_handle.ignore(registry); let signal_stream = SignalStream::init()?; registry.register_event(signal_stream, PollEvent::Readable, |_| ParentEvent::Signal); let signal_handlers = register_handlers(Self::SIGNALS)?; Ok(Self { monitor_pid: Some(monitor_pid), sudo_pid, parent_pgrp, command_pid: None, tty_pipe, tty_size, foreground, term_raw, backchannel, message_queue: VecDeque::new(), backchannel_write_handle, signal_stream, signal_handlers, }) } fn run(&mut self, registry: EventRegistry) -> io::Result { let result = match registry.event_loop(self) { StopReason::Break(err) | StopReason::Exit(ParentExit::Backchannel(err)) => Err(err), StopReason::Exit(ParentExit::Command(exit_reason)) => Ok(exit_reason), }; // Send red light to the monitor after processing all events retry_while_interrupted(|| self.backchannel.send(&MonitorMessage::Edge)).map_err( |err| { dev_error!("cannot send red light to monitor: {err}"); err }, )?; result } /// Read an event from the backchannel and return the event if it should break the event loop. fn on_message_received(&mut self, registry: &mut EventRegistry) { match self.backchannel.recv() { Err(err) => { match err.kind() { // If we get EOF the monitor exited or was killed io::ErrorKind::UnexpectedEof => { dev_info!("received EOF from backchannel"); registry.set_exit(err.into()); } // We can try later if receive is interrupted. io::ErrorKind::Interrupted => {} // Failed to read command status. This means that something is wrong with the socket // and we should stop. _ => { dev_error!("cannot receive message from backchannel: {err}"); if !registry.got_break() { registry.set_break(err); } } } } Ok(event) => { match event { // Received the PID of the command. This means that the command is already // executing. ParentMessage::CommandPid(pid) => { dev_info!("received command PID ({pid}) from monitor"); self.command_pid = pid.into(); } ParentMessage::CommandStatus(status) => { // The command terminated or the monitor was not able to spawn it. We should stop // either way. match status { CommandStatus::Exit(exit_code) => { dev_info!("command exited with status code {exit_code}"); registry.set_exit(ExitReason::Code(exit_code).into()); } CommandStatus::Term(signal) => { dev_info!("command was terminated by {}", signal_fmt(signal)); registry.set_exit(ExitReason::Signal(signal).into()); } CommandStatus::Stop(signal) => { dev_info!( "command was stopped by {}, suspending parent", signal_fmt(signal) ); // Suspend parent and tell monitor how to resume on return if let Some(signal) = self.suspend_pty(signal, registry) { self.schedule_signal(signal, registry); } self.tty_pipe.resume_events(registry); } } } ParentMessage::IoError(code) => { let err = io::Error::from_raw_os_error(code); dev_info!("received error ({code}) for monitor: {err}"); registry.set_break(err); } ParentMessage::ShortRead => { dev_info!("received short read error for monitor"); registry.set_break(io::ErrorKind::UnexpectedEof.into()); } } } } } /// Decides if the signal sent by the process with `signaler_pid` PID is self-terminating. /// /// A signal is self-terminating if `signaler_pid`: /// - is the same PID of the command, or /// - is in the process group of the command and either sudo or the command is the leader. fn is_self_terminating(&self, signaler_pid: ProcessId) -> bool { if signaler_pid.is_valid() { if Some(signaler_pid) == self.command_pid { return true; } if let Ok(signaler_pgrp) = getpgid(signaler_pid) { if Some(signaler_pgrp) == self.command_pid || signaler_pgrp == self.sudo_pid { return true; } } } false } /// Schedule sending a signal event to the monitor using the backchannel. /// /// The signal message will be sent once the backchannel is ready to be written. fn schedule_signal(&mut self, signal: c_int, registry: &mut EventRegistry) { dev_info!("scheduling message with {} for monitor", signal_fmt(signal)); self.message_queue.push_back(MonitorMessage::Signal(signal)); // Start polling the backchannel for writing if not already. self.backchannel_write_handle.resume(registry); } /// Send the first message in the event queue using the backchannel, if any. /// /// Calling this function will block until the backchannel can be written. fn check_message_queue(&mut self, registry: &mut EventRegistry) { if let Some(msg) = self.message_queue.front() { dev_info!("sending message {msg:?} to monitor over backchannel"); match self.backchannel.send(msg) { // The event was sent, remove it from the queue Ok(()) => { self.message_queue.pop_front().unwrap(); // Stop polling the backchannel for writing if the queue is empty. if self.message_queue.is_empty() { self.backchannel_write_handle.ignore(registry); } } Err(err) => { // We can try later if send is interrupted. if err.kind() != io::ErrorKind::Interrupted { // There's something wrong with the backchannel, break the event loop. dev_error!("cannot send via backchannel {err}"); registry.set_break(err); } } } } } /// Suspend sudo if the command is suspended. /// /// Return `SIGCONT_FG` or `SIGCONT_BG` to state whether the command should be resumend in the /// foreground or not. fn suspend_pty( &mut self, signal: SignalNumber, registry: &mut EventRegistry, ) -> Option { // Ignore `SIGCONT` while suspending to avoid resuming the terminal twice. let sigcont_handler = SignalHandler::register(SIGCONT, SignalHandlerBehavior::Ignore) .map_err(|err| dev_warn!("cannot set handler for SIGCONT: {err}")) .ok(); if let SIGTTOU | SIGTTIN = signal { // If sudo is already the foreground process we can resume the command in the // foreground. Otherwise, we have to suspend and resume later. if !self.foreground && self.check_foreground().is_err() { // User's tty was revoked. return None; } if self.foreground { dev_info!( "command received {}, parent running in the foreground", signal_fmt(signal) ); if !self.term_raw { if self.tty_pipe.left_mut().set_raw_mode(false).is_ok() { self.term_raw = true; } // Resume command in the foreground return Some(SIGCONT_FG); } } } // Stop polling the terminals. self.tty_pipe.ignore_events(registry); if self.term_raw { match self.tty_pipe.left_mut().restore(false) { Ok(()) => self.term_raw = false, Err(err) => dev_warn!("cannot restore terminal settings: {err}"), } } let signal_handler = if signal != SIGSTOP { SignalHandler::register(signal, SignalHandlerBehavior::Default) .map_err(|err| dev_warn!("cannot set handler for {}: {err}", signal_fmt(signal))) .ok() } else { None }; if self.parent_pgrp != self.sudo_pid && kill(self.parent_pgrp, 0).is_err() || killpg(self.parent_pgrp, signal).is_err() { dev_error!("no parent to suspend, terminating command"); if let Some(command_pid) = self.command_pid.take() { terminate_process(command_pid, true); } } drop(signal_handler); if self.command_pid.is_none() || self.resume_terminal().is_err() { return None; } let ret_signal = if self.term_raw { SIGCONT_FG } else { SIGCONT_BG }; // Restore the handler for SIGCONT. drop(sigcont_handler); Some(ret_signal) } /// Check whether we are part of the foreground process group and update the foreground flag. fn check_foreground(&mut self) -> io::Result<()> { let pgrp = self.tty_pipe.left().tcgetpgrp()?; self.foreground = pgrp == self.parent_pgrp; Ok(()) } /// Restore the terminal when sudo resumes after receiving `SIGCONT`. fn resume_terminal(&mut self) -> io::Result<()> { self.check_foreground()?; // Update the pty settings based on the user's tty. self.tty_pipe .left() .copy_to(self.tty_pipe.right()) .map_err(|err| { dev_error!("cannot copy terminal settings to pty: {err}"); err })?; // FIXME: sync the terminal size here. dev_info!( "parent is in {} ({} -> {})", cond_fmt(self.foreground, "foreground", "background"), cond_fmt(self.term_raw, "raw", "cooked"), cond_fmt(self.foreground, "raw", "cooked"), ); if self.foreground { // We're in the foreground, set tty to raw mode. if self.tty_pipe.left_mut().set_raw_mode(false).is_ok() { self.term_raw = true; } } else { // We're in the background, cannot access tty. self.term_raw = false; } Ok(()) } fn on_signal(&mut self, registry: &mut EventRegistry) { let info = match self.signal_stream.recv() { Ok(info) => info, Err(err) => { dev_error!("parent could not receive signal: {err}"); return; } }; dev_info!("parent received{}", info); let Some(monitor_pid) = self.monitor_pid else { dev_info!("monitor was terminated, ignoring signal"); return; }; match info.signal() { SIGCHLD => handle_sigchld(self, registry, "monitor", monitor_pid), SIGCONT => { self.resume_terminal().ok(); } SIGWINCH => { if let Err(err) = self.handle_sigwinch() { dev_warn!("cannot resize terminal: {}", err); } } signal => { if let Some(pid) = info.signaler_pid() { if self.is_self_terminating(pid) { // Skip the signal if it was sent by the user and it is self-terminating. return; } } // FIXME: check `send_command_status` self.schedule_signal(signal, registry) } } } fn handle_sigwinch(&mut self) -> io::Result<()> { let new_size = self.tty_pipe.left().get_size()?; if new_size != self.tty_size { dev_info!("updating pty size from {} to {new_size}", self.tty_size); // Set the pty size. self.tty_pipe.right().set_size(&new_size)?; // Send SIGWINCH to the command. if let Some(command_pid) = self.command_pid { killpg(command_pid, SIGWINCH).ok(); } // Update the terminal size. self.tty_size = new_size; } Ok(()) } } enum ParentExit { /// Error while reading from the backchannel. Backchannel(io::Error), /// The command exited. Command(ExitReason), } impl From for ParentExit { fn from(err: io::Error) -> Self { Self::Backchannel(err) } } impl From for ParentExit { fn from(reason: ExitReason) -> Self { Self::Command(reason) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ParentEvent { Signal, Tty(PollEvent), Pty(PollEvent), Backchannel(PollEvent), } impl Process for ParentClosure { type Event = ParentEvent; type Break = io::Error; type Exit = ParentExit; fn on_event(&mut self, event: Self::Event, registry: &mut EventRegistry) { match event { ParentEvent::Signal => self.on_signal(registry), ParentEvent::Tty(poll_event) => { // Check if tty which existed is now gone. if self.tty_pipe.left().tcgetsid().is_err() { dev_warn!("tty gone (closed/detached), ignoring future events"); self.tty_pipe.ignore_events(registry); } else { self.tty_pipe.on_left_event(poll_event, registry).ok(); } } ParentEvent::Pty(poll_event) => { self.tty_pipe.on_right_event(poll_event, registry).ok(); } ParentEvent::Backchannel(poll_event) => match poll_event { PollEvent::Readable => self.on_message_received(registry), PollEvent::Writable => self.check_message_queue(registry), }, } } } impl HandleSigchld for ParentClosure { const OPTIONS: WaitOptions = WaitOptions::new().all().untraced().no_hang(); fn on_exit(&mut self, _exit_code: c_int, _registry: &mut EventRegistry) { self.monitor_pid = None; } fn on_term(&mut self, _signal: SignalNumber, _registry: &mut EventRegistry) { self.monitor_pid = None; } fn on_stop(&mut self, signal: SignalNumber, registry: &mut EventRegistry) { if let Some(signal) = self.suspend_pty(signal, registry) { self.schedule_signal(signal, registry); } self.tty_pipe.resume_events(registry); } } sudo-rs-0.2.10/src/exec/use_pty/pipe/mod.rs000064400000000000000000000154301046102023000165700ustar 00000000000000mod ring_buffer; use std::{ io::{self, Read, Write}, marker::PhantomData, os::fd::AsFd, }; use crate::exec::event::{EventHandle, EventRegistry, PollEvent, Process}; use self::ring_buffer::RingBuffer; // A pipe able to stream data bidirectionally between two read-write types. pub(super) struct Pipe { left: L, right: R, buffer_lr: Buffer, buffer_rl: Buffer, background: bool, } impl Pipe { /// Create a new pipe between two read-write types and register them to be polled. pub fn new( left: L, right: R, registry: &mut EventRegistry, f_left: fn(PollEvent) -> T::Event, f_right: fn(PollEvent) -> T::Event, ) -> Self { Self { buffer_lr: Buffer::new( registry.register_event(&left, PollEvent::Readable, f_left), registry.register_event(&right, PollEvent::Writable, f_right), registry, ), buffer_rl: Buffer::new( registry.register_event(&right, PollEvent::Readable, f_right), registry.register_event(&left, PollEvent::Writable, f_left), registry, ), left, right, background: false, } } /// Get a reference to the left side of the pipe. pub(super) fn left(&self) -> &L { &self.left } /// Get a mutable reference to the left side of the pipe. pub(super) fn left_mut(&mut self) -> &mut L { &mut self.left } /// Get a reference to the right side of the pipe. pub(super) fn right(&self) -> &R { &self.right } /// Stop the poll events of this pipe. pub(super) fn ignore_events(&mut self, registry: &mut EventRegistry) { self.buffer_lr.read_handle.ignore(registry); self.buffer_lr.write_handle.ignore(registry); self.buffer_rl.read_handle.ignore(registry); self.buffer_rl.write_handle.ignore(registry); } /// Stop the poll events of the left end of this pipe. pub(super) fn disable_input(&mut self, registry: &mut EventRegistry) { self.buffer_lr.read_handle.ignore(registry); self.background = true; } /// Resume the poll events of this pipe pub(super) fn resume_events(&mut self, registry: &mut EventRegistry) { if !self.background { self.buffer_lr.read_handle.resume(registry); } self.buffer_lr.write_handle.resume(registry); self.buffer_rl.read_handle.resume(registry); self.buffer_rl.write_handle.resume(registry); } /// Handle a poll event for the left side of the pipe. pub(super) fn on_left_event( &mut self, poll_event: PollEvent, registry: &mut EventRegistry, ) -> io::Result<()> { match poll_event { PollEvent::Readable => self.buffer_lr.read(&mut self.left, registry), PollEvent::Writable => { if self.buffer_rl.write(&mut self.left, registry)? { self.buffer_rl.read_handle.resume(registry); } Ok(()) } } } /// Handle a poll event for the right side of the pipe. pub(super) fn on_right_event( &mut self, poll_event: PollEvent, registry: &mut EventRegistry, ) -> io::Result<()> { match poll_event { PollEvent::Readable => self.buffer_rl.read(&mut self.right, registry), PollEvent::Writable => { if self.buffer_lr.write(&mut self.right, registry)? && !self.background { self.buffer_lr.read_handle.resume(registry); } Ok(()) } } } /// Flush the pipe, ensuring that all the contents are written to the left side. pub(super) fn flush_left(&mut self) -> io::Result<()> { let buffer = &mut self.buffer_rl; let source = &mut self.right; let sink = &mut self.left; // Flush the ring buffer, then process any eventual bytes still in-flight. buffer.internal.remove(sink)?; if buffer.write_handle.is_active() { let mut buf = [0u8; RingBuffer::LEN]; loop { match source.read(&mut buf) { Ok(read_bytes) => sink.write_all(&buf[..read_bytes])?, Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, Err(e) => return Err(e), } } } sink.flush() } } /// A buffer that stores the bytes read from `R` before they are written to `W`. struct Buffer { internal: RingBuffer, /// The handle for the event of the reader. read_handle: EventHandle, /// The handle for the event of the writer. write_handle: EventHandle, marker: PhantomData<(R, W)>, } impl Buffer { /// Create a new, empty buffer fn new( read_handle: EventHandle, mut write_handle: EventHandle, registry: &mut EventRegistry, ) -> Self { // The buffer is empty, don't write write_handle.ignore(registry); Self { internal: RingBuffer::new(), read_handle, write_handle, marker: PhantomData, } } /// Read bytes into the buffer. /// /// Calling this function will block until `read` is ready to be read. fn read( &mut self, read: &mut R, registry: &mut EventRegistry, ) -> io::Result<()> { // If the buffer is full, there is nothing to be read. if self.internal.is_full() { self.read_handle.ignore(registry); return Ok(()); } // Read bytes and insert them into the buffer. let inserted_len = self.internal.insert(read)?; // If we inserted something, the buffer is not empty anymore and we can resume writing. if inserted_len > 0 { self.write_handle.resume(registry); } Ok(()) } /// Write bytes from the buffer. /// /// Calling this function will block until `write` is ready to be written. fn write( &mut self, write: &mut W, registry: &mut EventRegistry, ) -> io::Result { // If the buffer is empty, there is nothing to be written. if self.internal.is_empty() { self.write_handle.ignore(registry); return Ok(false); } // Remove bytes from the buffer and write them. let removed_len = self.internal.remove(write)?; // Return whether we actually freed up some buffer space Ok(removed_len > 0) } } sudo-rs-0.2.10/src/exec/use_pty/pipe/ring_buffer.rs000064400000000000000000000251151046102023000203020ustar 00000000000000use std::io::{self, IoSlice, IoSliceMut, Read, Write}; pub(super) struct RingBuffer { storage: Box<[u8; Self::LEN]>, // The start index of the non-empty section of the buffer. start: usize, // The length of the non-empty section of the buffer. len: usize, } impl RingBuffer { /// The size of the internal storage of the ring buffer. pub(super) const LEN: usize = 8 * 1024; /// Create a new, empty buffer. pub(super) fn new() -> Self { Self { storage: Box::new([0; Self::LEN]), start: 0, len: 0, } } pub(super) fn is_full(&self) -> bool { self.len == self.storage.len() } pub(super) fn insert(&mut self, read: &mut R) -> io::Result { let inserted_len = if self.is_empty() { // Case 1.1. The buffer is empty, meaning that there are two unfilled slices in // `storage`:`start..` and `..start`. let (second_slice, first_slice) = self.storage.split_at_mut(self.start); read.read_vectored(&mut [first_slice, second_slice].map(IoSliceMut::new))? } else { let &mut Self { start, len, .. } = self; let end = start + len; if end >= self.storage.len() { // Case 1.2. The buffer is not empty and the filled section wraps around `storage`. // Meaning that there is only one unfilled slice in `storage`: `end..start`. let end = end % self.storage.len(); read.read(&mut self.storage[end..start])? } else { // Case 1.3. The buffer is not empty and the filled section is a contiguous slice // of `storage`. Meaning that there are two unfilled slices in `storage`: `..start` // and `end..`. let (mid, first_slice) = self.storage.split_at_mut(end); let second_slice = &mut mid[..start]; read.read_vectored(&mut [first_slice, second_slice].map(IoSliceMut::new))? } }; self.len += inserted_len; debug_assert!(self.start < Self::LEN); debug_assert!(self.len <= Self::LEN); Ok(inserted_len) } pub(super) fn is_empty(&self) -> bool { self.len == 0 } pub(super) fn remove(&mut self, write: &mut W) -> io::Result { let removed_len = if self.is_full() { // Case 2.1. The buffer is full, meaning that there are two filled slices in `storage`: // `start..` and `..start`. let (second_slice, first_slice) = self.storage.split_at(self.start); write.write_vectored(&[first_slice, second_slice].map(IoSlice::new))? } else { let end = self.start + self.len; if end >= self.storage.len() { // Case 2.2. The buffer is not full and the filled section wraps around `storage`. // Meaning that there are two non-empty slices in `storage`: `start..` and `..end`. let end = end % self.storage.len(); let first_slice = &self.storage[self.start..]; let second_slice = &self.storage[..end]; write.write_vectored(&[first_slice, second_slice].map(IoSlice::new))? } else { // Case 2.3. The buffer is not full and the filled section is a contiguous slice // of `storage.` Meaning that there is only one filled slice in `storage`: // `start..end`. write.write(&self.storage[self.start..end])? } }; self.start += removed_len; self.start %= Self::LEN; self.len -= removed_len; debug_assert!(self.start < Self::LEN); debug_assert!(self.len <= Self::LEN); Ok(removed_len) } } #[cfg(test)] mod tests { use super::RingBuffer; #[test] fn empty_buffer_is_empty() { let buf = RingBuffer::new(); assert!(buf.is_empty()); } #[test] fn full_buffer_is_full() { let mut buf = RingBuffer::new(); let inserted_len = buf.insert(&mut [0x45; RingBuffer::LEN].as_slice()).unwrap(); assert_eq!(inserted_len, RingBuffer::LEN); assert!(buf.is_full()); } #[test] fn buffer_is_fifo() { let mut buf = RingBuffer::new(); let expected = (0..=u8::MAX).collect::>(); let inserted_len = buf.insert(&mut expected.as_slice()).unwrap(); assert_eq!(inserted_len, expected.len()); let mut found = vec![]; let removed_len = buf.remove(&mut found).unwrap(); assert_eq!(removed_len, expected.len()); assert_eq!(expected, found); } #[test] fn insert_into_empty_buffer_with_offset() { const HALF_LEN: usize = RingBuffer::LEN / 2; let mut buf = RingBuffer::new(); // This should leave the buffer empty but with the start field pointing to the middle of // the buffer. // ┌───────────────────┐ // │ │ // └───────────────────┘ // ▲ // │ // start buf.insert(&mut [0u8; HALF_LEN].as_slice()).unwrap(); buf.remove(&mut vec![]).unwrap(); assert_eq!(buf.start, HALF_LEN); assert_eq!(buf.len, 0); // Then we fill the first half of the buffer with ones and the second one with twos in a // single insertion. This tests case 1.1. // ┌─────────┬─────────┐ // │ 2 │ 1 │ // └─────────┴─────────┘ // ▲ // │ // start let mut expected = vec![1; HALF_LEN]; expected.extend_from_slice(&[2; HALF_LEN]); buf.insert(&mut expected.as_slice()).unwrap(); assert_eq!(buf.start, HALF_LEN); assert_eq!(buf.len, RingBuffer::LEN); // When we remove all the elements of the buffer we should find them in the same order we // inserted them. This tests case 2.1. let mut found = vec![]; let removed_len = buf.remove(&mut found).unwrap(); assert_eq!(removed_len, expected.len()); assert_eq!(expected, found); assert_eq!(buf.start, HALF_LEN); assert_eq!(buf.len, 0); } #[test] fn insert_into_non_empty_wrapping_buffer() { const QUARTER_LEN: usize = RingBuffer::LEN / 4; let mut buf = RingBuffer::new(); // This should leave the buffer empty but with the start field pointing to the middle of // the buffer. // ┌───────────────────────┐ // │ │ // └───────────────────────┘ // ▲ // │ // start buf.insert(&mut [0; 2 * QUARTER_LEN].as_slice()).unwrap(); buf.remove(&mut vec![]).unwrap(); assert_eq!(buf.start, 2 * QUARTER_LEN); assert_eq!(buf.len, 0); // Then we fill one quarter of the buffer with ones. This gives us a non-empty buffer whose // empty section is not contiguous. // ┌───────────┬─────┬─────┐ // │ │ 1 │ │ // └───────────┴─────┴─────┘ // ▲ // │ // start let mut expected = vec![1; QUARTER_LEN]; buf.insert(&mut expected.as_slice()).unwrap(); assert_eq!(buf.start, 2 * QUARTER_LEN); assert_eq!(buf.len, QUARTER_LEN); // Then we fill one quarter of the buffer with twos and another quarter of the buffer with // threes in a single insertion. This tests case 1.2. // ┌─────┬─────┬─────┬─────┐ // │ 3 │ │ 1 │ 2 │ // └─────┴─────┴─────┴─────┘ // ▲ // │ // start let mut second_half = vec![2; QUARTER_LEN]; second_half.extend_from_slice(&[3; QUARTER_LEN]); buf.insert(&mut second_half.as_slice()).unwrap(); expected.extend(second_half); assert_eq!(buf.start, 2 * QUARTER_LEN); assert_eq!(buf.len, 3 * QUARTER_LEN); // When we remove all the elements of the buffer we should find them in the same order we // inserted them. This tests case 2.2. let mut found = vec![]; let removed_len = buf.remove(&mut found).unwrap(); assert_eq!(removed_len, expected.len()); assert_eq!(expected, found); assert_eq!(buf.start, QUARTER_LEN); assert_eq!(buf.len, 0); } #[test] fn insert_into_non_empty_non_wrapping_buffer() { const QUARTER_LEN: usize = RingBuffer::LEN / 4; let mut buf = RingBuffer::new(); // We fill one quarter of the buffer with ones. This gives us a non-empty buffer whose // empty section is contiguous. // ┌─────┬────────────────┐ // │ 1 │ │ // └─────┴────────────────┘ // ▲ // │ // └ start let mut expected = vec![1; QUARTER_LEN]; buf.insert(&mut expected.as_slice()).unwrap(); assert_eq!(buf.start, 0); assert_eq!(buf.len, QUARTER_LEN); // Then we fill one quarter of the buffer with twos. This tests case 1.3. // ┌─────┬─────┬──────────┐ // │ 1 │ 2 │ │ // └─────┴─────┴──────────┘ // ▲ // │ // └ start let second_half = vec![2; QUARTER_LEN]; buf.insert(&mut second_half.as_slice()).unwrap(); expected.extend(second_half); assert_eq!(buf.start, 0); assert_eq!(buf.len, 2 * QUARTER_LEN); // When we remove all the elements of the buffer we should find them in the same order we // inserted them. This tests case 2.3. let mut found = vec![]; let removed_len = buf.remove(&mut found).unwrap(); assert_eq!(removed_len, expected.len()); assert_eq!(expected, found); assert_eq!(buf.start, 2 * QUARTER_LEN); assert_eq!(buf.len, 0); } } sudo-rs-0.2.10/src/lib.rs000064400000000000000000000010211046102023000131750ustar 00000000000000#[macro_use] mod macros; #[cfg(feature = "apparmor")] pub(crate) mod apparmor; pub(crate) mod common; pub(crate) mod cutils; pub(crate) mod defaults; pub(crate) mod exec; pub(crate) mod log; pub(crate) mod pam; pub(crate) mod sudoers; pub(crate) mod system; mod su; mod sudo; mod visudo; pub use su::main as su_main; pub use sudo::main as sudo_main; pub use visudo::main as visudo_main; #[cfg(feature = "do-not-use-all-features")] compile_error!("Refusing to compile using 'cargo --all-features' --- please read the README"); sudo-rs-0.2.10/src/log/mod.rs000064400000000000000000000112351046102023000137770ustar 00000000000000#![allow(unused_macros)] use self::simple_logger::SimpleLogger; use self::syslog::Syslog; pub use log::Level; use std::ops::Deref; mod simple_logger; mod syslog; // TODO: logger_macro has an allow_unused that should be removed macro_rules! logger_macro { ($name:ident is $rule_level:ident to $target:expr, $d:tt) => { macro_rules! $name { ($d($d arg:tt)+) => (::log::log!(target: $target, $crate::log::Level::$rule_level, $d($d arg)+)); } #[allow(unused)] pub(crate) use $name; }; ($name:ident is $rule_level:ident to $target:expr) => { logger_macro!($name is $rule_level to $target, $); }; } logger_macro!(auth_error is Error to "sudo::auth"); logger_macro!(auth_warn is Warn to "sudo::auth"); logger_macro!(auth_info is Info to "sudo::auth"); logger_macro!(auth_debug is Debug to "sudo::auth"); logger_macro!(auth_trace is Trace to "sudo::auth"); logger_macro!(user_error is Error to "sudo::user"); logger_macro!(user_warn is Warn to "sudo::user"); logger_macro!(user_info is Info to "sudo::user"); logger_macro!(user_debug is Debug to "sudo::user"); logger_macro!(user_trace is Trace to "sudo::user"); // TODO: dev_logger_macro has an allow_unused that should be removed macro_rules! dev_logger_macro { ($name:ident is $rule_level:ident to $target:expr, $d:tt) => { macro_rules! $name { ($d($d arg:tt)+) => { if std::cfg!(feature = "dev") { (::log::log!( target: $target, $crate::log::Level::$rule_level, "{}: {}", std::panic::Location::caller(), format_args!($d($d arg)+) )); } }; } #[allow(unused)] pub(crate) use $name; }; ($name:ident is $rule_level:ident to $target:expr) => { dev_logger_macro!($name is $rule_level to $target, $); }; } dev_logger_macro!(dev_error is Error to "sudo::dev"); dev_logger_macro!(dev_warn is Warn to "sudo::dev"); dev_logger_macro!(dev_info is Info to "sudo::dev"); dev_logger_macro!(dev_debug is Debug to "sudo::dev"); dev_logger_macro!(dev_trace is Trace to "sudo::dev"); #[derive(Default)] pub struct SudoLogger(Vec<(String, Box)>); impl SudoLogger { pub fn new(prefix: &'static str) -> Self { let mut logger: Self = Default::default(); logger.add_logger("sudo::auth", Syslog); logger.add_logger("sudo::user", SimpleLogger::to_stderr(prefix)); #[cfg(feature = "dev")] { let path = option_env!("SUDO_DEV_LOGS") .map(|s| s.into()) .unwrap_or_else(|| { std::env::temp_dir().join(format!("sudo-dev-{}.log", std::process::id())) }); logger.add_logger("sudo::dev", SimpleLogger::to_file(path, "").unwrap()); } logger } pub fn into_global_logger(self) { log::set_boxed_logger(Box::new(self)) .map(|()| log::set_max_level(log::LevelFilter::Trace)) .expect("Could not set previously set logger"); } /// Add a logger for a specific prefix to the stack fn add_logger( &mut self, prefix: impl ToString + Deref, logger: impl log::Log + 'static, ) { self.add_boxed_logger(prefix, Box::new(logger)) } /// Add a boxed logger for a specific prefix to the stack fn add_boxed_logger( &mut self, prefix: impl ToString + Deref, logger: Box, ) { let prefix = if prefix.ends_with("::") { prefix.to_string() } else { // given a prefix `my::prefix`, we want to match `my::prefix::somewhere` // but not `my::prefix_to_somewhere` format!("{}::", prefix.to_string()) }; self.0.push((prefix, logger)) } } impl log::Log for SudoLogger { fn enabled(&self, metadata: &log::Metadata) -> bool { self.0.iter().any(|(_, l)| l.enabled(metadata)) } fn log(&self, record: &log::Record) { for (prefix, l) in self.0.iter() { if record.target() == &prefix[..prefix.len() - 2] || record.target().starts_with(prefix) { l.log(record); } } } fn flush(&self) { for (_, l) in self.0.iter() { l.flush(); } } } #[cfg(test)] mod tests { use super::SudoLogger; #[test] fn can_construct_logger() { let logger = SudoLogger::new("sudo: "); let len = if cfg!(feature = "dev") { 3 } else { 2 }; assert_eq!(logger.0.len(), len); } } sudo-rs-0.2.10/src/log/simple_logger.rs000064400000000000000000000056471046102023000160620ustar 00000000000000use std::io::Write; #[cfg(feature = "dev")] use std::{fs::File, path::Path}; use log::Log; pub struct SimpleLogger where for<'a> &'a W: Write, { target: W, prefix: &'static str, } impl Log for SimpleLogger where for<'a> &'a W: Write, { fn enabled(&self, metadata: &log::Metadata) -> bool { metadata.level() <= log::max_level() && metadata.level() <= log::STATIC_MAX_LEVEL } fn log(&self, record: &log::Record) { let s = format!("{}{}\n", self.prefix, record.args()); let _ = (&self.target).write_all(s.as_bytes()); } fn flush(&self) { let _ = (&self.target).flush(); } } impl SimpleLogger { pub fn to_stderr(prefix: &'static str) -> SimpleLogger { SimpleLogger { target: std::io::stderr(), prefix, } } } #[cfg(feature = "dev")] impl SimpleLogger { pub fn to_file>(name: P, prefix: &'static str) -> Result { let target = std::fs::OpenOptions::new() .append(true) .create(true) .open(name)?; Ok(Self { target, prefix }) } } #[cfg(test)] mod tests { use std::{ io, sync::{Arc, RwLock}, }; use super::SimpleLogger; use log::{LevelFilter, Log}; #[derive(Clone, Default)] struct MyString { inner: Arc>, } impl MyString { fn read(&self) -> String { self.inner.read().unwrap().clone() } } impl io::Write for &'_ MyString { fn write(&mut self, buf: &[u8]) -> io::Result { self.inner .write() .unwrap() .push_str(std::str::from_utf8(buf).unwrap()); Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { self.write(b"flushed").map(drop) } } #[test] fn test_default_level() { let logger = SimpleLogger::to_stderr("test"); let metadata = log::Metadata::builder().level(log::Level::Trace).build(); log::set_max_level(LevelFilter::Trace); assert!(logger.enabled(&metadata)); log::set_max_level(LevelFilter::Info); assert!(!logger.enabled(&metadata)); } #[test] fn test_write_and_flush() { let target = MyString::default(); let logger = SimpleLogger { target: target.clone(), prefix: "[test] ", }; let record = log::Record::builder() .args(format_args!("Hello World!")) .level(log::Level::Info) .build(); logger.log(&record); let value = target.read(); assert_eq!(value, "[test] Hello World!\n"); drop(value); logger.flush(); let value = target.read(); assert_eq!(value, "[test] Hello World!\nflushed"); drop(value); } } sudo-rs-0.2.10/src/log/syslog.rs000064400000000000000000000217721046102023000145470ustar 00000000000000use core::fmt::{self, Write}; use log::{Level, Log, Metadata}; pub struct Syslog; mod internal { use crate::system::syslog; use std::ffi::CStr; const DOTDOTDOT_START: &[u8] = b"[...] "; const DOTDOTDOT_END: &[u8] = b" [...]"; const MAX_MSG_LEN: usize = 960; const NULL_BYTE_LEN: usize = 1; // for C string compatibility const BUFSZ: usize = MAX_MSG_LEN + DOTDOTDOT_END.len() + NULL_BYTE_LEN; pub struct SysLogMessageWriter { buffer: [u8; BUFSZ], cursor: usize, facility: libc::c_int, priority: libc::c_int, } // - whenever a SysLogMessageWriter has been constructed, a syslog message WILL be created // for one specific event; this struct functions as a low-level interface for that message // - the caller of the pub functions will have to take care never to `append` more bytes than // are `available`, or a panic will occur. // - the impl guarantees that after `line_break()`, there will be enough room available for at // least a single UTF8 character sequence (which is true since MAX_MSG_LEN >= 10) impl SysLogMessageWriter { pub fn new(priority: libc::c_int, facility: libc::c_int) -> Self { Self { buffer: [0; BUFSZ], cursor: 0, priority, facility, } } pub fn append(&mut self, bytes: &[u8]) { let num_bytes = bytes.len(); self.buffer[self.cursor..self.cursor + num_bytes].copy_from_slice(bytes); self.cursor += num_bytes; } pub fn line_break(&mut self) { self.append(DOTDOTDOT_END); self.commit_to_syslog(); self.append(DOTDOTDOT_START); } fn commit_to_syslog(&mut self) { self.append(&[0]); let message = CStr::from_bytes_with_nul(&self.buffer[..self.cursor]).unwrap(); syslog(self.priority, self.facility, message); self.cursor = 0; } pub fn available(&self) -> usize { MAX_MSG_LEN - self.cursor } } impl Drop for SysLogMessageWriter { fn drop(&mut self) { self.commit_to_syslog(); } } } use internal::SysLogMessageWriter; /// `floor_char_boundary` is currently unstable in Rust fn floor_char_boundary(data: &str, mut index: usize) -> usize { if index >= data.len() { return data.len(); } while !data.is_char_boundary(index) { index -= 1; } index } /// This function REQUIRES that `message` is larger than `max_size` (or a panic will occur). /// This function WILL return a non-zero result if `max_size` is large enough to fit /// at least the first character of `message`. fn suggested_break(message: &str, max_size: usize) -> usize { // method A: try to split the message in two non-empty parts on an ASCII white space character // method B: split on the utf8 character boundary that consumes the most data if let Some(pos) = message.as_bytes()[1..max_size] .iter() .rposition(|c| c.is_ascii_whitespace()) { // since pos+1 contains ASCII whitespace, it acts as a valid utf8 boundary as well pos + 1 } else { floor_char_boundary(message, max_size) } } impl Write for SysLogMessageWriter { fn write_str(&mut self, mut message: &str) -> fmt::Result { while message.len() > self.available() { let truncate_boundary = suggested_break(message, self.available()); let left = &message[..truncate_boundary]; let right = &message[truncate_boundary..]; self.append(left.as_bytes()); self.line_break(); // This loop while terminate, since either of the following is true: // 1. truncate_boundary is strictly positive: // message.len() has strictly decreased, and self.available() has not decreased // 2. truncate_boundary is zero: // message.len() has remained unchanged, but self.available() has strictly increased; // this latter is true since, for truncate_boundary to be 0, self.available() must // have been not large enough to fit a single UTF8 character message = right; } self.append(message.as_bytes()); Ok(()) } } const FACILITY: libc::c_int = libc::LOG_AUTH; impl Log for Syslog { fn enabled(&self, metadata: &Metadata) -> bool { metadata.level() <= log::max_level() && metadata.level() <= log::STATIC_MAX_LEVEL } fn log(&self, record: &log::Record) { let priority = match record.level() { Level::Error => libc::LOG_ERR, Level::Warn => libc::LOG_WARNING, Level::Info => libc::LOG_INFO, Level::Debug => libc::LOG_DEBUG, Level::Trace => libc::LOG_DEBUG, }; let mut writer = SysLogMessageWriter::new(priority, FACILITY); let _ = write!(writer, "{}", record.args()); } fn flush(&self) { // pass } } #[cfg(test)] mod tests { use log::Log; use std::fmt::Write; use super::{SysLogMessageWriter, Syslog, FACILITY}; #[test] fn can_write_to_syslog() { let logger = Syslog; let record = log::Record::builder() .args(format_args!("Hello World!")) .level(log::Level::Info) .build(); logger.log(&record); } #[test] fn can_handle_multiple_writes() { let mut writer = SysLogMessageWriter::new(libc::LOG_DEBUG, FACILITY); for i in 1..20 { let _ = write!(writer, "{}", "Test 123 ".repeat(i)); } } #[test] fn can_truncate_syslog() { let logger = Syslog; let record = log::Record::builder() .args(format_args!("This is supposed to be a very long syslog message but idk what to write, so I am just going to tell you about the time I tried to make coffee with a teapot. So I woke up one morning and decided to make myself a pot of coffee, however after all the wild coffee parties and mishaps the coffee pot had evetually given it's last cup on a tragic morning I call wednsday. So it came to, that the only object capable of giving me hope for the day was my teapot. As I stood in the kitchen and reached for my teapot it, as if sensing the impending horrors that awaited the innocent little teapot, emmited a horse sheak of desperation. \"three hundred and seven\", it said. \"What?\" I asked with a voice of someone who clearly did not want to be bothered until he had his daily almost medically necessary dose of caffine. \"I am a teapot\" it responded with a voice of increasing forcefulness. \"I am a teapot, not a coffee pot\". It was then, in my moments of confusion that my brain finally understood, this was a teapot.")) .level(log::Level::Info) .build(); logger.log(&record); } #[test] fn can_truncate_syslog_with_no_spaces() { let logger = Syslog; let record = log::Record::builder() .args(format_args!("iwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercasesiwillhandlecornercases")) .level(log::Level::Info) .build(); logger.log(&record); } #[test] fn will_not_break_utf8() { let mut writer = SysLogMessageWriter::new(libc::LOG_DEBUG, FACILITY); let _ = write!(writer, "{}¢", "x".repeat(959)); } } sudo-rs-0.2.10/src/macros.rs000064400000000000000000000031151046102023000137210ustar 00000000000000// the `std::print` macros panic on any IO error. these are non-panicking alternatives macro_rules! println_ignore_io_error { ($($tt:tt)*) => {{ use std::io::Write; let _ = writeln!(std::io::stdout(), $($tt)*); }} } macro_rules! eprintln_ignore_io_error { ($($tt:tt)*) => {{ use std::io::Write; let _ = writeln!(std::io::stderr(), $($tt)*); }} } // catch unintentional uses of `print*` macros with the test suite #[allow(unused_macros)] #[cfg(debug_assertions)] macro_rules! eprintln { ($($tt:tt)*) => { compile_error!("do not use `eprintln!`; use the `write!` macro instead") }; } #[allow(unused_macros)] #[cfg(debug_assertions)] macro_rules! eprint { ($($tt:tt)*) => { compile_error!("do not use `eprint!`; use the `write!` macro instead") }; } #[allow(unused_macros)] #[cfg(debug_assertions)] macro_rules! println { ($($tt:tt)*) => { compile_error!("do not use `println!`; use the `write!` macro instead") }; } #[allow(unused_macros)] #[cfg(debug_assertions)] macro_rules! print { ($($tt:tt)*) => { compile_error!("do not use `print!`; use the `write!` macro instead") }; } macro_rules! cstr { ($lit:literal) => {{ // this `const` item produces compile time errors = it performs the checks at compile time const CS: &'static std::ffi::CStr = match std::ffi::CStr::from_bytes_until_nul(concat!($lit, "\0").as_bytes()) { Ok(x) => x, Err(_) => panic!("string literal did not pass CStr checks"), }; CS }}; } sudo-rs-0.2.10/src/pam/converse.rs000064400000000000000000000371141046102023000150440ustar 00000000000000use std::io; use crate::cutils::string_from_ptr; use crate::system::{ signal::{self, SignalSet}, time::Duration, }; use super::sys::*; use super::{error::PamResult, rpassword, securemem::PamBuffer, PamError, PamErrorType}; /// Each message in a PAM conversation will have a message style. Each of these /// styles must be handled separately. #[derive(Clone, Copy)] pub enum PamMessageStyle { /// Prompt for input using a message. The input should considered secret /// and should be hidden from view. PromptEchoOff = PAM_PROMPT_ECHO_OFF as isize, /// Prompt for input using a message. The input does not have to be /// considered a secret and may be displayed to the user. PromptEchoOn = PAM_PROMPT_ECHO_ON as isize, /// Display an error message. The user should not be prompted for any input. ErrorMessage = PAM_ERROR_MSG as isize, /// Display some informational text. The user should not be prompted for any /// input. TextInfo = PAM_TEXT_INFO as isize, } impl PamMessageStyle { pub fn from_int(val: libc::c_int) -> Option { use PamMessageStyle::*; match val as _ { PAM_PROMPT_ECHO_OFF => Some(PromptEchoOff), PAM_PROMPT_ECHO_ON => Some(PromptEchoOn), PAM_ERROR_MSG => Some(ErrorMessage), PAM_TEXT_INFO => Some(TextInfo), _ => None, } } } pub trait Converser { /// Handle a normal prompt, i.e. present some message and ask for a value. /// The value is not considered a secret. fn handle_normal_prompt(&self, msg: &str) -> PamResult; /// Handle a hidden prompt, i.e. present some message and ask for a value. /// The value is considered secret and should not be visible. fn handle_hidden_prompt(&self, msg: &str) -> PamResult; /// Display an error message to the user, the user does not need to input a /// value. fn handle_error(&self, msg: &str) -> PamResult<()>; /// Display an informational message to the user, the user does not need to /// input a value. fn handle_info(&self, msg: &str) -> PamResult<()>; } /// Handle a single message in a conversation. fn handle_message( app_data: &ConverserData, style: PamMessageStyle, msg: &str, ) -> PamResult> { use PamMessageStyle::*; match style { PromptEchoOn | PromptEchoOff if app_data.no_interact => Err(PamError::InteractionRequired), PromptEchoOn => app_data.converser.handle_normal_prompt(msg).map(Some), PromptEchoOff => { let final_prompt = match app_data.auth_prompt.as_deref() { None => { // Suppress password prompt entirely when -p '' is passed. String::new() } Some(prompt) => { format!("[{}: {prompt}] {msg}", app_data.converser_name) } }; app_data .converser .handle_hidden_prompt(&final_prompt) .map(Some) } ErrorMessage => app_data.converser.handle_error(msg).map(|()| None), TextInfo => app_data.converser.handle_info(msg).map(|()| None), } } /// A converser that uses stdin/stdout/stderr to display messages and to request /// input from the user. pub struct CLIConverser { pub(super) name: String, pub(super) use_stdin: bool, pub(super) bell: bool, pub(super) password_feedback: bool, pub(super) password_timeout: Option, } use rpassword::Terminal; struct SignalGuard(Option); impl SignalGuard { fn unblock_interrupts() -> Self { let cur_signals = SignalSet::empty().and_then(|mut set| { set.add(signal::consts::SIGINT)?; set.add(signal::consts::SIGQUIT)?; set.unblock() }); Self(cur_signals.ok()) } } impl Drop for SignalGuard { fn drop(&mut self) { if let Some(signal) = &self.0 { // Ignore any errors at this point let _ = signal.set_mask(); } } } impl CLIConverser { fn open(&self) -> std::io::Result<(Terminal<'_>, SignalGuard)> { let term = if self.use_stdin { Terminal::open_stdie()? } else { Terminal::open_tty()? }; Ok((term, SignalGuard::unblock_interrupts())) } } impl Converser for CLIConverser { fn handle_normal_prompt(&self, msg: &str) -> PamResult { let (mut tty, _guard) = self.open()?; tty.prompt(&format!("[{}: input needed] {msg} ", self.name))?; Ok(tty.read_cleartext()?) } fn handle_hidden_prompt(&self, msg: &str) -> PamResult { let (mut tty, _guard) = self.open()?; if self.bell && !self.use_stdin { tty.bell()?; } tty.prompt(msg)?; if self.password_feedback { tty.read_password_with_feedback(self.password_timeout) } else { tty.read_password(self.password_timeout) } .map_err(|err| { if let io::ErrorKind::TimedOut = err.kind() { PamError::TimedOut } else { PamError::IoError(err) } }) } fn handle_error(&self, msg: &str) -> PamResult<()> { let (mut tty, _) = self.open()?; Ok(tty.prompt(&format!("[{} error] {msg}\n", self.name))?) } fn handle_info(&self, msg: &str) -> PamResult<()> { let (mut tty, _) = self.open()?; Ok(tty.prompt(&format!("[{}] {msg}\n", self.name))?) } } /// Helper struct that contains the converser as well as panic boolean pub(super) struct ConverserData { pub(super) converser: C, pub(super) converser_name: String, pub(super) no_interact: bool, pub(super) auth_prompt: Option, // pam_authenticate does not return error codes returned by the conversation // function; these are set by the conversation function instead of returning // multiple error codes. pub(super) timed_out: bool, pub(super) panicked: bool, } /// This function implements the conversation function of `pam_conv`. /// /// This function should always be called with an appdata_ptr that implements /// the `Converser` trait. It then collects the messages provided into a vector /// that is passed to the converser. The converser can then respond to those /// messages and add their replies (where applicable). Finally the replies are /// converted back to the C interface and returned to PAM. This function tries /// to catch any unwinding panics and sets state to indicate that a panic /// occurred. /// /// # Safety /// * If called with an appdata_ptr that does not correspond with the Converser /// this function will exhibit undefined behavior. /// * The messages from PAM are assumed to be formatted correctly. pub(super) unsafe extern "C" fn converse( num_msg: libc::c_int, msg: *mut *const pam_message, response: *mut *mut pam_response, appdata_ptr: *mut libc::c_void, ) -> libc::c_int { let result = std::panic::catch_unwind(|| { let mut resp_bufs = Vec::with_capacity(num_msg as usize); for i in 0..num_msg as usize { // convert the input messages to Rust types // SAFETY: the PAM contract ensures that `num_msg` does not exceed the amount // of messages presented to this function in `msg`, and that it is not being // written to at the same time as we are reading it. Note that the reference // we create does not escape this loopy body. let message: &pam_message = unsafe { &**msg.add(i) }; // SAFETY: PAM ensures that the messages passed are properly null-terminated let msg = unsafe { string_from_ptr(message.msg) }; let style = if let Some(style) = PamMessageStyle::from_int(message.msg_style) { style } else { // early return if there is a failure to convert, pam would have given us nonsense return PamErrorType::ConversationError; }; // send the conversation off to the Rust part // SAFETY: appdata_ptr contains the `*mut ConverserData` that is untouched by PAM let app_data = unsafe { &mut *(appdata_ptr as *mut ConverserData) }; match handle_message(app_data, style, &msg) { Ok(resp_buf) => { resp_bufs.push(resp_buf); } Err(PamError::TimedOut) => { app_data.timed_out = true; return PamErrorType::ConversationError; } Err(_) => return PamErrorType::ConversationError, } } // Allocate enough memory for the responses, which are initialized with zero. // SAFETY: this will either allocate the required amount of (initialized) bytes, // or return a null pointer. let temp_resp = unsafe { libc::calloc( num_msg as libc::size_t, std::mem::size_of::() as libc::size_t, ) } as *mut pam_response; if temp_resp.is_null() { return PamErrorType::BufferError; } // Store the responses for (i, resp_buf) in resp_bufs.into_iter().enumerate() { // SAFETY: `i` will not exceed `num_msg` by the way `conversation_messages` // is constructed, so `temp_resp` will have allocated-and-initialized data at // the required offset that only we have a writable pointer to. let response: &mut pam_response = unsafe { &mut *(temp_resp.add(i)) }; if let Some(secbuf) = resp_buf { response.resp = secbuf.leak().as_ptr().cast(); } } // Set the responses // SAFETY: PAM contract says that we are passed a valid, non-null, writeable pointer here. unsafe { *response = temp_resp }; PamErrorType::Success }); // handle any unwinding panics that occurred here let res = match result { Ok(r) => r, Err(_) => { // notify caller that a panic has occurred // SAFETY: appdata_ptr contains the `*mut ConverserData` that is untouched by PAM let app_data = unsafe { &mut *(appdata_ptr as *mut ConverserData) }; app_data.panicked = true; PamErrorType::ConversationError } }; res.as_int() } #[allow(clippy::undocumented_unsafe_blocks)] #[cfg(test)] mod test { use super::*; use std::pin::Pin; use PamMessageStyle::*; struct PamMessage { msg: String, style: PamMessageStyle, } impl Converser for String { fn handle_normal_prompt(&self, msg: &str) -> PamResult { Ok(PamBuffer::new(format!("{self} says {msg}").into_bytes())) } fn handle_hidden_prompt(&self, msg: &str) -> PamResult { Ok(PamBuffer::new(msg.as_bytes().to_vec())) } fn handle_error(&self, msg: &str) -> PamResult<()> { panic!("{msg}") } fn handle_info(&self, _msg: &str) -> PamResult<()> { Ok(()) } } // essentially do the inverse of the "conversation function" fn dummy_pam(msgs: &[PamMessage], talkie: &pam_conv) -> Vec> { let pam_msgs = msgs .iter() .map(|PamMessage { msg, style, .. }| pam_message { msg: std::ffi::CString::new(&msg[..]).unwrap().into_raw(), msg_style: *style as i32, }) .rev() .collect::>(); let mut ptrs = pam_msgs .iter() .map(|x| x as *const pam_message) .rev() .collect::>(); let mut raw_response = std::ptr::null_mut::(); let conv_err = unsafe { talkie.conv.expect("non-null fn ptr")( ptrs.len() as i32, ptrs.as_mut_ptr(), &mut raw_response, talkie.appdata_ptr, ) }; // deallocate the leaky strings for rec in ptrs { unsafe { drop(std::ffi::CString::from_raw((*rec).msg as *mut _)); } } if conv_err != 0 { return vec![]; } let result = msgs .iter() .enumerate() .map(|(i, _)| unsafe { let ptr = raw_response.add(i); if (*ptr).resp.is_null() { None } else { // "The resp_retcode member of this struct is unused and should be set to zero." assert_eq!((*ptr).resp_retcode, 0); let response = string_from_ptr((*ptr).resp); libc::free((*ptr).resp as *mut _); Some(response) } }) .collect(); unsafe { libc::free(raw_response as *mut _) }; result } fn msg(style: PamMessageStyle, msg: &str) -> PamMessage { let msg = msg.to_string(); PamMessage { style, msg } } // sanity check on the test cases; lib.rs is expected to manage the lifetime of the pointer // inside the pam_conv object explicitly. use std::marker::PhantomData; struct PamConvBorrow<'a> { pam_conv: pam_conv, _marker: std::marker::PhantomData<&'a ()>, } impl<'a> PamConvBorrow<'a> { fn new(data: Pin<&'a mut ConverserData>) -> PamConvBorrow<'a> { let appdata_ptr = unsafe { data.get_unchecked_mut() as *mut ConverserData as *mut libc::c_void }; PamConvBorrow { pam_conv: pam_conv { conv: Some(converse::), appdata_ptr, }, _marker: PhantomData, } } fn borrow(&self) -> &pam_conv { &self.pam_conv } } #[test] fn miri_pam_gpt() { let mut hello = Box::pin(ConverserData { converser: "tux".to_string(), converser_name: "tux".to_string(), no_interact: false, auth_prompt: Some("authenticate".to_owned()), timed_out: false, panicked: false, }); let cookie = PamConvBorrow::new(hello.as_mut()); let pam_conv = cookie.borrow(); assert_eq!(dummy_pam(&[], pam_conv), vec![]); assert_eq!( dummy_pam(&[msg(PromptEchoOn, "hello")], pam_conv), vec![Some("tux says hello".to_string())] ); assert_eq!( dummy_pam(&[msg(PromptEchoOff, "fish")], pam_conv), vec![Some("[tux: authenticate] fish".to_string())] ); assert_eq!(dummy_pam(&[msg(TextInfo, "mars")], pam_conv), vec![None]); assert_eq!( dummy_pam( &[ msg(PromptEchoOff, "banging the rocks together"), msg(TextInfo, ""), msg(PromptEchoOn, ""), ], pam_conv ), vec![ Some("[tux: authenticate] banging the rocks together".to_string()), None, Some("tux says ".to_string()), ] ); //assert!(!hello.panicked); // not allowed by borrow checker let real_hello = unsafe { &mut *(pam_conv.appdata_ptr as *mut ConverserData) }; assert!(!real_hello.panicked); assert_eq!(dummy_pam(&[msg(ErrorMessage, "oops")], pam_conv), vec![]); assert!(hello.panicked); // allowed now } } sudo-rs-0.2.10/src/pam/error.rs000064400000000000000000000233551046102023000143530ustar 00000000000000use std::{ffi::NulError, fmt, str::Utf8Error}; use crate::cutils::string_from_ptr; use super::sys::*; pub type PamResult = Result; // TODO: add missing doc-comments #[derive(PartialEq, Eq, Debug)] pub enum PamErrorType { /// There was no error running the PAM command Success, OpenError, SymbolError, ServiceError, SystemError, BufferError, ConversationError, PermissionDenied, /// The maximum number of authentication attempts was reached and no more /// attempts should be made. MaxTries, /// The user failed to authenticate correctly. AuthError, NewAuthTokenRequired, /// The application does not have enough credentials to authenticate the /// user. This can for example happen if we wanted to update the user /// password from a non-root process, which we cannot do. CredentialsInsufficient, /// PAM modules were unable to access the authentication information (for /// example due to a network error). AuthInfoUnavailable, /// The specified user is unknown to an authentication service. UserUnknown, /// Failed to retrieve the credentials (i.e. password) for a user. CredentialsUnavailable, /// The credentials (i.e. password) for this user were expired. CredentialsExpired, /// There was an error setting the user credentials. CredentialsError, /// The user account is expired and can no longer be used. AccountExpired, AuthTokenExpired, SessionError, AuthTokenError, AuthTokenRecoveryError, AuthTokenLockBusy, AuthTokenDisableAging, NoModuleData, Ignore, /// The application should exit immediately. Abort, TryAgain, ModuleUnknown, /// The application tried to set/delete an undefined or inaccessible item. BadItem, // Extension in OpenPAM and LinuxPAM // DomainUnknown, // OpenPAM only // BadHandle // OpenPAM only // BadFeature // OpenPAM only // BadConstant // OpenPAM only // ConverseAgain // LinuxPAM only // Incomplete // LinuxPAM only UnknownErrorType(i32), } impl PamErrorType { pub(super) fn from_int(errno: libc::c_int) -> PamErrorType { use PamErrorType::*; match errno as _ { PAM_SUCCESS => Success, PAM_OPEN_ERR => OpenError, PAM_SYMBOL_ERR => SymbolError, PAM_SERVICE_ERR => ServiceError, PAM_SYSTEM_ERR => SystemError, PAM_BUF_ERR => BufferError, PAM_CONV_ERR => ConversationError, PAM_PERM_DENIED => PermissionDenied, PAM_MAXTRIES => MaxTries, PAM_AUTH_ERR => AuthError, PAM_NEW_AUTHTOK_REQD => NewAuthTokenRequired, PAM_CRED_INSUFFICIENT => CredentialsInsufficient, PAM_AUTHINFO_UNAVAIL => AuthInfoUnavailable, PAM_USER_UNKNOWN => UserUnknown, PAM_CRED_UNAVAIL => CredentialsUnavailable, PAM_CRED_EXPIRED => CredentialsExpired, PAM_CRED_ERR => CredentialsError, PAM_ACCT_EXPIRED => AccountExpired, PAM_AUTHTOK_EXPIRED => AuthTokenExpired, PAM_SESSION_ERR => SessionError, PAM_AUTHTOK_ERR => AuthTokenError, PAM_AUTHTOK_RECOVERY_ERR => AuthTokenRecoveryError, PAM_AUTHTOK_LOCK_BUSY => AuthTokenLockBusy, PAM_AUTHTOK_DISABLE_AGING => AuthTokenDisableAging, PAM_NO_MODULE_DATA => NoModuleData, PAM_IGNORE => Ignore, PAM_ABORT => Abort, PAM_TRY_AGAIN => TryAgain, PAM_MODULE_UNKNOWN => ModuleUnknown, PAM_BAD_ITEM => BadItem, // PAM_DOMAIN_UNKNOWN => DomainUnknown, // PAM_BAD_HANDLE => BadHandle, // PAM_BAD_FEATURE => BadFeature, // PAM_BAD_CONSTANT => BadConstant, // PAM_CONV_AGAIN => ConverseAgain, // PAM_INCOMPLETE => Incomplete, _ => UnknownErrorType(errno), } } pub fn as_int(&self) -> libc::c_int { use PamErrorType::*; match self { Success => PAM_SUCCESS as libc::c_int, OpenError => PAM_OPEN_ERR as libc::c_int, SymbolError => PAM_SYMBOL_ERR as libc::c_int, ServiceError => PAM_SERVICE_ERR as libc::c_int, SystemError => PAM_SYSTEM_ERR as libc::c_int, BufferError => PAM_BUF_ERR as libc::c_int, ConversationError => PAM_CONV_ERR as libc::c_int, PermissionDenied => PAM_PERM_DENIED as libc::c_int, MaxTries => PAM_MAXTRIES as libc::c_int, AuthError => PAM_AUTH_ERR as libc::c_int, NewAuthTokenRequired => PAM_NEW_AUTHTOK_REQD as libc::c_int, CredentialsInsufficient => PAM_CRED_INSUFFICIENT as libc::c_int, AuthInfoUnavailable => PAM_AUTHINFO_UNAVAIL as libc::c_int, UserUnknown => PAM_USER_UNKNOWN as libc::c_int, CredentialsUnavailable => PAM_CRED_UNAVAIL as libc::c_int, CredentialsExpired => PAM_CRED_EXPIRED as libc::c_int, CredentialsError => PAM_CRED_ERR as libc::c_int, AccountExpired => PAM_ACCT_EXPIRED as libc::c_int, AuthTokenExpired => PAM_AUTHTOK_EXPIRED as libc::c_int, SessionError => PAM_SESSION_ERR as libc::c_int, AuthTokenError => PAM_AUTHTOK_ERR as libc::c_int, AuthTokenRecoveryError => PAM_AUTHTOK_RECOVERY_ERR as libc::c_int, AuthTokenLockBusy => PAM_AUTHTOK_LOCK_BUSY as libc::c_int, AuthTokenDisableAging => PAM_AUTHTOK_DISABLE_AGING as libc::c_int, NoModuleData => PAM_NO_MODULE_DATA as libc::c_int, Ignore => PAM_IGNORE as libc::c_int, Abort => PAM_ABORT as libc::c_int, TryAgain => PAM_TRY_AGAIN as libc::c_int, ModuleUnknown => PAM_MODULE_UNKNOWN as libc::c_int, BadItem => PAM_BAD_ITEM as libc::c_int, // DomainUnknown => PAM_DOMAIN_UNKNOWN as libc::c_int, // BadHandle => PAM_BAD_HANDLE as libc::c_int, // BadFeature => PAM_BAD_FEATURE as libc::c_int, // BadConstant => PAM_BAD_CONSTANT as libc::c_int, // ConverseAgain => PAM_CONV_AGAIN as libc::c_int, // Incomplete => PAM_INCOMPLETE as libc::c_int, UnknownErrorType(e) => *e, } } fn get_err_msg(&self) -> String { // SAFETY: pam_strerror technically takes a pam handle as the first argument, // but we do not know of any implementation that actually uses the pamh // argument. See also the netbsd man page for `pam_strerror`. let data = unsafe { pam_strerror(std::ptr::null_mut(), self.as_int()) }; if data.is_null() { String::from("Error unresolved by PAM") } else { // SAFETY: pam_strerror returns a pointer to a null-terminated string unsafe { string_from_ptr(data) } } } } #[derive(Debug)] pub enum PamError { UnexpectedNulByte(NulError), Utf8Error(Utf8Error), Pam(PamErrorType), IoError(std::io::Error), EnvListFailure, InteractionRequired, TimedOut, InvalidUser(String, String), } impl From for PamError { fn from(err: std::io::Error) -> Self { PamError::IoError(err) } } impl From for PamError { fn from(err: NulError) -> Self { PamError::UnexpectedNulByte(err) } } impl From for PamError { fn from(err: Utf8Error) -> Self { PamError::Utf8Error(err) } } impl fmt::Display for PamError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { PamError::UnexpectedNulByte(_) => write!(f, "Unexpected nul byte in input"), PamError::Utf8Error(_) => write!(f, "Could not read input data as UTF-8 string"), PamError::Pam(PamErrorType::AuthError) => { write!(f, "Account validation failure, is your account locked?") } PamError::Pam(PamErrorType::NewAuthTokenRequired) => { write!( f, "Account or password is expired, reset your password and try again" ) } PamError::Pam(PamErrorType::AuthTokenExpired) => { write!(f, "Password expired, contact your system administrator") } PamError::Pam(tp) => write!(f, "PAM error: {}", tp.get_err_msg()), PamError::IoError(e) => write!(f, "IO error: {e}"), PamError::EnvListFailure => { write!( f, "It was not possible to get a list of environment variables" ) } PamError::InteractionRequired => write!(f, "Interaction is required"), PamError::TimedOut => write!(f, "timed out"), PamError::InvalidUser(username, other_user) => { write!( f, "Sorry, user {username} is not allowed to authenticate as {other_user}.", ) } } } } impl PamError { /// Create a new PamError based on the error number from pam. pub(super) fn from_pam(errno: libc::c_int) -> PamError { let tp = PamErrorType::from_int(errno); PamError::Pam(tp) } } /// Returns `Ok(())` if the error code is `PAM_SUCCESS` or a `PamError` in other cases pub(super) fn pam_err(err: libc::c_int) -> Result<(), PamError> { if err == PAM_SUCCESS as libc::c_int { Ok(()) } else { Err(PamError::from_pam(err)) } } #[cfg(test)] mod test { use super::PamErrorType; #[test] fn isomorphy() { for i in -100..100 { let pam = PamErrorType::from_int(i); assert_eq!(pam.as_int(), i); assert_eq!(PamErrorType::from_int(pam.as_int()), pam); } } } sudo-rs-0.2.10/src/pam/mod.rs000064400000000000000000000356341046102023000140040ustar 00000000000000use std::{ ffi::{CStr, CString, OsStr, OsString}, io, os::raw::c_char, os::unix::prelude::OsStrExt, ptr::NonNull, }; use crate::system::{ signal::{self, SignalSet}, time::Duration, }; use converse::ConverserData; use error::pam_err; pub use error::{PamError, PamErrorType, PamResult}; use sys::*; mod converse; mod error; mod rpassword; mod securemem; #[cfg_attr(target_os = "linux", path = "sys_linuxpam.rs")] #[cfg_attr(target_os = "freebsd", path = "sys_openpam.rs")] #[allow(nonstandard_style)] pub mod sys; #[cfg(target_os = "freebsd")] const PAM_DATA_SILENT: std::ffi::c_int = 0; pub use converse::CLIConverser; pub struct PamContext { data_ptr: *mut ConverserData, pamh: *mut pam_handle_t, silent: bool, allow_null_auth_token: bool, last_pam_status: Option, session_started: bool, } impl PamContext { /// Build the PamContext with the CLI conversation function. /// /// The target user is optional and may also be set after the context was /// constructed or not set at all in which case PAM will ask for a /// username. /// /// This function will error when initialization of the PAM session somehow failed. #[allow(clippy::too_many_arguments)] pub fn new_cli( converser_name: &str, service_name: &str, use_stdin: bool, bell: bool, no_interact: bool, password_feedback: bool, password_timeout: Option, target_user: Option<&str>, ) -> PamResult { let converser = CLIConverser { bell, name: converser_name.to_owned(), use_stdin, password_feedback, password_timeout, }; let c_service_name = CString::new(service_name)?; let c_user = target_user.map(CString::new).transpose()?; let c_user_ptr = match c_user { Some(ref c) => c.as_ptr(), None => std::ptr::null(), }; // this will be de-allocated explicitly in this type's drop method let data_ptr = Box::into_raw(Box::new(ConverserData { converser, converser_name: converser_name.to_owned(), no_interact, auth_prompt: Some("authenticate".to_owned()), timed_out: false, panicked: false, })); let mut pamh = std::ptr::null_mut(); // SAFETY: we are passing the required fields to `pam_start`; in particular, the value // of `pamh` set above is not used, but will be overwritten by `pam_start`. let res = unsafe { pam_start( c_service_name.as_ptr(), c_user_ptr, &pam_conv { conv: Some(converse::converse::), appdata_ptr: data_ptr as *mut libc::c_void, }, &mut pamh, ) }; pam_err(res)?; assert!(!pamh.is_null()); Ok(PamContext { data_ptr, pamh, silent: false, allow_null_auth_token: true, last_pam_status: None, session_started: false, }) } pub fn set_auth_prompt(&mut self, prompt: Option) { // SAFETY: self.data_ptr was created by Box::into_raw unsafe { (*self.data_ptr).auth_prompt = prompt; } } /// Set whether output of pam calls should be silent or not, by default /// PAM calls are not silent. pub fn mark_silent(&mut self, silent: bool) { self.silent = silent; } /// Set whether or not to allow empty authentication tokens, by default such /// tokens are allowed. pub fn mark_allow_null_auth_token(&mut self, allow: bool) { self.allow_null_auth_token = allow; } /// Get the PAM flag value for the silent flag fn silent_flag(&self) -> i32 { if self.silent { PAM_SILENT as _ } else { 0 } } /// Get the PAM flag value for the disallow_null_authtok flag fn disallow_null_auth_token_flag(&self) -> i32 { if self.allow_null_auth_token { 0 } else { PAM_DISALLOW_NULL_AUTHTOK as _ } } /// Run authentication for the account pub fn authenticate(&mut self, for_user: &str) -> PamResult<()> { let mut flags = 0; flags |= self.silent_flag(); flags |= self.disallow_null_auth_token_flag(); // Temporarily mask SIGINT and SIGQUIT. let cur_signals = SignalSet::empty().and_then(|mut set| { set.add(signal::consts::SIGINT)?; set.add(signal::consts::SIGQUIT)?; set.block() }); // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`) let auth_res = pam_err(unsafe { pam_authenticate(self.pamh, flags) }); // Restore signals if let Ok(set) = cur_signals { set.set_mask().map_err(PamError::IoError)?; } if self.has_panicked() { panic!("Panic during pam authentication"); } // SAFETY: self.data_ptr was created by Box::into_raw if unsafe { (*self.data_ptr).timed_out } { return Err(PamError::TimedOut); } #[allow(clippy::question_mark)] if let Err(err) = auth_res { return Err(err); } // Check that no PAM module changed the user. match self.get_user() { Ok(pam_user) => { if pam_user != for_user { return Err(PamError::InvalidUser(pam_user, for_user.to_string())); } } Err(e) => { return Err(e); } } Ok(()) } /// Check that the account is valid pub fn validate_account(&mut self) -> PamResult<()> { let mut flags = 0; flags |= self.silent_flag(); flags |= self.disallow_null_auth_token_flag(); // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`) pam_err(unsafe { pam_acct_mgmt(self.pamh, flags) }) } /// Attempt to validate the account, if that fails because the authentication /// token is outdated, then an update of the authentication token is requested. pub fn validate_account_or_change_auth_token(&mut self) -> PamResult<()> { let check_val = self.validate_account(); match check_val { Ok(()) => Ok(()), Err(PamError::Pam(PamErrorType::NewAuthTokenRequired)) => { self.change_auth_token(true)?; Ok(()) } Err(e) => Err(e), } } /// Set the user that will be authenticated. pub fn set_user(&mut self, user: &str) -> PamResult<()> { let c_user = CString::new(user)?; // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`); furthermore, // `c_user.as_ptr()` will point to a correct null-terminated string. pam_err(unsafe { pam_set_item( self.pamh, PAM_USER as _, c_user.as_ptr() as *const libc::c_void, ) }) } /// Get the user that is currently active in the PAM handle pub fn get_user(&mut self) -> PamResult { let mut data = std::ptr::null(); // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`) pam_err(unsafe { pam_get_item(self.pamh, PAM_USER as _, &mut data) })?; // safety check to make sure that we were not passed a null pointer by PAM, // or that in fact PAM did not write to `data` at all. if data.is_null() { return Err(PamError::IoError(io::Error::new( io::ErrorKind::InvalidData, "PAM didn't return username", ))); } // SAFETY: the contract for `pam_get_item` ensures that if `data` was touched by // `pam_get_item`, it will point to a valid null-terminated string. let cstr = unsafe { CStr::from_ptr(data as *const c_char) }; Ok(cstr.to_str()?.to_owned()) } /// Set the TTY path for the current TTY that this PAM session started from. pub fn set_tty>(&mut self, tty_path: P) -> PamResult<()> { let data = CString::new(tty_path.as_ref().as_bytes())?; // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`); furthermore, // `data.as_ptr()` will point to a correct null-terminated string. pam_err(unsafe { pam_set_item( self.pamh, PAM_TTY as _, data.as_ptr() as *const libc::c_void, ) }) } // Set the user that requested the actions in this PAM instance. pub fn set_requesting_user(&mut self, user: &str) -> PamResult<()> { let data = CString::new(user.as_bytes())?; // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`); furthermore, // `data.as_ptr()` will point to a correct null-terminated string. pam_err(unsafe { pam_set_item( self.pamh, PAM_RUSER as _, data.as_ptr() as *const libc::c_void, ) }) } /// Re-initialize the credentials stored in PAM pub fn credentials_reinitialize(&mut self) -> PamResult<()> { self.credentials(PAM_REINITIALIZE_CRED as libc::c_int) } /// Updates to the credentials stored in PAM fn credentials(&mut self, action: libc::c_int) -> PamResult<()> { let mut flags = action; flags |= self.silent_flag(); // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`). pam_err(unsafe { pam_setcred(self.pamh, flags) }) } /// Ask the user to change the authentication token (password). /// /// If `expired_only` is set to true, only expired authentication tokens /// will be asked to be replaced, otherwise a replacement will always be /// requested. pub fn change_auth_token(&mut self, expired_only: bool) -> PamResult<()> { let mut flags = 0; flags |= self.silent_flag(); if expired_only { flags |= PAM_CHANGE_EXPIRED_AUTHTOK as libc::c_int; } // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`). pam_err(unsafe { pam_chauthtok(self.pamh, flags) }) } /// Start a user session for the authenticated user. pub fn open_session(&mut self) -> PamResult<()> { assert!(!self.session_started); // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`). pam_err(unsafe { pam_open_session(self.pamh, self.silent_flag()) })?; self.session_started = true; Ok(()) } /// End the user session. pub fn close_session(&mut self) { // closing the pam session is best effort, if any error occurs we cannot // do anything with it if self.session_started { // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`). let _ = pam_err(unsafe { pam_close_session(self.pamh, self.silent_flag()) }); self.session_started = false; } } /// Get a full listing of the current PAM environment pub fn env(&mut self) -> PamResult> { let mut res = Vec::new(); // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`). // The man page for pam_getenvlist states that: // The format of the memory is a malloc()'d array of char pointers, the last element // of which is set to NULL. Each of the non-NULL entries in this array point to a // NUL terminated and malloc()'d char string of the form: "name=value". // // The pam_getenvlist function returns NULL on failure. let envs = unsafe { pam_getenvlist(self.pamh) }; if envs.is_null() { return Err(PamError::EnvListFailure); } let mut curr_env = envs; // SAFETY: the loop invariant is as follows: // - `curr_env` itself is always a valid pointer to an array of valid (possibly NULL) pointers // - if `curr_env` points to a pointer that is not-null, that data is a c-string allocated by malloc() // - `curr_env` points to NULL if and only if it is the final element in the array while let Some(curr_str) = NonNull::new(unsafe { curr_env.read() }) { let data = { // SAFETY: `curr_str` points to a valid null-terminated string per the above let cstr = unsafe { CStr::from_ptr(curr_str.as_ptr()) }; let bytes = cstr.to_bytes(); if let Some(pos) = bytes.iter().position(|b| *b == b'=') { let key = OsStr::from_bytes(&bytes[..pos]).to_owned(); let value = OsStr::from_bytes(&bytes[pos + 1..]).to_owned(); Some((key, value)) } else { None } }; if let Some((k, v)) = data { res.push((k, v)); } // SAFETY: curr_str was obtained via libc::malloc() so we are responsible for freeing it. // At this point, curr_str is also the only remaining pointer/reference to that allocated data // (the data was copied above), so it can be deallocated without risk of use-after-free errors. unsafe { libc::free(curr_str.as_ptr().cast()) }; // SAFETY: curr_env was not NULL, so it was not the last element in the list and so PAM // ensures that the next offset also is a valid pointer, and points to valid data. curr_env = unsafe { curr_env.offset(1) }; } // SAFETY: `envs` itself was obtained by malloc(), so we are responsible for freeing it. unsafe { libc::free(envs.cast()) }; Ok(res) } /// Check if anything panicked since the last call. pub fn has_panicked(&self) -> bool { // SAFETY: self.data_ptr was created by Box::into_raw unsafe { (*self.data_ptr).panicked } } } impl Drop for PamContext { fn drop(&mut self) { // data_ptr's pointee is de-allocated in this scope // SAFETY: self.data_ptr was created by Box::into_raw let _data = unsafe { Box::from_raw(self.data_ptr) }; self.close_session(); // It looks like PAM_DATA_SILENT is important to set for our sudo context, but // it is unclear what it really does and does not do, other than the vague // documentation description to 'not take the call to seriously' // Also see https://github.com/systemd/systemd/issues/22318 // SAFETY: `self.pamh` contains a correct handle (obtained from `pam_start`) unsafe { pam_end( self.pamh, self.last_pam_status.unwrap_or(PAM_SUCCESS as libc::c_int) | PAM_DATA_SILENT as libc::c_int, ) }; } } sudo-rs-0.2.10/src/pam/rpassword.rs000064400000000000000000000270661046102023000152510ustar 00000000000000/// Parts of the code below are Copyright (c) 2023, Conrad Kleinespel et al /// /// This module contains code that was originally written by Conrad Kleinespel for the rpassword /// crate. No copyright notices were found in the original code. /// /// See: https://docs.rs/rpassword/latest/rpassword/ /// /// Most code was replaced and so is no longer a derived work; work that we kept: /// /// - the "HiddenInput" struct and implementation, with changes: /// * replaced occurrences of explicit 'i32' and 'c_int' with RawFd /// * open the TTY ourselves to mitigate Linux CVE-2023-2002 /// - the general idea of a "SafeString" type that clears its memory /// (although much more robust than in the original code) /// use std::io::{self, Error, ErrorKind, Read}; use std::os::fd::{AsFd, AsRawFd, BorrowedFd}; use std::time::Instant; use std::{fs, mem}; use libc::{tcsetattr, termios, ECHO, ECHONL, ICANON, TCSANOW, VEOF, VERASE, VKILL}; use crate::cutils::cerr; use crate::system::time::Duration; use super::securemem::PamBuffer; struct HiddenInput { tty: fs::File, term_orig: termios, } impl HiddenInput { fn new() -> io::Result> { // control ourselves that we are really talking to a TTY // mitigates: https://marc.info/?l=oss-security&m=168164424404224 let Ok(tty) = fs::File::open("/dev/tty") else { // if we have nothing to show, we have nothing to hide return Ok(None); }; // Make two copies of the terminal settings. The first one will be modified // and the second one will act as a backup for when we want to set the // terminal back to its original state. let mut term = safe_tcgetattr(&tty)?; let term_orig = safe_tcgetattr(&tty)?; // Hide the password. This is what makes this function useful. term.c_lflag &= !ECHO; // But don't hide the NL character when the user hits ENTER. term.c_lflag |= ECHONL; // Disable canonical mode to read character by character when pwfeedback is enabled. term.c_lflag &= !ICANON; // Save the settings for now. // SAFETY: we are passing tcsetattr a valid file descriptor and pointer-to-struct cerr(unsafe { tcsetattr(tty.as_raw_fd(), TCSANOW, &term) })?; Ok(Some(HiddenInput { tty, term_orig })) } } impl Drop for HiddenInput { fn drop(&mut self) { // Set the the mode back to normal // SAFETY: we are passing tcsetattr a valid file descriptor and pointer-to-struct unsafe { tcsetattr(self.tty.as_raw_fd(), TCSANOW, &self.term_orig); } } } fn safe_tcgetattr(tty: impl AsFd) -> io::Result { let mut term = mem::MaybeUninit::::uninit(); // SAFETY: we are passing tcgetattr a pointer to valid memory cerr(unsafe { ::libc::tcgetattr(tty.as_fd().as_raw_fd(), term.as_mut_ptr()) })?; // SAFETY: if the previous call was a success, `tcgetattr` has initialized `term` Ok(unsafe { term.assume_init() }) } fn erase_feedback(sink: &mut dyn io::Write, i: usize) { const BACKSPACE: u8 = 0x08; for _ in 0..i { if sink.write(&[BACKSPACE, b' ', BACKSPACE]).is_err() { return; } } } enum Hidden<'a> { No, Yes(&'a HiddenInput), WithFeedback(&'a HiddenInput), } /// Reads a password from the given file descriptor while optionally showing feedback to the user. fn read_unbuffered( source: &mut dyn io::Read, sink: &mut dyn io::Write, hide_input: Hidden<'_>, ) -> io::Result { struct ExitGuard<'a> { pw_len: usize, feedback: bool, sink: &'a mut dyn io::Write, } // Ensure we erase the password feedback no matter how we exit read_unbuffered impl Drop for ExitGuard<'_> { fn drop(&mut self) { if self.feedback { erase_feedback(self.sink, self.pw_len); } let _ = self.sink.write(b"\n"); } } let mut password = PamBuffer::default(); let mut state = ExitGuard { pw_len: 0, feedback: matches!(hide_input, Hidden::WithFeedback(_)), sink, }; // invariant: the amount of nonzero-bytes in the buffer correspond // with the amount of asterisks on the terminal (both tracked in `pw_len`) //TODO: we actually only want to allow clippy::unbuffered_bytes #[allow(clippy::perf)] for read_byte in source.bytes() { let read_byte = read_byte?; if read_byte == b'\n' || read_byte == b'\r' { break; } if let Hidden::Yes(input) | Hidden::WithFeedback(input) = hide_input { if read_byte == input.term_orig.c_cc[VEOF] { password.fill(0); break; } if read_byte == input.term_orig.c_cc[VERASE] { if state.pw_len > 0 { if let Hidden::WithFeedback(_) = hide_input { erase_feedback(state.sink, 1); } password[state.pw_len - 1] = 0; state.pw_len -= 1; } continue; } if read_byte == input.term_orig.c_cc[VKILL] { if let Hidden::WithFeedback(_) = hide_input { erase_feedback(state.sink, state.pw_len); } password.fill(0); state.pw_len = 0; continue; } } if let Some(dest) = password.get_mut(state.pw_len) { *dest = read_byte; state.pw_len += 1; if let Hidden::WithFeedback(_) = hide_input { let _ = state.sink.write(b"*"); } } else { return Err(Error::new( ErrorKind::OutOfMemory, "incorrect password attempt", )); } } Ok(password) } /// Write something and immediately flush fn write_unbuffered(sink: &mut dyn io::Write, text: &[u8]) -> io::Result<()> { sink.write_all(text)?; sink.flush() } struct TimeoutRead<'a> { timeout_at: Option, fd: BorrowedFd<'a>, } impl<'a> TimeoutRead<'a> { fn new(fd: BorrowedFd<'a>, timeout: Option) -> TimeoutRead<'a> { TimeoutRead { timeout_at: timeout.map(|timeout| Instant::now() + timeout.into()), fd, } } } impl io::Read for TimeoutRead<'_> { fn read(&mut self, buf: &mut [u8]) -> io::Result { let pollmask = libc::POLLIN | libc::POLLRDHUP; let mut pollfd = [libc::pollfd { fd: self.fd.as_raw_fd(), events: pollmask, revents: 0, }; 1]; let timeout = match self.timeout_at { Some(timeout_at) => { let now = Instant::now(); if now > timeout_at { return Err(io::Error::from(ErrorKind::TimedOut)); } (timeout_at - now) .as_millis() .try_into() .unwrap_or(i32::MAX) } None => -1, }; // SAFETY: pollfd is initialized and its length matches cerr(unsafe { libc::poll( pollfd.as_mut_ptr(), pollfd.len().try_into().unwrap(), timeout, ) })?; // There may yet be data waiting to be read even if POLLHUP is set. if pollfd[0].revents & (pollmask | libc::POLLHUP) > 0 { // SAFETY: buf is initialized and its length matches let ret = cerr(unsafe { libc::read( self.fd.as_raw_fd(), buf.as_mut_ptr() as *mut libc::c_void, buf.len(), ) })?; Ok(ret as usize) } else { Err(io::Error::from(io::ErrorKind::TimedOut)) } } } /// A data structure representing either /dev/tty or /dev/stdin+stderr pub enum Terminal<'a> { Tty(fs::File), StdIE(io::StdinLock<'a>, io::StderrLock<'a>), } impl Terminal<'_> { /// Open the current TTY for user communication pub fn open_tty() -> io::Result { Ok(Terminal::Tty( fs::OpenOptions::new() .read(true) .write(true) .open("/dev/tty")?, )) } /// Open standard input and standard error for user communication pub fn open_stdie() -> io::Result { Ok(Terminal::StdIE(io::stdin().lock(), io::stderr().lock())) } /// Reads input with TTY echo disabled pub fn read_password(&mut self, timeout: Option) -> io::Result { let hide_input = HiddenInput::new()?; self.read_inner( timeout, hide_input.as_ref().map(Hidden::Yes).unwrap_or(Hidden::No), ) } /// Reads input with TTY echo disabled, but do provide visual feedback while typing. pub fn read_password_with_feedback( &mut self, timeout: Option, ) -> io::Result { let hide_input = HiddenInput::new()?; self.read_inner( timeout, hide_input .as_ref() .map(Hidden::WithFeedback) .unwrap_or(Hidden::No), ) } /// Reads input with TTY echo enabled pub fn read_cleartext(&mut self) -> io::Result { self.read_inner(None, Hidden::No) } fn read_inner( &mut self, timeout: Option, hide_input: Hidden<'_>, ) -> io::Result { match self { Terminal::StdIE(stdin, stdout) => { let mut reader = TimeoutRead::new(stdin.as_fd(), timeout); read_unbuffered(&mut reader, stdout, hide_input) } Terminal::Tty(file) => { let mut reader = TimeoutRead::new(file.as_fd(), timeout); read_unbuffered(&mut reader, &mut &*file, hide_input) } } } /// Display information pub fn prompt(&mut self, text: &str) -> io::Result<()> { write_unbuffered(self.sink(), text.as_bytes()) } /// Ring the bell pub fn bell(&mut self) -> io::Result<()> { const BELL: &[u8; 1] = b"\x07"; write_unbuffered(self.sink(), BELL) } // boilerplate reduction functions fn sink(&mut self) -> &mut dyn io::Write { match self { Terminal::StdIE(_, x) => x, Terminal::Tty(x) => x, } } } #[cfg(test)] mod test { use super::*; #[test] fn miri_test_read() { let mut data = "password123\nhello world".as_bytes(); let mut stdout = Vec::new(); let buf = read_unbuffered(&mut data, &mut stdout, Hidden::No).unwrap(); // check that the \n is not part of input assert_eq!( buf.iter() .map(|&b| b as char) .take_while(|&x| x != '\0') .collect::(), "password123" ); // check that the \n is also consumed but the rest of the input is still there assert_eq!(std::str::from_utf8(data).unwrap(), "hello world"); } #[test] fn miri_test_longpwd() { let mut stdout = Vec::new(); assert!(read_unbuffered(&mut "a".repeat(511).as_bytes(), &mut stdout, Hidden::No).is_ok()); assert!(read_unbuffered(&mut "a".repeat(512).as_bytes(), &mut stdout, Hidden::No).is_err()); } #[test] fn miri_test_write() { let mut data = Vec::new(); write_unbuffered(&mut data, b"prompt").unwrap(); assert_eq!(std::str::from_utf8(&data).unwrap(), "prompt"); } } sudo-rs-0.2.10/src/pam/securemem.rs000064400000000000000000000076551046102023000152140ustar 00000000000000//! Routines for "secure" memory operations; i.e. data that we need to send to Linux-PAM and don't //! want any copies to leak (that we would then need to zeroize). use std::{ alloc::{self, Layout}, ptr::NonNull, slice, }; const SIZE: usize = super::sys::PAM_MAX_RESP_SIZE as usize; pub struct PamBuffer(NonNull<[u8; SIZE]>); const LAYOUT: Layout = match Layout::from_size_align(SIZE, 1) { Ok(layout) => layout, Err(_) => unreachable!(), }; impl PamBuffer { // consume this buffer and return its internal pointer // (ending the type-level security, but guaranteeing you need unsafe code to access the data) pub fn leak(self) -> NonNull { let result = self.0; std::mem::forget(self); result.cast() } // initialize the buffer with already existing data (otherwise populating it is a bit hairy) // this is inferior than placing the data into the securebuffer directly #[cfg(test)] pub fn new(mut src: impl AsMut<[u8]>) -> Self { let mut buffer = PamBuffer::default(); let src = src.as_mut(); buffer[..src.len()].copy_from_slice(src); wipe_memory(src); buffer } } impl Default for PamBuffer { fn default() -> Self { // SAFETY: `calloc` returns either a cleared, allocated chunk of `SIZE` bytes // or NULL to indicate that the allocation request failed let res = unsafe { libc::calloc(1, SIZE) }; if let Some(nn) = NonNull::new(res) { PamBuffer(nn.cast()) } else { alloc::handle_alloc_error(LAYOUT) } } } impl std::ops::Deref for PamBuffer { type Target = [u8]; fn deref(&self) -> &[u8] { // SAFETY: `self.0.as_ptr()` is non-null, aligned, and initialized, and points to `SIZE` bytes. // The lifetime of the slice does not exceed that of `self`. // // We make the slice one less in size to guarantee the existence of a terminating NUL. unsafe { slice::from_raw_parts(self.0.as_ptr().cast(), SIZE - 1) } } } impl std::ops::DerefMut for PamBuffer { fn deref_mut(&mut self) -> &mut [u8] { // SAFETY: see above unsafe { slice::from_raw_parts_mut(self.0.as_ptr().cast(), SIZE - 1) } } } impl Drop for PamBuffer { fn drop(&mut self) { // SAFETY: same as for `deref()` and `deref_mut()` wipe_memory(unsafe { self.0.as_mut() }); // SAFETY: `self.0.as_ptr()` was obtained via `calloc`, so calling `free` is proper. unsafe { libc::free(self.0.as_ptr().cast()) } } } /// Used to zero out memory and protect sensitive data from leaking; inspired by Conrad Kleinespel's /// Rustatic rtoolbox::SafeString, fn wipe_memory(memory: &mut [u8]) { use std::sync::atomic; let nonsense: u8 = 0x55; for c in memory { // SAFETY: `c` is safe for writes (it comes from a &mut reference) unsafe { std::ptr::write_volatile(c, nonsense) }; } atomic::fence(atomic::Ordering::SeqCst); atomic::compiler_fence(atomic::Ordering::SeqCst); } #[allow(clippy::undocumented_unsafe_blocks)] #[cfg(test)] mod test { use super::PamBuffer; #[test] fn miri_test_leaky_cstring() { let test = |text: &str| unsafe { let buf = PamBuffer::new(text.to_string().as_bytes_mut()); assert_eq!(&buf[..text.len()], text.as_bytes()); let nn = buf.leak(); let result = crate::cutils::string_from_ptr(nn.as_ptr().cast()); libc::free(nn.as_ptr().cast()); result }; assert_eq!(test(""), ""); assert_eq!(test("hello"), "hello"); } #[test] fn miri_test_wipe() { let mut memory: [u8; 3] = [1, 2, 3]; let fix = PamBuffer::new(&mut memory); assert_eq!(memory, [0x55, 0x55, 0x55]); assert_eq!(fix[0..=2], [1, 2, 3]); assert!(fix[3..].iter().all(|&x| x == 0)); std::mem::drop(fix); } } sudo-rs-0.2.10/src/pam/sys_linuxpam.rs000064400000000000000000000073471046102023000157600ustar 00000000000000/* automatically generated by rust-bindgen 0.70.1, minified by cargo-minify */ pub type pam_handle_t = u8; #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct pam_message { pub msg_style: libc::c_int, pub msg: *const libc::c_char, } #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct pam_response { pub resp: *mut libc::c_char, pub resp_retcode: libc::c_int, } #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct pam_conv { pub conv: ::std::option::Option< unsafe extern "C" fn( num_msg: libc::c_int, msg: *mut *const pam_message, resp: *mut *mut pam_response, appdata_ptr: *mut libc::c_void, ) -> libc::c_int, >, pub appdata_ptr: *mut libc::c_void, } pub const PAM_SUCCESS: u32 = 0; pub const PAM_OPEN_ERR: u32 = 1; pub const PAM_SYMBOL_ERR: u32 = 2; pub const PAM_SERVICE_ERR: u32 = 3; pub const PAM_SYSTEM_ERR: u32 = 4; pub const PAM_BUF_ERR: u32 = 5; pub const PAM_PERM_DENIED: u32 = 6; pub const PAM_AUTH_ERR: u32 = 7; pub const PAM_CRED_INSUFFICIENT: u32 = 8; pub const PAM_AUTHINFO_UNAVAIL: u32 = 9; pub const PAM_USER_UNKNOWN: u32 = 10; pub const PAM_MAXTRIES: u32 = 11; pub const PAM_NEW_AUTHTOK_REQD: u32 = 12; pub const PAM_ACCT_EXPIRED: u32 = 13; pub const PAM_SESSION_ERR: u32 = 14; pub const PAM_CRED_UNAVAIL: u32 = 15; pub const PAM_CRED_EXPIRED: u32 = 16; pub const PAM_CRED_ERR: u32 = 17; pub const PAM_NO_MODULE_DATA: u32 = 18; pub const PAM_CONV_ERR: u32 = 19; pub const PAM_AUTHTOK_ERR: u32 = 20; pub const PAM_AUTHTOK_RECOVERY_ERR: u32 = 21; pub const PAM_AUTHTOK_LOCK_BUSY: u32 = 22; pub const PAM_AUTHTOK_DISABLE_AGING: u32 = 23; pub const PAM_TRY_AGAIN: u32 = 24; pub const PAM_IGNORE: u32 = 25; pub const PAM_ABORT: u32 = 26; pub const PAM_AUTHTOK_EXPIRED: u32 = 27; pub const PAM_MODULE_UNKNOWN: u32 = 28; pub const PAM_BAD_ITEM: u32 = 29; pub const PAM_SILENT: u32 = 32768; pub const PAM_DISALLOW_NULL_AUTHTOK: u32 = 1; pub const PAM_REINITIALIZE_CRED: u32 = 8; pub const PAM_CHANGE_EXPIRED_AUTHTOK: u32 = 32; pub const PAM_USER: u32 = 2; pub const PAM_TTY: u32 = 3; pub const PAM_RUSER: u32 = 8; pub const PAM_DATA_SILENT: u32 = 1073741824; pub const PAM_PROMPT_ECHO_OFF: u32 = 1; pub const PAM_PROMPT_ECHO_ON: u32 = 2; pub const PAM_ERROR_MSG: u32 = 3; pub const PAM_TEXT_INFO: u32 = 4; pub const PAM_MAX_RESP_SIZE: u32 = 512; extern "C" { pub fn pam_set_item( pamh: *mut pam_handle_t, item_type: libc::c_int, item: *const libc::c_void, ) -> libc::c_int; } extern "C" { pub fn pam_get_item( pamh: *const pam_handle_t, item_type: libc::c_int, item: *mut *const libc::c_void, ) -> libc::c_int; } extern "C" { pub fn pam_strerror(pamh: *mut pam_handle_t, errnum: libc::c_int) -> *const libc::c_char; } extern "C" { pub fn pam_getenvlist(pamh: *mut pam_handle_t) -> *mut *mut libc::c_char; } extern "C" { pub fn pam_start( service_name: *const libc::c_char, user: *const libc::c_char, pam_conversation: *const pam_conv, pamh: *mut *mut pam_handle_t, ) -> libc::c_int; } extern "C" { pub fn pam_end(pamh: *mut pam_handle_t, pam_status: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_authenticate(pamh: *mut pam_handle_t, flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_setcred(pamh: *mut pam_handle_t, flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_acct_mgmt(pamh: *mut pam_handle_t, flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_open_session(pamh: *mut pam_handle_t, flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_close_session(pamh: *mut pam_handle_t, flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_chauthtok(pamh: *mut pam_handle_t, flags: libc::c_int) -> libc::c_int; } sudo-rs-0.2.10/src/pam/sys_openpam.rs000064400000000000000000000104341046102023000155510ustar 00000000000000/* automatically generated by rust-bindgen 0.70.1, minified by cargo-minify */ pub type pam_handle_t = u8; pub type _bindgen_ty_1 = libc::c_uint; pub type _bindgen_ty_2 = libc::c_uint; pub type _bindgen_ty_3 = libc::c_int; pub type _bindgen_ty_4 = libc::c_uint; #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct pam_message { pub msg_style: libc::c_int, pub msg: *mut libc::c_char, } #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct pam_response { pub resp: *mut libc::c_char, pub resp_retcode: libc::c_int, } #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct pam_conv { pub conv: ::std::option::Option< unsafe extern "C" fn( arg1: libc::c_int, arg2: *mut *const pam_message, arg3: *mut *mut pam_response, arg4: *mut libc::c_void, ) -> libc::c_int, >, pub appdata_ptr: *mut libc::c_void, } pub const PAM_SUCCESS: _bindgen_ty_1 = 0; pub const PAM_OPEN_ERR: _bindgen_ty_1 = 1; pub const PAM_SYMBOL_ERR: _bindgen_ty_1 = 2; pub const PAM_SERVICE_ERR: _bindgen_ty_1 = 3; pub const PAM_SYSTEM_ERR: _bindgen_ty_1 = 4; pub const PAM_BUF_ERR: _bindgen_ty_1 = 5; pub const PAM_CONV_ERR: _bindgen_ty_1 = 6; pub const PAM_PERM_DENIED: _bindgen_ty_1 = 7; pub const PAM_MAXTRIES: _bindgen_ty_1 = 8; pub const PAM_AUTH_ERR: _bindgen_ty_1 = 9; pub const PAM_NEW_AUTHTOK_REQD: _bindgen_ty_1 = 10; pub const PAM_CRED_INSUFFICIENT: _bindgen_ty_1 = 11; pub const PAM_AUTHINFO_UNAVAIL: _bindgen_ty_1 = 12; pub const PAM_USER_UNKNOWN: _bindgen_ty_1 = 13; pub const PAM_CRED_UNAVAIL: _bindgen_ty_1 = 14; pub const PAM_CRED_EXPIRED: _bindgen_ty_1 = 15; pub const PAM_CRED_ERR: _bindgen_ty_1 = 16; pub const PAM_ACCT_EXPIRED: _bindgen_ty_1 = 17; pub const PAM_AUTHTOK_EXPIRED: _bindgen_ty_1 = 18; pub const PAM_SESSION_ERR: _bindgen_ty_1 = 19; pub const PAM_AUTHTOK_ERR: _bindgen_ty_1 = 20; pub const PAM_AUTHTOK_RECOVERY_ERR: _bindgen_ty_1 = 21; pub const PAM_AUTHTOK_LOCK_BUSY: _bindgen_ty_1 = 22; pub const PAM_AUTHTOK_DISABLE_AGING: _bindgen_ty_1 = 23; pub const PAM_NO_MODULE_DATA: _bindgen_ty_1 = 24; pub const PAM_IGNORE: _bindgen_ty_1 = 25; pub const PAM_ABORT: _bindgen_ty_1 = 26; pub const PAM_TRY_AGAIN: _bindgen_ty_1 = 27; pub const PAM_MODULE_UNKNOWN: _bindgen_ty_1 = 28; pub const PAM_BAD_ITEM: _bindgen_ty_1 = 31; pub const PAM_PROMPT_ECHO_OFF: _bindgen_ty_2 = 1; pub const PAM_PROMPT_ECHO_ON: _bindgen_ty_2 = 2; pub const PAM_ERROR_MSG: _bindgen_ty_2 = 3; pub const PAM_TEXT_INFO: _bindgen_ty_2 = 4; pub const PAM_MAX_RESP_SIZE: _bindgen_ty_2 = 512; pub const PAM_SILENT: _bindgen_ty_3 = -2147483648; pub const PAM_DISALLOW_NULL_AUTHTOK: _bindgen_ty_3 = 1; pub const PAM_REINITIALIZE_CRED: _bindgen_ty_3 = 4; pub const PAM_CHANGE_EXPIRED_AUTHTOK: _bindgen_ty_3 = 4; pub const PAM_USER: _bindgen_ty_4 = 2; pub const PAM_TTY: _bindgen_ty_4 = 3; pub const PAM_RUSER: _bindgen_ty_4 = 8; extern "C" { pub fn pam_acct_mgmt(_pamh: *mut pam_handle_t, _flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_authenticate(_pamh: *mut pam_handle_t, _flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_chauthtok(_pamh: *mut pam_handle_t, _flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_close_session(_pamh: *mut pam_handle_t, _flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_end(_pamh: *mut pam_handle_t, _status: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_get_item( _pamh: *const pam_handle_t, _item_type: libc::c_int, _item: *mut *const libc::c_void, ) -> libc::c_int; } extern "C" { pub fn pam_getenvlist(_pamh: *mut pam_handle_t) -> *mut *mut libc::c_char; } extern "C" { pub fn pam_open_session(_pamh: *mut pam_handle_t, _flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_set_item( _pamh: *mut pam_handle_t, _item_type: libc::c_int, _item: *const libc::c_void, ) -> libc::c_int; } extern "C" { pub fn pam_setcred(_pamh: *mut pam_handle_t, _flags: libc::c_int) -> libc::c_int; } extern "C" { pub fn pam_start( _service: *const libc::c_char, _user: *const libc::c_char, _pam_conv: *const pam_conv, _pamh: *mut *mut pam_handle_t, ) -> libc::c_int; } extern "C" { pub fn pam_strerror( _pamh: *const pam_handle_t, _error_number: libc::c_int, ) -> *const libc::c_char; } sudo-rs-0.2.10/src/pam/wrapper.h000064400000000000000000000000371046102023000144750ustar 00000000000000#include sudo-rs-0.2.10/src/su/cli.rs000064400000000000000000000547271046102023000136520ustar 00000000000000use std::{mem, path::PathBuf}; use crate::common::SudoString; use super::DEFAULT_USER; #[cfg_attr(test, derive(Debug, PartialEq))] pub enum SuAction { Help(SuHelpOptions), Version(SuVersionOptions), Run(SuRunOptions), } impl SuAction { pub fn from_env() -> Result { SuOptions::parse_arguments(std::env::args())?.validate() } #[cfg(test)] pub fn parse_arguments(args: impl IntoIterator) -> Result { SuOptions::parse_arguments(args)?.validate() } #[cfg(test)] #[allow(clippy::result_large_err)] pub fn try_into_run(self) -> Result { if let Self::Run(v) = self { Ok(v) } else { Err(self) } } } #[cfg_attr(test, derive(Debug, PartialEq))] pub struct SuHelpOptions {} impl TryFrom for SuHelpOptions { type Error = String; fn try_from(mut opts: SuOptions) -> Result { let help = mem::take(&mut opts.help); debug_assert!(help); reject_all("--help", opts)?; Ok(Self {}) } } #[cfg_attr(test, derive(Debug, PartialEq))] pub struct SuVersionOptions {} impl TryFrom for SuVersionOptions { type Error = String; fn try_from(mut opts: SuOptions) -> Result { let version = mem::take(&mut opts.version); debug_assert!(version); reject_all("--version", opts)?; Ok(Self {}) } } #[derive(Debug)] #[cfg_attr(test, derive(PartialEq))] pub struct SuRunOptions { // -c pub command: Option, // -g pub group: Vec, // -l pub login: bool, // -p pub preserve_environment: bool, // -s pub shell: Option, // -G pub supp_group: Vec, // -w pub whitelist_environment: Vec, pub user: SudoString, pub arguments: Vec, } #[cfg(test)] impl Default for SuRunOptions { fn default() -> Self { Self { command: None, group: vec![], login: false, preserve_environment: false, shell: None, supp_group: vec![], whitelist_environment: vec![], user: DEFAULT_USER.into(), arguments: vec![], } } } impl TryFrom for SuRunOptions { type Error = String; fn try_from(mut opts: SuOptions) -> Result { let command = mem::take(&mut opts.command); let group = mem::take(&mut opts.group); let login = mem::take(&mut opts.login); let preserve_environment = mem::take(&mut opts.preserve_environment); // always `true`; cannot be disabled via the CLI let _pty = mem::take(&mut opts.pty); let shell = mem::take(&mut opts.shell); let supp_group = mem::take(&mut opts.supp_group); let whitelist_environment = mem::take(&mut opts.whitelist_environment); let mut positional_args = mem::take(&mut opts.positional_args); reject_all("run mode", opts)?; let user = if positional_args.is_empty() { DEFAULT_USER.to_string() } else { positional_args.remove(0) }; let arguments = positional_args; Ok(Self { command, group, login, preserve_environment, shell, supp_group, whitelist_environment, user: SudoString::try_from(user).map_err(|err| err.to_string())?, arguments, }) } } fn reject_all(context: &str, opts: SuOptions) -> Result<(), String> { macro_rules! ensure_options_absent { ($($opt:ident,)*) => { let SuOptions { $($opt),* } = opts; $(if !$opt.is_absent() { let name = concat!("--", stringify!($opt)).replace('_', "-"); return Err(format!("{context} conflicts with {name}")); })* }; } ensure_options_absent! { command, group, help, login, preserve_environment, pty, shell, supp_group, version, whitelist_environment, positional_args, }; if !positional_args.is_absent() { return Err(format!("{context} conflicts with positional argument")); } Ok(()) } trait IsAbsent { fn is_absent(&self) -> bool; } impl IsAbsent for bool { fn is_absent(&self) -> bool { !*self } } impl IsAbsent for Option { fn is_absent(&self) -> bool { self.is_none() } } impl IsAbsent for Vec { fn is_absent(&self) -> bool { self.is_empty() } } #[derive(Debug, Default, PartialEq)] struct SuOptions { // -c command: Option, // -g group: Vec, // -h help: bool, // -l login: bool, // -p preserve_environment: bool, // -P pty: bool, // -s shell: Option, // -G supp_group: Vec, // -V version: bool, // -w whitelist_environment: Vec, positional_args: Vec, } type OptionSetter = fn(&mut SuOptions, Option) -> Result<(), String>; struct SuOption { short: char, long: &'static str, takes_argument: bool, set: OptionSetter, } impl SuOptions { const SU_OPTIONS: &'static [SuOption] = &[ SuOption { short: 'c', long: "command", takes_argument: true, set: |sudo_options, argument| { if argument.is_some() { sudo_options.command = argument; Ok(()) } else { Err("no command provided".into()) } }, }, SuOption { short: 'g', long: "group", takes_argument: true, set: |sudo_options, argument| { if let Some(value) = argument { sudo_options.group.push(SudoString::from_cli_string(value)); Ok(()) } else { Err("no group provided".into()) } }, }, SuOption { short: 'G', long: "supp-group", takes_argument: true, set: |sudo_options, argument| { if let Some(value) = argument { sudo_options .supp_group .push(SudoString::from_cli_string(value)); Ok(()) } else { Err("no supplementary group provided".into()) } }, }, SuOption { short: 'l', long: "login", takes_argument: false, set: |sudo_options, _| { if sudo_options.login { Err(more_than_once("--login")) } else { sudo_options.login = true; Ok(()) } }, }, SuOption { short: 'p', long: "preserve-environment", takes_argument: false, set: |sudo_options, _| { if sudo_options.preserve_environment { Err(more_than_once("--preserve-environment")) } else { sudo_options.preserve_environment = true; Ok(()) } }, }, SuOption { short: 'm', long: "preserve-environment", takes_argument: false, set: |sudo_options, _| { if sudo_options.preserve_environment { Err(more_than_once("--preserve-environment")) } else { sudo_options.preserve_environment = true; Ok(()) } }, }, SuOption { short: 'P', long: "pty", takes_argument: false, set: |sudo_options, _| { if sudo_options.pty { Err(more_than_once("--pty")) } else { sudo_options.pty = true; Ok(()) } }, }, SuOption { short: 's', long: "shell", takes_argument: true, set: |sudo_options, argument| { if let Some(path) = argument { sudo_options.shell = Some(PathBuf::from(path)); Ok(()) } else { Err("no shell provided".into()) } }, }, SuOption { short: 'w', long: "whitelist-environment", takes_argument: true, set: |sudo_options, argument| { if let Some(list) = argument { let values: Vec = list.split(',').map(str::to_string).collect(); sudo_options.whitelist_environment.extend(values); Ok(()) } else { Err("no environment whitelist provided".into()) } }, }, SuOption { short: 'V', long: "version", takes_argument: false, set: |sudo_options, _| { if sudo_options.version { Err(more_than_once("--version")) } else { sudo_options.version = true; Ok(()) } }, }, SuOption { short: 'h', long: "help", takes_argument: false, set: |sudo_options, _| { if sudo_options.help { Err(more_than_once("--help")) } else { sudo_options.help = true; Ok(()) } }, }, ]; /// parse su arguments into SuOptions struct fn parse_arguments(arguments: impl IntoIterator) -> Result { let mut options: SuOptions = SuOptions::default(); let mut arg_iter = arguments.into_iter().skip(1); while let Some(arg) = arg_iter.next() { // - or -l or --login indicates a login shell should be started if arg == "-" { if options.login { return Err(more_than_once("--login")); } else { options.login = true; } } else if arg == "--" { // only positional arguments after this point options.positional_args.extend(arg_iter); break; // if the argument starts with -- it must be a full length option name } else if let Some(unprefixed) = arg.strip_prefix("--") { // parse assignments like '--group=ferris' if let Some((key, value)) = unprefixed.split_once('=') { // lookup the option by name if let Some(option) = Self::SU_OPTIONS.iter().find(|o| o.long == key) { // the value is already present, when the option does not take any arguments this results in an error if option.takes_argument { (option.set)(&mut options, Some(value.to_string()))?; } else { Err(format!("'--{}' does not take any arguments", option.long))?; } } else { Err(format!("unrecognized option '{arg}'"))?; } // lookup the option } else if let Some(option) = Self::SU_OPTIONS.iter().find(|o| o.long == unprefixed) { // try to parse an argument when the option needs an argument if option.takes_argument { let next_arg = arg_iter.next(); (option.set)(&mut options, next_arg)?; } else { (option.set)(&mut options, None)?; } } else { Err(format!("unrecognized option '{arg}'"))?; } } else if let Some(unprefixed) = arg.strip_prefix('-') { // flags can be grouped, so we loop over the the characters let mut chars = unprefixed.chars(); while let Some(curr) = chars.next() { // lookup the option if let Some(option) = Self::SU_OPTIONS.iter().find(|o| o.short == curr) { // try to parse an argument when one is necessary, either the rest of the current flag group or the next argument let rest = chars.as_str(); if option.takes_argument { let next_arg = if rest.is_empty() { arg_iter.next() } else { Some(rest.to_string()) }; (option.set)(&mut options, next_arg)?; // stop looping over flags if the current flag takes an argument break; } else { // parse flag without argument (option.set)(&mut options, None)?; } } else { Err(format!("unrecognized option '{curr}'"))?; } } } else { options.positional_args.push(arg); } } Ok(options) } fn validate(self) -> Result { let action = if self.help { SuAction::Help(self.try_into()?) } else if self.version { SuAction::Version(self.try_into()?) } else { SuAction::Run(self.try_into()?) }; Ok(action) } } fn more_than_once(flag: &str) -> String { format!("argument '{flag}' was provided more than once, but cannot be used multiple times") } #[cfg(test)] mod tests { use std::vec; use super::{SuAction, SuHelpOptions, SuOptions, SuRunOptions, SuVersionOptions}; fn parse(args: &[&str]) -> SuAction { let mut args = args.iter().map(|s| s.to_string()).collect::>(); args.insert(0, "/bin/su".to_string()); SuOptions::parse_arguments(args) .unwrap() .validate() .unwrap() } #[test] fn it_parses_group() { let expected = SuAction::Run(SuRunOptions { group: vec!["ferris".into()], ..<_>::default() }); assert_eq!(expected, parse(&["-g", "ferris"])); assert_eq!(expected, parse(&["-gferris"])); assert_eq!(expected, parse(&["--group", "ferris"])); assert_eq!(expected, parse(&["--group=ferris"])); } #[test] fn it_parses_shell_default() { let result = parse(&["--shell", "/bin/bash"]); assert_eq!( result, SuAction::Run(SuRunOptions { shell: Some("/bin/bash".into()), ..<_>::default() }) ); } #[test] fn it_parses_whitelist() { let result = parse(&["-w", "FOO,BAR"]); assert_eq!( result, SuAction::Run(SuRunOptions { whitelist_environment: vec!["FOO".to_string(), "BAR".to_string()], ..<_>::default() }) ); } #[test] fn it_parses_combined_options() { let expected = SuAction::Run(SuRunOptions { login: true, ..<_>::default() }); assert_eq!(expected, parse(&["-Pl"])); assert_eq!(expected, parse(&["-lP"])); } #[test] fn it_parses_combined_options_and_arguments() { let expected = SuAction::Run(SuRunOptions { login: true, shell: Some("/bin/bash".into()), ..<_>::default() }); assert_eq!(expected, parse(&["-Pls/bin/bash"])); assert_eq!(expected, parse(&["-Pls", "/bin/bash"])); assert_eq!(expected, parse(&["-Pl", "-s/bin/bash"])); assert_eq!(expected, parse(&["-lP", "-s", "/bin/bash"])); assert_eq!(expected, parse(&["-lP", "--shell=/bin/bash"])); assert_eq!(expected, parse(&["-lP", "--shell", "/bin/bash"])); } #[test] fn it_parses_an_user() { let expected = SuAction::Run(SuRunOptions { user: "ferris".into(), ..<_>::default() }); assert_eq!(expected, parse(&["-P", "ferris"])); assert_eq!(expected, parse(&["ferris", "-P"])); } #[test] fn it_parses_arguments() { let expected = SuAction::Run(SuRunOptions { user: "ferris".into(), arguments: vec!["script.sh".to_string()], ..<_>::default() }); assert_eq!(expected, parse(&["-P", "ferris", "script.sh"])); } #[test] fn it_parses_command() { let expected = SuAction::Run(SuRunOptions { command: Some("'echo hi'".to_string()), ..<_>::default() }); assert_eq!(expected, parse(&["-c", "'echo hi'"])); assert_eq!(expected, parse(&["-c'echo hi'"])); assert_eq!(expected, parse(&["--command", "'echo hi'"])); assert_eq!(expected, parse(&["--command='echo hi'"])); let expected = SuAction::Run(SuRunOptions { command: Some("env".to_string()), ..<_>::default() }); assert_eq!(expected, parse(&["-c", "env"])); assert_eq!(expected, parse(&["-cenv"])); assert_eq!(expected, parse(&["--command", "env"])); assert_eq!(expected, parse(&["--command=env"])); } #[test] fn it_parses_supplementary_group() { let expected = SuAction::Run(SuRunOptions { supp_group: vec!["ferris".into()], ..<_>::default() }); assert_eq!(expected, parse(&["-G", "ferris"])); assert_eq!(expected, parse(&["-Gferris"])); assert_eq!(expected, parse(&["--supp-group", "ferris"])); assert_eq!(expected, parse(&["--supp-group=ferris"])); } #[test] fn it_parses_multiple_supplementary_groups() { let expected = SuAction::Run(SuRunOptions { supp_group: vec!["ferris".into(), "krabbetje".into(), "krabbe".into()], ..<_>::default() }); assert_eq!( expected, parse(&["-G", "ferris", "-G", "krabbetje", "--supp-group", "krabbe"]) ); } #[test] fn it_parses_login() { let expected = SuAction::Run(SuRunOptions { login: true, ..<_>::default() }); assert_eq!(expected, parse(&["-"])); assert_eq!(expected, parse(&["-l"])); assert_eq!(expected, parse(&["--login"])); } #[test] fn it_parses_pty() { let expected = SuAction::Run(<_>::default()); assert_eq!(expected, parse(&["-P"])); assert_eq!(expected, parse(&["--pty"])); } #[test] fn it_parses_shell() { let expected = SuAction::Run(SuRunOptions { shell: Some("some-shell".into()), ..<_>::default() }); assert_eq!(expected, parse(&["-s", "some-shell"])); assert_eq!(expected, parse(&["-ssome-shell"])); assert_eq!(expected, parse(&["--shell", "some-shell"])); assert_eq!(expected, parse(&["--shell=some-shell"])); } #[test] fn it_parses_whitelist_environment() { let expected = SuAction::Run(SuRunOptions { whitelist_environment: vec!["FOO".to_string(), "BAR".to_string()], ..<_>::default() }); assert_eq!(expected, parse(&["-w", "FOO,BAR"])); assert_eq!(expected, parse(&["-wFOO,BAR"])); assert_eq!(expected, parse(&["--whitelist-environment", "FOO,BAR"])); assert_eq!(expected, parse(&["--whitelist-environment=FOO,BAR"])); } #[test] fn it_parses_help() { let expected = SuAction::Help(SuHelpOptions {}); assert_eq!(expected, parse(&["-h"])); assert_eq!(expected, parse(&["--help"])); } #[test] fn it_parses_version() { let expected = SuAction::Version(SuVersionOptions {}); assert_eq!(expected, parse(&["-V"])); assert_eq!(expected, parse(&["--version"])); } #[test] fn short_flag_whitespace() { let expected = SuAction::Run(SuRunOptions { group: vec![" ".into()], ..<_>::default() }); assert_eq!(expected, parse(&["-g "])); } #[test] fn short_flag_whitespace_positional_argument() { let expected = SuAction::Run(SuRunOptions { group: vec![" ".into()], user: "ghost".into(), ..<_>::default() }); assert_eq!(expected, parse(&["-g ", "ghost"])); } #[test] fn long_flag_equal_whitespace() { let expected = SuAction::Run(SuRunOptions { group: vec![" ".into()], ..<_>::default() }); assert_eq!(expected, parse(&["--group= "])); } #[test] fn flag_after_positional_argument() { let expected = SuAction::Run(SuRunOptions { arguments: vec![], login: true, user: "ferris".into(), ..<_>::default() }); assert_eq!(expected, parse(&["ferris", "-l"])); } #[test] fn flags_after_dash() { let expected = SuAction::Run(SuRunOptions { command: Some("echo".to_string()), login: true, ..<_>::default() }); assert_eq!(expected, parse(&["-", "-c", "echo"])); } #[test] fn only_positional_args_after_dashdash() { let expected = SuAction::Run(SuRunOptions { user: "ferris".into(), arguments: vec!["-c".to_string(), "echo".to_string()], ..<_>::default() }); assert_eq!(expected, parse(&["--", "ferris", "-c", "echo"])); } #[test] fn repeated_boolean_flag() { let f = |s: &str| s.to_string(); assert!(SuOptions::parse_arguments(["su", "-l", "-l"].map(f)).is_err()); assert!(SuOptions::parse_arguments(["su", "-", "-l"].map(f)).is_err()); assert!(SuOptions::parse_arguments(["su", "--login", "-l"].map(f)).is_err()); assert!(SuOptions::parse_arguments(["su", "-p", "-p"].map(f)).is_err()); assert!(SuOptions::parse_arguments(["su", "-p", "--preserve-environment"].map(f)).is_err()); } } sudo-rs-0.2.10/src/su/context.rs000064400000000000000000000210401046102023000145450ustar 00000000000000use std::{ collections::HashMap, env, ffi::OsString, fs, path::{Path, PathBuf}, }; use crate::common::{error::Error, resolve::CurrentUser}; use crate::exec::{RunOptions, Umask}; use crate::log::user_warn; use crate::system::{Group, Process, User}; use crate::{common::resolve::is_valid_executable, system::interface::UserId}; type Environment = HashMap; use super::cli::SuRunOptions; const VALID_LOGIN_SHELLS_LIST: &str = "/etc/shells"; const FALLBACK_LOGIN_SHELL: &str = "/bin/sh"; const PATH_MAILDIR: &str = env!("PATH_MAILDIR"); const PATH_DEFAULT: &str = env!("SU_PATH_DEFAULT"); const PATH_DEFAULT_ROOT: &str = env!("SU_PATH_DEFAULT_ROOT"); #[derive(Debug)] pub(crate) struct SuContext { command: PathBuf, arguments: Vec, pub(crate) options: SuRunOptions, pub(crate) environment: Environment, pub(crate) user: User, pub(crate) requesting_user: CurrentUser, group: Group, pub(crate) process: Process, } /// check that a shell is not restricted / exists in /etc/shells fn is_restricted(shell: &Path) -> bool { if let Some(pattern) = shell.as_os_str().to_str() { if let Ok(contents) = fs::read_to_string(VALID_LOGIN_SHELLS_LIST) { return !contents.lines().any(|l| l == pattern); } else { return FALLBACK_LOGIN_SHELL != pattern; } } true } impl SuContext { pub(crate) fn from_env(options: SuRunOptions) -> Result { let process = crate::system::Process::new(); // resolve environment, reset if this is a login let mut environment = if options.login { Environment::default() } else { env::vars_os().collect::() }; // Don't reset the environment variables specified in the // comma-separated list when clearing the environment for // --login. The whitelist is ignored for the environment // variables HOME, SHELL, USER, LOGNAME, and PATH. if options.login { if let Some(value) = env::var_os("TERM") { environment.insert("TERM".into(), value); } for name in options.whitelist_environment.iter() { if let Some(value) = env::var_os(name) { environment.insert(name.into(), value); } } } let requesting_user = CurrentUser::resolve()?; // resolve target user let mut user = User::from_name(options.user.as_cstr())? .ok_or_else(|| Error::UserNotFound(options.user.clone().into()))?; // check the current user is root let is_current_root = User::real_uid() == UserId::ROOT; let is_target_root = options.user == "root"; // only root can set a (additional) group if !is_current_root && (!options.supp_group.is_empty() || !options.group.is_empty()) { return Err(Error::Options( "only root can specify alternative groups".to_owned(), )); } // resolve target group let mut group = user.primary_group()?; if !options.supp_group.is_empty() || !options.group.is_empty() { user.groups.clear(); } for group_name in options.group.iter() { let primary_group = Group::from_name(group_name.as_cstr())? .ok_or_else(|| Error::GroupNotFound(group_name.clone().into()))?; // last argument is the primary group group = primary_group.clone(); user.groups.insert(0, primary_group.gid); } // add additional group if current user is root for (index, group_name) in options.supp_group.iter().enumerate() { let supp_group = Group::from_name(group_name.as_cstr())? .ok_or_else(|| Error::GroupNotFound(group_name.clone().into()))?; // set primary group if none was provided if index == 0 && options.group.is_empty() { group = supp_group.clone(); } user.groups.push(supp_group.gid); } // the shell specified with --shell // the shell specified in the environment variable SHELL, if the --preserve-environment option is used // the shell listed in the passwd entry of the target user let user_shell = user.shell.clone(); let mut command = options .shell .as_ref() .cloned() .or_else(|| { if options.preserve_environment && is_current_root { environment.get(&OsString::from("SHELL")).map(|v| v.into()) } else { None } }) .unwrap_or(user_shell.clone()); // If the target user has a restricted shell (i.e. the shell field of // this user's entry in /etc/passwd is not listed in /etc/shells), // then the --shell option or the $SHELL environment variable won't be // taken into account, unless su is called by root. if is_restricted(user_shell.as_path()) && !is_current_root { user_warn!( "using restricted shell {}", user_shell.as_os_str().to_string_lossy() ); command = user_shell; } if !command.exists() { return Err(Error::CommandNotFound(command)); } if !is_valid_executable(&command) { return Err(Error::InvalidCommand(command)); } // pass command to shell let arguments = if let Some(command) = &options.command { vec!["-c".to_owned(), command.to_owned()] } else { options.arguments.clone() }; if options.login { environment.insert( "PATH".into(), if is_target_root { PATH_DEFAULT_ROOT } else { PATH_DEFAULT } .into(), ); } if !options.preserve_environment { // extend environment with fixed variables environment.insert("HOME".into(), user.home.clone().into()); environment.insert("SHELL".into(), command.clone().into()); environment.insert( "MAIL".into(), format!("{PATH_MAILDIR}/{}", user.name).into(), ); if !is_target_root || options.login { environment.insert("USER".into(), options.user.clone().into()); environment.insert("LOGNAME".into(), options.user.clone().into()); } } Ok(SuContext { command, arguments, options, environment, user, requesting_user, group, process, }) } } impl SuContext { pub(crate) fn as_run_options(&self) -> RunOptions<'_> { RunOptions { command: &self.command, arguments: &self.arguments, arg0: None, chdir: None, is_login: self.options.login, user: &self.user, group: &self.group, umask: Umask::Preserve, use_pty: true, noexec: false, } } } #[cfg(test)] mod tests { use std::path::PathBuf; use crate::{ common::Error, su::cli::{SuAction, SuRunOptions}, }; use super::SuContext; fn get_options(args: &[&str]) -> SuRunOptions { let mut args = args.iter().map(|s| s.to_string()).collect::>(); args.insert(0, "/bin/su".to_string()); SuAction::parse_arguments(args) .unwrap() .try_into_run() .unwrap() } #[test] fn su_to_root() { let options = get_options(&["root"]); let context = SuContext::from_env(options).unwrap(); assert_eq!(context.user.name, "root"); } #[test] fn group_as_non_root() { let options = get_options(&["-g", "root"]); let result = SuContext::from_env(options); let expected = Error::Options("only root can specify alternative groups".to_owned()); assert!(result.is_err()); assert_eq!(format!("{}", result.err().unwrap()), format!("{expected}")); } #[test] fn invalid_shell() { let options = get_options(&["-s", "/not/a/shell"]); let result = SuContext::from_env(options); let expected = Error::CommandNotFound(PathBuf::from("/not/a/shell")); assert!(result.is_err()); assert_eq!(format!("{}", result.err().unwrap()), format!("{expected}")); } } sudo-rs-0.2.10/src/su/help.rs000064400000000000000000000017421046102023000140200ustar 00000000000000pub const USAGE_MSG: &str = "Usage: su [options] [-] [ [...]]"; const DESCRIPTOR: &str = "Change the effective user ID and group ID to that of . A mere - implies -l. If is not given, root is assumed."; const HELP_MSG: &str = "Options: -m, -p, --preserve-environment do not reset environment variables -w, --whitelist-environment don't reset specified variables -g, --group specify the primary group -G, --supp-group specify a supplemental group -, -l, --login make the shell a login shell -c, --command pass a single command to the shell with -c -s, --shell run if /etc/shells allows it -P, --pty create a new pseudo-terminal -h, --help display this help -V, --version display version "; pub fn long_help_message() -> String { format!("{USAGE_MSG}\n\n{DESCRIPTOR}\n\n{HELP_MSG}") } sudo-rs-0.2.10/src/su/mod.rs000064400000000000000000000106621046102023000136500ustar 00000000000000#![forbid(unsafe_code)] use crate::common::error::Error; use crate::exec::ExitReason; use crate::log::user_warn; use crate::pam::{PamContext, PamError, PamErrorType}; use crate::system::term::current_tty_name; use std::{env, process}; use cli::SuAction; use context::SuContext; use help::{long_help_message, USAGE_MSG}; use self::cli::SuRunOptions; mod cli; mod context; mod help; const DEFAULT_USER: &str = "root"; const VERSION: &str = env!("CARGO_PKG_VERSION"); fn authenticate(requesting_user: &str, user: &str, login: bool) -> Result { // FIXME make it configurable by the packager let context = if login && cfg!(target_os = "linux") { "su-l" } else { "su" }; let use_stdin = true; let mut pam = PamContext::new_cli( "su", context, use_stdin, false, false, false, None, Some(user), )?; pam.set_requesting_user(requesting_user)?; // attempt to set the TTY this session is communicating on if let Ok(pam_tty) = current_tty_name() { pam.set_tty(&pam_tty)?; } pam.mark_silent(true); pam.mark_allow_null_auth_token(false); let mut max_tries = 3; let mut current_try = 0; loop { current_try += 1; match pam.authenticate(user) { // there was no error, so authentication succeeded Ok(_) => break, // maxtries was reached, pam does not allow any more tries Err(PamError::Pam(PamErrorType::MaxTries)) => { return Err(Error::MaxAuthAttempts(current_try)); } // there was an authentication error, we can retry Err(PamError::Pam(PamErrorType::AuthError)) => { max_tries -= 1; if max_tries == 0 { return Err(Error::MaxAuthAttempts(current_try)); } else { user_warn!("Authentication failed, try again."); } } // there was another pam error, return the error Err(e) => { return Err(e.into()); } } } pam.validate_account_or_change_auth_token()?; pam.open_session()?; Ok(pam) } fn run(options: SuRunOptions) -> Result<(), Error> { // lookup user and build context object let context = SuContext::from_env(options)?; // authenticate the target user let mut pam: PamContext = authenticate( &context.requesting_user.name, &context.user.name, context.options.login, )?; // su in all cases uses PAM (pam_getenvlist(3)) to do the // final environment modification. Command-line options such as // --login and --preserve-environment affect the environment before // it is modified by PAM. let mut environment = context.environment.clone(); environment.extend(pam.env()?); let pid = context.process.pid; // run command and return corresponding exit code let command_exit_reason = crate::exec::run_command(context.as_run_options(), environment); pam.close_session(); match command_exit_reason? { ExitReason::Code(code) => process::exit(code), ExitReason::Signal(signal) => { crate::system::kill(pid, signal)?; } } Ok(()) } pub fn main() { crate::log::SudoLogger::new("su: ").into_global_logger(); let action = match SuAction::from_env() { Ok(action) => action, Err(error) => { println_ignore_io_error!("su: {error}\n{USAGE_MSG}"); std::process::exit(1); } }; match action { SuAction::Help(_) => { println_ignore_io_error!("{}", long_help_message()); std::process::exit(0); } SuAction::Version(_) => { eprintln_ignore_io_error!("su-rs {VERSION}"); std::process::exit(0); } SuAction::Run(options) => match run(options) { Err(Error::CommandNotFound(c)) => { eprintln_ignore_io_error!("su: {}", Error::CommandNotFound(c)); std::process::exit(127); } Err(Error::InvalidCommand(c)) => { eprintln_ignore_io_error!("su: {}", Error::InvalidCommand(c)); std::process::exit(126); } Err(error) => { eprintln_ignore_io_error!("su: {error}"); std::process::exit(1); } _ => {} }, }; } sudo-rs-0.2.10/src/sudo/cli/help.rs000064400000000000000000000035271046102023000151150ustar 00000000000000pub const USAGE_MSG: &str = "\ usage: sudo -h | -K | -k | -V usage: sudo [-BknS] [-p prompt] [-D directory] [-g group] [-u user] [-i | -s] [command [arg ...]] usage: sudo -v [-BknS] [-p prompt] [-g group] [-u user] usage: sudo -l [-BknS] [-p prompt] [-U user] [-g group] [-u user] [command [arg ...]] usage: sudo -e [-BknS] [-p prompt] [-D directory] [-g group] [-u user] file ..."; const DESCRIPTOR: &str = "sudo - run commands as another user"; const HELP_MSG: &str = "Options: -B, --bell ring bell when prompting -D, --chdir=directory change the working directory before running command -g, --group=group run command as the specified group name or ID -h, --help display help message and exit -i, --login run login shell as the target user; a command may also be specified -K, --remove-timestamp remove timestamp file completely -k, --reset-timestamp invalidate timestamp file -l, --list list user's privileges or check a specific command; use twice for longer format -n, --non-interactive non-interactive mode, no prompts are used -p, --prompt=prompt use the specified password prompt -S, --stdin read password from standard input -s, --shell run shell as the target user; a command may also be specified -U, --other-user=user in list mode, display privileges for user -u, --user=user run command (or edit file) as specified user name or ID -V, --version display version information and exit -v, --validate update user's timestamp without running a command -- stop processing command line arguments"; pub fn long_help_message() -> String { format!("{DESCRIPTOR}\n{USAGE_MSG}\n{HELP_MSG}") } sudo-rs-0.2.10/src/sudo/cli/help_edit.rs000064400000000000000000000020271046102023000161140ustar 00000000000000pub const USAGE_MSG: &str = "\ usage: sudoedit -h | -V usage: sudoedit [-BknS] [-p prompt] [-g group] [-u user] file ..."; const DESCRIPTOR: &str = "sudo - edit files as another user"; const HELP_MSG: &str = "Options: Options: -B, --bell ring bell when prompting -g, --group=group run command as the specified group name or ID -h, --help display help message and exit -k, --reset-timestamp invalidate timestamp file -n, --non-interactive non-interactive mode, no prompts are used -p, --prompt=prompt use the specified password prompt -S, --stdin read password from standard input -u, --user=user run command (or edit file) as specified user name or ID -V, --version display version information and exit -- stop processing command line arguments"; pub fn long_help_message() -> String { format!("{DESCRIPTOR}\n{USAGE_MSG}\n{HELP_MSG}") } sudo-rs-0.2.10/src/sudo/cli/mod.rs000064400000000000000000000621551046102023000147460ustar 00000000000000#![forbid(unsafe_code)] use std::os::unix::ffi::OsStrExt; use std::{borrow::Cow, mem}; use crate::common::{SudoPath, SudoString}; pub mod help; #[cfg_attr(not(feature = "sudoedit"), allow(unused))] pub mod help_edit; #[cfg(test)] mod tests; #[cfg_attr(not(feature = "sudoedit"), allow(dead_code))] pub enum SudoAction { Edit(SudoEditOptions), Help(SudoHelpOptions), List(SudoListOptions), RemoveTimestamp(SudoRemoveTimestampOptions), ResetTimestamp(SudoResetTimestampOptions), Run(SudoRunOptions), Validate(SudoValidateOptions), Version(SudoVersionOptions), } pub(super) fn is_sudoedit(command_path: Option) -> bool { std::path::Path::new(&command_path.unwrap_or_default()) .file_name() .is_some_and(|name| name.as_bytes().starts_with(b"sudoedit")) } impl SudoAction { /// try to parse and environment variable assignment /// parse command line arguments from the environment and handle errors pub fn from_env() -> Result { Self::try_parse_from(std::env::args()) } pub fn try_parse_from(iter: I) -> Result where I: IntoIterator, T: Into + Clone, { let opts = SudoOptions::try_parse_from(iter)?; opts.validate() } #[cfg(test)] #[must_use] pub fn is_edit(&self) -> bool { matches!(self, Self::Edit(..)) } #[cfg(test)] #[must_use] pub fn is_help(&self) -> bool { matches!(self, Self::Help(..)) } #[cfg(test)] #[must_use] pub fn is_remove_timestamp(&self) -> bool { matches!(self, Self::RemoveTimestamp(..)) } #[cfg(test)] #[must_use] pub fn is_reset_timestamp(&self) -> bool { matches!(self, Self::ResetTimestamp(..)) } #[cfg(test)] #[must_use] pub fn is_list(&self) -> bool { matches!(self, Self::List(..)) } #[cfg(test)] #[must_use] pub fn is_version(&self) -> bool { matches!(self, Self::Version(..)) } #[cfg(test)] #[must_use] pub fn is_validate(&self) -> bool { matches!(self, Self::Validate(..)) } #[cfg(test)] #[allow(clippy::result_large_err)] pub fn try_into_run(self) -> Result { if let Self::Run(v) = self { Ok(v) } else { Err(self) } } #[cfg(test)] #[must_use] pub fn is_run(&self) -> bool { matches!(self, Self::Run(..)) } } // sudo -h | -K | -k | -V pub struct SudoHelpOptions {} impl TryFrom for SudoHelpOptions { type Error = String; fn try_from(mut opts: SudoOptions) -> Result { // see `SudoOptions::validate` let help = mem::take(&mut opts.help); debug_assert!(help); Ok(Self {}) } } // sudo -h | -K | -k | -V pub struct SudoVersionOptions {} impl TryFrom for SudoVersionOptions { type Error = String; fn try_from(mut opts: SudoOptions) -> Result { // see `SudoOptions::validate` let version = mem::take(&mut opts.version); debug_assert!(version); Ok(Self {}) } } // sudo -h | -K | -k | -V pub struct SudoRemoveTimestampOptions {} impl TryFrom for SudoRemoveTimestampOptions { type Error = String; fn try_from(mut opts: SudoOptions) -> Result { // see `SudoOptions::validate` let remove_timestamp = mem::take(&mut opts.remove_timestamp); debug_assert!(remove_timestamp); reject_all("--remove-timestamp", opts)?; Ok(Self {}) } } // sudo -h | -K | -k | -V pub struct SudoResetTimestampOptions {} impl TryFrom for SudoResetTimestampOptions { type Error = String; fn try_from(mut opts: SudoOptions) -> Result { // see `SudoOptions::validate` let reset_timestamp = mem::take(&mut opts.reset_timestamp); debug_assert!(reset_timestamp); reject_all("--reset-timestamp", opts)?; Ok(Self {}) } } // sudo -v [-ABkNnS] [-g group] [-h host] [-p prompt] [-u user] pub struct SudoValidateOptions { // -B pub bell: bool, // -k pub reset_timestamp: bool, // -n pub non_interactive: bool, // -S pub stdin: bool, // -p pub prompt: Option, // -g pub group: Option, // -u pub user: Option, } impl TryFrom for SudoValidateOptions { type Error = String; fn try_from(mut opts: SudoOptions) -> Result { // see `SudoOptions::validate` let validate = mem::take(&mut opts.validate); debug_assert!(validate); let bell = mem::take(&mut opts.bell); let reset_timestamp = mem::take(&mut opts.reset_timestamp); let non_interactive = mem::take(&mut opts.non_interactive); let stdin = mem::take(&mut opts.stdin); let prompt = mem::take(&mut opts.prompt); let group = mem::take(&mut opts.group); let user = mem::take(&mut opts.user); if bell && stdin { return Err("--bell conflicts with --stdin".into()); } reject_all("--validate", opts)?; Ok(Self { bell, reset_timestamp, non_interactive, stdin, prompt, group, user, }) } } // sudo -e [-ABkNnS] [-r role] [-t type] [-C num] [-D directory] [-g group] [-h host] [-p prompt] [-R directory] [-T timeout] [-u user] file ... #[cfg_attr(not(feature = "sudoedit"), allow(dead_code))] pub struct SudoEditOptions { // -B pub bell: bool, // -k pub reset_timestamp: bool, // -n pub non_interactive: bool, // -S pub stdin: bool, // -p pub prompt: Option, // -D pub chdir: Option, // -g pub group: Option, // -u pub user: Option, pub positional_args: Vec, } impl TryFrom for SudoEditOptions { type Error = String; fn try_from(mut opts: SudoOptions) -> Result { // see `SudoOptions::validate` let edit = mem::take(&mut opts.edit); debug_assert!(edit); let bell = mem::take(&mut opts.bell); let reset_timestamp = mem::take(&mut opts.reset_timestamp); let non_interactive = mem::take(&mut opts.non_interactive); let stdin = mem::take(&mut opts.stdin); let prompt = mem::take(&mut opts.prompt); let chdir = mem::take(&mut opts.chdir); let group = mem::take(&mut opts.group); let user = mem::take(&mut opts.user); let positional_args = mem::take(&mut opts.positional_args); if bell && stdin { return Err("--bell conflicts with --stdin".into()); } reject_all("--edit", opts)?; if positional_args.is_empty() { return Err("must specify at least one file path".into()); } Ok(Self { bell, reset_timestamp, non_interactive, stdin, prompt, chdir, group, user, positional_args, }) } } // sudo -l [-ABkNnS] [-g group] [-h host] [-p prompt] [-U user] [-u user] [command [arg ...]] pub struct SudoListOptions { // -B pub bell: bool, // -l OR -l -l pub list: List, // -k pub reset_timestamp: bool, // -n pub non_interactive: bool, // -S pub stdin: bool, // -p pub prompt: Option, // -g pub group: Option, // -U pub other_user: Option, // -u pub user: Option, pub positional_args: Vec, } impl TryFrom for SudoListOptions { type Error = String; fn try_from(mut opts: SudoOptions) -> Result { let bell = mem::take(&mut opts.bell); let list = opts.list.take().unwrap(); let reset_timestamp = mem::take(&mut opts.reset_timestamp); let non_interactive = mem::take(&mut opts.non_interactive); let stdin = mem::take(&mut opts.stdin); let prompt = mem::take(&mut opts.prompt); let group = mem::take(&mut opts.group); let other_user = mem::take(&mut opts.other_user); let user = mem::take(&mut opts.user); let positional_args = mem::take(&mut opts.positional_args); if bell && stdin { return Err("--bell conflicts with --stdin".into()); } // when present, `-u` must be accompanied by a command let has_command = !positional_args.is_empty(); let valid_user_flag = user.is_none() || has_command; if !valid_user_flag { return Err("'--user' flag must be accompanied by a command".into()); } reject_all("--list", opts)?; Ok(Self { bell, list, reset_timestamp, non_interactive, stdin, prompt, group, other_user, user, positional_args, }) } } // sudo [-ABbEHnPS] [-C num] [-D directory] [-g group] [-h host] [-p prompt] [-R directory] [-T timeout] [-u user] [VAR=value] [-i | -s] [command [arg ...]] pub struct SudoRunOptions { // -B pub bell: bool, // -E /* ignored, part of env_var_list */ // -k pub reset_timestamp: bool, // -n pub non_interactive: bool, // -S pub stdin: bool, // -p pub prompt: Option, // -D pub chdir: Option, // -g pub group: Option, // -u pub user: Option, // VAR=value pub env_var_list: Vec<(String, String)>, // -i pub login: bool, // -s pub shell: bool, pub positional_args: Vec, } impl TryFrom for SudoRunOptions { type Error = String; fn try_from(mut opts: SudoOptions) -> Result { let bell = mem::take(&mut opts.bell); let reset_timestamp = mem::take(&mut opts.reset_timestamp); let non_interactive = mem::take(&mut opts.non_interactive); let stdin = mem::take(&mut opts.stdin); let prompt = mem::take(&mut opts.prompt); let chdir = mem::take(&mut opts.chdir); let group = mem::take(&mut opts.group); let user = mem::take(&mut opts.user); let env_var_list = mem::take(&mut opts.env_var_list); let login = mem::take(&mut opts.login); let shell = mem::take(&mut opts.shell); let positional_args = mem::take(&mut opts.positional_args); if bell && stdin { return Err("--bell conflicts with --stdin".into()); } let context = match (login, shell, positional_args.is_empty()) { (true, false, _) => "--login", (false, true, _) => "--shell", (false, false, false) => "command (positional argument)", (true, true, _) => return Err("--login conflicts with --shell".into()), (false, false, true) => { if cfg!(debug_assertions) { // see `SudoOptions::validate` panic!(); } else { return Err( "expected one of: --login, --shell, a command as a positional argument" .into(), ); } } }; reject_all(context, opts)?; Ok(Self { bell, reset_timestamp, non_interactive, stdin, prompt, chdir, group, user, env_var_list, login, shell, positional_args, }) } } #[derive(Default)] struct SudoOptions { // -B bell: bool, // -D chdir: Option, // -g group: Option, // -i login: bool, // -n non_interactive: bool, // -U other_user: Option, // -E /* ignored, part of env_var_list */ // -s shell: bool, // -S stdin: bool, // -p prompt: Option, // -u user: Option, // additional environment env_var_list: Vec<(String, String)>, /* actions */ // -e edit: bool, // -h help: bool, // -l list: Option, // -K remove_timestamp: bool, // -k reset_timestamp: bool, // -v validate: bool, // -V version: bool, // arguments passed straight through, either separated by -- or just trailing. positional_args: Vec, } #[derive(Debug, Clone, PartialEq)] pub enum List { Once, Verbose, } impl List { #[must_use] pub fn is_verbose(&self) -> bool { matches!(self, Self::Verbose) } } enum SudoArg { Flag(String), Argument(String, String), Environment(String, String), Rest(Vec), } impl SudoArg { const TAKES_ARGUMENT_SHORT: &'static [char] = &['D', 'g', 'h', 'p', 'R', 'U', 'u']; const TAKES_ARGUMENT: &'static [&'static str] = &[ "chdir", "group", "host", "chroot", "other-user", "user", "prompt", ]; /// argument assignments and shorthand options preprocessing /// the iterator should only iterate over the actual arguments fn normalize_arguments(mut arg_iter: I) -> Result, String> where I: Iterator, { let mut processed = vec![]; while let Some(arg) = arg_iter.next() { if arg == "--" { processed.push(SudoArg::Rest(arg_iter.collect())); break; } else if let Some(unprefixed) = arg.strip_prefix("--") { if let Some((key, value)) = unprefixed.split_once('=') { // convert assignment to normal tokens // only accept arguments when one is expected // `--preserve-env` is special as it only takes an argument using this `key=value` syntax if !Self::TAKES_ARGUMENT.contains(&key) && key != "preserve-env" { Err(format!("'{key}' does not take any arguments"))?; } processed.push(SudoArg::Argument("--".to_string() + key, value.to_string())); } else if Self::TAKES_ARGUMENT.contains(&unprefixed) { if let Some(next) = arg_iter.next() { processed.push(SudoArg::Argument(arg, next)); } else { Err(format!("'{arg}' expects an argument"))?; } } else { processed.push(SudoArg::Flag(arg)); } } else if let Some(unprefixed) = arg.strip_prefix('-') { // split combined shorthand options let mut chars = unprefixed.chars(); while let Some(curr) = chars.next() { let flag = format!("-{curr}"); // convert option argument to separate segment if Self::TAKES_ARGUMENT_SHORT.contains(&curr) { let rest = chars.as_str(); let next = chars.next(); // assignment syntax is not accepted for shorthand arguments if next == Some('=') { Err("invalid option '='")?; } if next.is_some() { processed.push(SudoArg::Argument(flag, rest.to_string())); } else if let Some(next) = arg_iter.next() { processed.push(SudoArg::Argument(flag, next)); } else if curr == 'h' { // short version of --help has no arguments processed.push(SudoArg::Flag(flag)); } else { Err(format!("'-{curr}' expects an argument"))?; } break; } else { processed.push(SudoArg::Flag(flag)); } } } else if let Some((key, value)) = try_to_env_var(&arg) { processed.push(SudoArg::Environment(key, value)); } else { let mut rest = vec![arg]; rest.extend(arg_iter); processed.push(SudoArg::Rest(rest)); break; } } Ok(processed) } } impl SudoOptions { fn validate(self) -> Result { let action = if self.help { SudoAction::Help(self.try_into()?) } else if self.version { SudoAction::Version(self.try_into()?) } else if self.remove_timestamp { SudoAction::RemoveTimestamp(self.try_into()?) } else if self.validate { SudoAction::Validate(self.try_into()?) } else if self.list.is_some() { SudoAction::List(self.try_into()?) } else if self.edit { SudoAction::Edit(self.try_into()?) } else { let is_run = self.login | self.shell | !self.positional_args.is_empty(); if is_run { SudoAction::Run(self.try_into()?) } else if self.reset_timestamp { SudoAction::ResetTimestamp(self.try_into()?) } else { return Err("expected one of these actions: --help, --version, --remove-timestamp, --validate, --list, --edit, --login, --shell, a command as a positional argument, --reset-timestamp".into()); } }; Ok(action) } /// parse an iterator over command line arguments fn try_parse_from(iter: I) -> Result where I: IntoIterator, T: Into + Clone, { let mut arg_iter = iter.into_iter().map(Into::into); let invoked_as_sudoedit = is_sudoedit(arg_iter.next()); let mut options = Self { edit: invoked_as_sudoedit, ..Self::default() }; let arg_iter = SudoArg::normalize_arguments(arg_iter)? .into_iter() .peekable(); for arg in arg_iter { match arg { SudoArg::Flag(flag) => match flag.as_str() { "-B" | "--bell" => { options.bell = true; } "-E" | "--preserve-env" => { eprintln_ignore_io_error!( "warning: preserving the entire environment is not supported, `{flag}` is ignored" ) } "-e" | "--edit" if !invoked_as_sudoedit => { options.edit = true; } "-H" | "--set-home" => { // this option is ignored, since it is the default for sudo-rs; but accept // it for backwards compatibility reasons } "-h" | "--help" => { options.help = true; } "-i" | "--login" => { options.login = true; } "-K" | "--remove-timestamp" => { options.remove_timestamp = true; } "-k" | "--reset-timestamp" => { options.reset_timestamp = true; } "-l" | "--list" => match options.list { None => options.list = Some(List::Once), Some(List::Once) => options.list = Some(List::Verbose), Some(List::Verbose) => {} }, "-n" | "--non-interactive" => { options.non_interactive = true; } "-S" | "--stdin" => { options.stdin = true; } "-s" | "--shell" => { options.shell = true; } "-V" | "--version" => { options.version = true; } "-v" | "--validate" => { options.validate = true; } _option => { Err("invalid option provided")?; } }, SudoArg::Argument(option, value) => match option.as_str() { "-D" | "--chdir" => { options.chdir = Some(SudoPath::from_cli_string(value)); } "-E" | "--preserve-env" => { options .env_var_list .extend(value.split(',').filter_map(|var| { std::env::var(var) .ok() .map(|value| (var.to_string(), value)) })); } "-g" | "--group" => { options.group = Some(SudoString::from_cli_string(value)); } "-p" | "--prompt" => { options.prompt = Some(value); } "-U" | "--other-user" => { options.other_user = Some(SudoString::from_cli_string(value)); } "-u" | "--user" => { options.user = Some(SudoString::from_cli_string(value)); } _option => { Err("invalid option provided")?; } }, SudoArg::Environment(key, value) => { options.env_var_list.push((key, value)); } SudoArg::Rest(mut rest) => { if let Some(cmd) = rest.first() { let cmd = std::path::Path::new(cmd); // This checks if the last character in the path is a /. This // works because the OS directly splits at b'/' without regards // for if it is part of another character (which it can't be // with UTF-8 anyways). let is_dir = cmd.as_os_str().as_bytes().ends_with(b"/"); if !options.edit && !is_dir && (cmd.ends_with("sudoedit") || cmd.ends_with("sudoedit-rs")) { eprintln_ignore_io_error!("sudoedit doesn't need to be run via sudo"); options.edit = true; rest.remove(0); } } options.positional_args = rest; } } } Ok(options) } } fn try_to_env_var(arg: &str) -> Option<(String, String)> { let (name, value) = arg.split_once('=')?; if name.chars().all(|c| c.is_alphanumeric() || c == '_') { Some((name.to_owned(), value.to_owned())) } else { None } } trait IsAbsent { fn is_absent(&self) -> bool; } impl IsAbsent for bool { fn is_absent(&self) -> bool { !*self } } impl IsAbsent for Option { fn is_absent(&self) -> bool { self.is_none() } } impl IsAbsent for Vec { fn is_absent(&self) -> bool { self.is_empty() } } fn ensure_is_absent(context: &str, thing: &dyn IsAbsent, name: &str) -> Result<(), String> { if thing.is_absent() { Ok(()) } else { Err(format!("{context} cannot be used together with {name}")) } } fn reject_all(context: &str, opts: SudoOptions) -> Result<(), String> { macro_rules! tuple { ($expr:expr) => { (&$expr as &dyn IsAbsent, { let name = concat!("--", stringify!($expr)); if name.contains('_') { Cow::Owned(name.replace('_', "-")) } else { Cow::Borrowed(name) } }) }; } let SudoOptions { bell, chdir, group, login, non_interactive, other_user, shell, stdin, prompt, user, env_var_list, edit, help, list, remove_timestamp, reset_timestamp, validate, version, positional_args, } = opts; let flags = [ tuple!(bell), tuple!(chdir), tuple!(edit), tuple!(group), tuple!(help), tuple!(list), tuple!(login), tuple!(non_interactive), tuple!(other_user), tuple!(remove_timestamp), tuple!(reset_timestamp), tuple!(shell), tuple!(stdin), tuple!(prompt), tuple!(user), tuple!(validate), tuple!(version), ]; for (value, name) in flags { ensure_is_absent(context, value, &name)?; } ensure_is_absent(context, &env_var_list, "environment variable")?; ensure_is_absent(context, &positional_args, "command")?; Ok(()) } sudo-rs-0.2.10/src/sudo/cli/tests.rs000064400000000000000000000312431046102023000153230ustar 00000000000000use crate::common::SudoPath; use super::{SudoAction, SudoOptions}; use pretty_assertions::assert_eq; /// Passing '-E' with a variable fails #[test] fn short_preserve_env_with_var_fails() { let argss = [["sudo", "-E=variable"], ["sudo", "-Evariable"]]; for args in argss { let res = SudoOptions::try_parse_from(args); assert!(res.is_err()) } } /// Passing '--preserve-env' with an argument fills 'preserve_env', 'short_preserve_env' stays 'false' #[test] fn preserve_env_with_var() { let cmd = SudoOptions::try_parse_from(["sudo", "--preserve-env=HOME"]).unwrap(); assert_eq!( [("HOME".to_string(), std::env::var("HOME").unwrap())], cmd.env_var_list.as_slice() ); } /// Passing '--preserve-env' with several arguments fills 'preserve_env', 'short_preserve_env' stays 'false' #[test] fn preserve_env_with_several_vars() { let cmd = SudoOptions::try_parse_from(["sudo", "--preserve-env=PATH,HOME"]).unwrap(); assert_eq!( [ ("PATH".to_string(), std::env::var("PATH").unwrap()), ("HOME".to_string(), std::env::var("HOME").unwrap()), ], cmd.env_var_list.as_slice() ); } #[test] fn preserve_env_boolean_and_list() { let argss = [ ["sudo", "--preserve-env", "--preserve-env=HOME"], ["sudo", "--preserve-env=HOME", "--preserve-env"], ]; for args in argss { let cmd = SudoOptions::try_parse_from(args).unwrap(); assert_eq!( [("HOME".to_string(), std::env::var("HOME").unwrap())], cmd.env_var_list.as_slice() ); } } #[test] fn preserve_env_repeated() { let cmd = SudoOptions::try_parse_from(["sudo", "--preserve-env=PATH", "--preserve-env=HOME"]) .unwrap(); assert_eq!( ["PATH", "HOME"], cmd.env_var_list .into_iter() .map(|x| x.0) .collect::>() .as_slice() ); } /// Catch env variable that is given without hyphens in 'VAR=value' form in env_var_list. /// external_args stay empty. #[test] fn env_variable() { let cmd = SudoOptions::try_parse_from(["sudo", "ENV=with_a_value"]).unwrap(); assert_eq!( cmd.env_var_list, vec![("ENV".to_owned(), "with_a_value".to_owned())] ); assert!(cmd.positional_args.is_empty()); } /// Catch several env variablse that are given without hyphens in 'VAR=value' form in env_var_list. /// external_args stay empty. #[test] fn several_env_variables() { let cmd = SudoOptions::try_parse_from([ "sudo", "ENV=with_a_value", "another_var=otherval", "more=this_is_a_val", ]) .unwrap(); assert_eq!( cmd.env_var_list, vec![ ("ENV".to_owned(), "with_a_value".to_owned()), ("another_var".to_owned(), "otherval".to_owned()), ("more".to_owned(), "this_is_a_val".to_owned()) ] ); assert!(cmd.positional_args.is_empty()); } /// Mix env variables and trailing arguments that just pass through sudo /// Divided by hyphens. #[test] fn mix_env_variables_with_trailing_args_divided_by_hyphens() { let cmd = SudoOptions::try_parse_from(["sudo", "env=var", "--", "external=args", "something"]) .unwrap(); assert_eq!(cmd.env_var_list, vec![("env".to_owned(), "var".to_owned())]); assert_eq!(cmd.positional_args, vec!["external=args", "something"]); } /// Mix env variables and trailing arguments that just pass through sudo /// Divided by known flag. #[test] fn mix_env_variables_with_trailing_args_divided_by_known_flag() { let cmd = SudoOptions::try_parse_from(["sudo", "-i", "external=args", "something"]).unwrap(); assert_eq!( cmd.env_var_list, vec![("external".to_owned(), "args".to_owned())] ); assert!(cmd.login); assert_eq!(cmd.positional_args, vec!["something"]); } /// Catch trailing arguments that just pass through sudo /// but look like a known flag. #[test] fn trailing_args_followed_by_known_flag() { let cmd = SudoOptions::try_parse_from(["sudo", "args", "followed_by", "known_flag", "-i"]).unwrap(); assert!(!cmd.login); assert_eq!( cmd.positional_args, vec!["args", "followed_by", "known_flag", "-i"] ); } /// Catch trailing arguments that just pass through sudo /// but look like a known flag, divided by hyphens. #[test] fn trailing_args_hyphens_known_flag() { let cmd = SudoOptions::try_parse_from([ "sudo", "--", "trailing", "args", "followed_by", "known_flag", "-i", ]) .unwrap(); assert!(!cmd.login); assert_eq!( cmd.positional_args, vec!["trailing", "args", "followed_by", "known_flag", "-i"] ); } /// Check that the first environment variable declaration before any command is not treated as part /// of the command. #[test] fn first_trailing_env_var_is_not_an_external_arg() { let cmd = SudoAction::try_parse_from(["sudo", "FOO=1", "command", "BAR=2"]).unwrap(); let opts = if let SudoAction::Run(opts) = cmd { opts } else { panic!() }; assert_eq!(opts.env_var_list, vec![("FOO".to_owned(), "1".to_owned()),]); assert_eq!(opts.positional_args, ["command", "BAR=2"],); } #[test] fn trailing_env_vars_are_external_args() { let cmd = SudoOptions::try_parse_from([ "sudo", "FOO=1", "-i", "BAR=2", "command", "BAZ=3", "arg", "FOOBAR=4", "command", "arg", "BARBAZ=5", ]) .unwrap(); assert!(cmd.login); assert_eq!( cmd.env_var_list, vec![ ("FOO".to_owned(), "1".to_owned()), ("BAR".to_owned(), "2".to_owned()) ] ); assert_eq!( cmd.positional_args, ["command", "BAZ=3", "arg", "FOOBAR=4", "command", "arg", "BARBAZ=5"] ); } #[test] fn single_env_var_declaration() { let cmd = SudoOptions::try_parse_from(["sudo", "FOO=1", "command"]).unwrap(); assert_eq!(cmd.env_var_list, vec![("FOO".to_owned(), "1".to_owned())]); assert_eq!(cmd.positional_args, ["command"]); } #[test] fn shorthand_with_argument() { let cmd = SudoOptions::try_parse_from(["sudo", "-u", "ferris"]).unwrap(); assert_eq!(cmd.user.as_deref(), Some("ferris")); } #[test] fn shorthand_with_direct_argument() { let cmd = SudoOptions::try_parse_from(["sudo", "-uferris"]).unwrap(); assert_eq!(cmd.user.as_deref(), Some("ferris")); } #[test] fn shorthand_without_argument() { let cmd = SudoOptions::try_parse_from(["sudo", "-u"]); assert!(cmd.is_err()) } #[test] fn non_interactive() { let cmd = SudoOptions::try_parse_from(["sudo", "-n"]).unwrap(); assert!(cmd.non_interactive); let cmd = SudoOptions::try_parse_from(["sudo", "--non-interactive"]).unwrap(); assert!(cmd.non_interactive); } #[test] fn stdin() { let cmd = SudoOptions::try_parse_from(["sudo", "-S"]).unwrap(); assert!(cmd.stdin); let cmd = SudoOptions::try_parse_from(["sudo", "--stdin"]).unwrap(); assert!(cmd.stdin); } #[test] fn shell() { let cmd = SudoOptions::try_parse_from(["sudo", "-s"]).unwrap(); assert!(cmd.shell); let cmd = SudoOptions::try_parse_from(["sudo", "--shell"]).unwrap(); assert!(cmd.shell); } #[test] fn directory() { let cmd = SudoOptions::try_parse_from(["sudo", "-D/some/path"]).unwrap(); assert_eq!(cmd.chdir, Some(SudoPath::from("/some/path"))); let cmd = SudoOptions::try_parse_from(["sudo", "--chdir", "/some/path"]).unwrap(); assert_eq!(cmd.chdir, Some(SudoPath::from("/some/path"))); let cmd = SudoOptions::try_parse_from(["sudo", "--chdir=/some/path"]).unwrap(); assert_eq!(cmd.chdir, Some(SudoPath::from("/some/path"))); } #[test] fn group() { let cmd = SudoOptions::try_parse_from(["sudo", "-grustaceans"]).unwrap(); assert_eq!(cmd.group.as_deref(), Some("rustaceans")); let cmd = SudoOptions::try_parse_from(["sudo", "--group", "rustaceans"]).unwrap(); assert_eq!(cmd.group.as_deref(), Some("rustaceans")); let cmd = SudoOptions::try_parse_from(["sudo", "--group=rustaceans"]).unwrap(); assert_eq!(cmd.group.as_deref(), Some("rustaceans")); } #[test] fn other_user() { let cmd = SudoOptions::try_parse_from(["sudo", "-Uferris"]).unwrap(); assert_eq!(cmd.other_user.as_deref(), Some("ferris")); let cmd = SudoOptions::try_parse_from(["sudo", "--other-user", "ferris"]).unwrap(); assert_eq!(cmd.other_user.as_deref(), Some("ferris")); let cmd = SudoOptions::try_parse_from(["sudo", "--other-user=ferris"]).unwrap(); assert_eq!(cmd.other_user.as_deref(), Some("ferris")); } #[test] fn invalid_option() { let cmd = SudoOptions::try_parse_from(["sudo", "--wololo"]); assert!(cmd.is_err()) } #[test] fn invalid_option_with_argument() { let cmd = SudoOptions::try_parse_from(["sudo", "--background=yes"]); assert!(cmd.is_err()) } #[test] fn no_argument_provided() { let cmd = SudoOptions::try_parse_from(["sudo", "--user"]); assert!(cmd.is_err()) } #[test] fn login() { let cmd = SudoOptions::try_parse_from(["sudo", "-i"]).unwrap(); assert!(cmd.login); let cmd = SudoOptions::try_parse_from(["sudo", "--login"]).unwrap(); assert!(cmd.login); } #[test] fn edit() { let cmd = SudoAction::try_parse_from(["sudo", "-e", "filepath"]).unwrap(); assert!(cmd.is_edit()); let cmd = SudoAction::try_parse_from(["sudo", "--edit", "filepath"]).unwrap(); assert!(cmd.is_edit()); let cmd = SudoAction::try_parse_from(["sudoedit", "filepath"]).unwrap(); assert!(cmd.is_edit()); let res = SudoAction::try_parse_from(["sudo", "--edit"]); assert!(res.is_err()); let res = SudoAction::try_parse_from(["sudoedit", "--edit", "filepath"]); assert!(res.is_err()); } #[test] fn help() { let cmd = SudoAction::try_parse_from(["sudo", "-h"]).unwrap(); assert!(cmd.is_help()); let cmd = SudoAction::try_parse_from(["sudo", "-bh"]); assert!(cmd.is_err()); let cmd = SudoAction::try_parse_from(["sudo", "--help"]).unwrap(); assert!(cmd.is_help()); } #[test] fn conflicting_arguments() { let cmd = SudoAction::try_parse_from(["sudo", "-K", "-k"]); assert!(cmd.is_err()); let cmd = SudoAction::try_parse_from(["sudo", "--remove-timestamp", "--reset-timestamp"]); assert!(cmd.is_err()); let cmd = SudoAction::try_parse_from(["sudo", "-K"]).unwrap(); assert!(cmd.is_remove_timestamp()); let cmd = SudoAction::try_parse_from(["sudo", "-k"]).unwrap(); assert!(cmd.is_reset_timestamp()); } #[test] fn list() { let valid: &[&[_]] = &[ &["sudo", "--list"], &["sudo", "-l"], &["sudo", "-l", "true"], &["sudo", "-l", "-U", "ferris"], &["sudo", "-l", "-U", "ferris", "true"], &["sudo", "-l", "-u", "ferris", "true"], &["sudo", "-l", "-u", "ferris", "-U", "root", "true"], ]; for args in valid { let cmd = SudoAction::try_parse_from(args.iter().copied()).unwrap(); assert!(cmd.is_list()); } let invalid: &[&[_]] = &[ &["sudo", "-l", "-u", "ferris"], &["sudo", "-l", "-u", "ferris", "-U", "root"], ]; for args in invalid { let res = SudoAction::try_parse_from(args.iter().copied()); assert!(res.is_err()) } } #[test] fn validate() { let cmd = SudoAction::try_parse_from(["sudo", "-v"]).unwrap(); assert!(cmd.is_validate()); let cmd = SudoAction::try_parse_from(["sudo", "--validate"]).unwrap(); assert!(cmd.is_validate()); } #[test] fn version() { let cmd = SudoAction::try_parse_from(["sudo", "-V"]).unwrap(); assert!(cmd.is_version()); let cmd = SudoAction::try_parse_from(["sudo", "--version"]).unwrap(); assert!(cmd.is_version()); } #[test] fn run_reset_timestamp_command() { let action = SudoAction::try_parse_from(["sudo", "-k", "true"]) .unwrap() .try_into_run() .ok() .unwrap(); assert_eq!(["true"], action.positional_args.as_slice()); assert!(action.reset_timestamp); } #[test] fn run_reset_timestamp_login() { let action = SudoAction::try_parse_from(["sudo", "-k", "-i"]) .unwrap() .try_into_run() .ok() .unwrap(); assert!(action.positional_args.is_empty()); assert!(action.reset_timestamp); assert!(action.login); } #[test] fn run_reset_timestamp_shell() { let action = SudoAction::try_parse_from(["sudo", "-k", "-s"]) .unwrap() .try_into_run() .ok() .unwrap(); assert!(action.positional_args.is_empty()); assert!(action.reset_timestamp); assert!(action.shell); } #[test] fn run_no_command() { assert!(SudoAction::try_parse_from(["sudo", "-u", "root"]).is_err()); } #[test] fn run_login() { assert!(SudoAction::try_parse_from(["sudo", "-i"]).unwrap().is_run()); } #[test] fn run_shell() { assert!(SudoAction::try_parse_from(["sudo", "-s"]).unwrap().is_run()); } sudo-rs-0.2.10/src/sudo/diagnostic.rs000064400000000000000000000030421046102023000155320ustar 00000000000000use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; use crate::sudoers::Span; pub(crate) fn cited_error(message: &str, span: Span, path: impl AsRef) { let path_str = path.as_ref().display(); let Span { start: (line, col), end: (end_line, mut end_col), } = span; eprintln_ignore_io_error!("{path_str}:{line}:{col}: {message}"); // we won't try to "span" errors across multiple lines if line != end_line { end_col = col; } let citation = || { let inp = BufReader::new(File::open(path).ok()?); let line = inp.lines().nth(line - 1)?.ok()?; let padding = line .chars() .take(col - 1) .map(|c| if c.is_whitespace() { c } else { ' ' }) .collect::(); let lineunder = std::iter::repeat('~') .take(end_col - col) .skip(1) .collect::(); eprintln_ignore_io_error!("{line}"); eprintln_ignore_io_error!("{padding}^{lineunder}"); Some(()) }; // we ignore any errors in displaying an error let _ = citation(); } macro_rules! diagnostic { ($str:expr, $path:tt @ $pos:ident) => { if let Some(range) = $pos { $crate::sudo::diagnostic::cited_error(&format!($str), range, $path); } else { eprintln_ignore_io_error!("sudo-rs: {}", format!($str)); } }; ($str:expr) => {{ eprintln_ignore_io_error!("sudo-rs: {}", format!($str)); }}; } pub(crate) use diagnostic; sudo-rs-0.2.10/src/sudo/edit.rs000064400000000000000000000217111046102023000143360ustar 00000000000000#![allow(unsafe_code)] use std::fs::File; use std::io::{Read, Seek, Write}; use std::net::Shutdown; use std::os::unix::{fs::OpenOptionsExt, net::UnixStream, process::ExitStatusExt}; use std::path::{Path, PathBuf}; use std::process::Command; use std::{io, process}; use crate::common::SudoPath; use crate::exec::ExitReason; use crate::log::user_info; use crate::system::file::{create_temporary_dir, FileLock}; use crate::system::wait::{Wait, WaitError, WaitOptions}; use crate::system::{fork, ForkResult}; struct ParentFileInfo<'a> { path: &'a Path, file: File, lock: FileLock, old_data: Vec, new_data_rx: UnixStream, new_data: Option>, } struct ChildFileInfo<'a> { path: &'a Path, old_data: Vec, tempfile_path: Option, new_data_tx: UnixStream, } pub(super) fn edit_files( editor: &Path, selected_files: Vec<(&SudoPath, File)>, ) -> io::Result { let mut files = vec![]; let mut child_files = vec![]; for (path, mut file) in selected_files { // Error for special files let metadata = file.metadata().map_err(|e| { io::Error::new( e.kind(), format!("Failed to read metadata for {}: {e}", path.display()), ) })?; if !metadata.is_file() { return Err(io::Error::new( io::ErrorKind::Other, format!("File {} is not a regular file", path.display()), )); } // Take file lock let lock = FileLock::exclusive(&file, true).map_err(|e| { io::Error::new(e.kind(), format!("Failed to lock {}: {e}", path.display())) })?; // Read file let mut old_data = Vec::new(); file.read_to_end(&mut old_data).map_err(|e| { io::Error::new(e.kind(), format!("Failed to read {}: {e}", path.display())) })?; // Create socket let (parent_socket, child_socket) = UnixStream::pair().unwrap(); files.push(ParentFileInfo { path, file, lock, old_data: old_data.clone(), new_data_rx: parent_socket, new_data: None, }); child_files.push(ChildFileInfo { path, old_data, tempfile_path: None, new_data_tx: child_socket, }); } // Spawn child // SAFETY: There should be no other threads at this point. let ForkResult::Parent(command_pid) = unsafe { fork() }.unwrap() else { drop(files); handle_child(editor, child_files) }; drop(child_files); for file in &mut files { // Read from socket file.new_data = Some(read_stream(&mut file.new_data_rx).map_err(|e| { io::Error::new(e.kind(), format!("Failed to read from socket: {e}")) })?); } // If child has error, exit with non-zero exit code let status = loop { match command_pid.wait(WaitOptions::new()) { Ok((_, status)) => break status, Err(WaitError::Io(err)) if err.kind() == io::ErrorKind::Interrupted => {} Err(err) => panic!("{err:?}"), } }; assert!(status.did_exit()); if let Some(signal) = status.term_signal() { return Ok(ExitReason::Signal(signal)); } else if let Some(code) = status.exit_status() { if code != 0 { return Ok(ExitReason::Code(code)); } } else { return Ok(ExitReason::Code(1)); } for mut file in files { let data = file.new_data.expect("filled in above"); if data == file.old_data { // File unchanged. No need to write it again. user_info!("{} unchanged", file.path.display()); continue; } // FIXME check if modified since reading and if so ask user what to do // Write file (move || { file.file.rewind()?; file.file.write_all(&data)?; file.file.set_len( data.len() .try_into() .expect("more than 18 exabyte of data???"), ) })() .map_err(|e| { io::Error::new( e.kind(), format!("Failed to write {}: {e}", file.path.display()), ) })?; drop(file.lock); } Ok(ExitReason::Code(0)) } struct TempDirDropGuard(PathBuf); impl Drop for TempDirDropGuard { fn drop(&mut self) { if let Err(e) = std::fs::remove_dir_all(&self.0) { eprintln_ignore_io_error!( "Failed to remove temporary directory {}: {e}", self.0.display(), ); }; } } fn handle_child(editor: &Path, file: Vec>) -> ! { match handle_child_inner(editor, file) { Ok(()) => process::exit(0), Err(err) => { eprintln_ignore_io_error!("{err}"); process::exit(1); } } } // FIXME maybe use pipes once std::io::pipe has been stabilized long enough. fn handle_child_inner(editor: &Path, mut files: Vec>) -> Result<(), String> { // Drop root privileges. // SAFETY: setuid does not change any memory and only affects OS state. unsafe { libc::setuid(libc::getuid()); } let tempdir = TempDirDropGuard( create_temporary_dir().map_err(|e| format!("Failed to create temporary directory: {e}"))?, ); for (i, file) in files.iter_mut().enumerate() { // Create temp file let dir = tempdir.0.join(format!("{i}")); std::fs::create_dir(&dir).map_err(|e| { format!( "Failed to create temporary directory {}: {e}", dir.display(), ) })?; let tempfile_path = dir.join(file.path.file_name().expect("file must have filename")); let mut tempfile = std::fs::OpenOptions::new() .read(true) .write(true) .create_new(true) .mode(0o600) .open(&tempfile_path) .map_err(|e| { format!( "Failed to create temporary file {}: {e}", tempfile_path.display(), ) })?; // Write to temp file tempfile.write_all(&file.old_data).map_err(|e| { format!( "Failed to write to temporary file {}: {e}", tempfile_path.display(), ) })?; drop(tempfile); file.tempfile_path = Some(tempfile_path); } // Spawn editor let status = Command::new(editor) .args( files .iter() .map(|file| file.tempfile_path.as_ref().expect("filled in above")), ) .status() .map_err(|e| format!("Failed to run editor {}: {e}", editor.display()))?; if !status.success() { drop(tempdir); if let Some(signal) = status.signal() { process::exit(128 + signal); } process::exit(status.code().unwrap_or(1)); } for mut file in files { let tempfile_path = file.tempfile_path.as_ref().expect("filled in above"); // Read from temp file let new_data = std::fs::read(tempfile_path).map_err(|e| { format!( "Failed to read from temporary file {}: {e}", tempfile_path.display(), ) })?; // FIXME preserve temporary file if the original couldn't be written to std::fs::remove_file(tempfile_path).map_err(|e| { format!( "Failed to remove temporary file {}: {e}", tempfile_path.display(), ) })?; // If the file has been changed to be empty, ask the user what to do. if new_data.is_empty() && new_data != file.old_data { match crate::visudo::ask_response( format!( "sudoedit: truncate {} to zero? (y/n) [n] ", file.path.display() ) .as_bytes(), b"yn", ) { Ok(b'y') => {} _ => { eprintln_ignore_io_error!("Not overwriting {}", file.path.display()); // Parent ignores write when new data matches old data write_stream(&mut file.new_data_tx, &file.old_data) .map_err(|e| format!("Failed to write data to parent: {e}"))?; continue; } } } // Write to socket write_stream(&mut file.new_data_tx, &new_data) .map_err(|e| format!("Failed to write data to parent: {e}"))?; } process::exit(0); } fn write_stream(socket: &mut UnixStream, data: &[u8]) -> io::Result<()> { socket.write_all(data)?; socket.shutdown(Shutdown::Both)?; Ok(()) } fn read_stream(socket: &mut UnixStream) -> io::Result> { let mut new_data = Vec::new(); socket.read_to_end(&mut new_data)?; Ok(new_data) } sudo-rs-0.2.10/src/sudo/env/environment.rs000064400000000000000000000312541046102023000165500ustar 00000000000000use std::{ collections::{HashMap, HashSet}, ffi::{OsStr, OsString}, os::unix::prelude::OsStrExt, }; use crate::common::{CommandAndArguments, Context, Error}; use crate::sudoers::Restrictions; use crate::system::PATH_MAX; use super::wildcard_match::wildcard_match; const PATH_MAILDIR: &str = env!("PATH_MAILDIR"); const PATH_ZONEINFO: &str = env!("PATH_ZONEINFO"); const PATH_DEFAULT: &str = env!("SUDO_PATH_DEFAULT"); pub type Environment = HashMap; /// obtain the system environment pub fn system_environment() -> Environment { std::env::vars_os().collect() } /// check byte slice contains with given byte slice fn contains_subsequence(haystack: &[u8], needle: &[u8]) -> bool { haystack .windows(needle.len()) .any(|window| window == needle) } /// Formats the command and arguments passed for the SUDO_COMMAND /// environment variable. Limit the length to 4096 bytes to prevent /// execve failure for very long argument vectors fn format_command(command_and_arguments: &CommandAndArguments) -> OsString { let mut formatted: OsString = command_and_arguments.command.clone().into(); for arg in &command_and_arguments.arguments { if formatted.len() + arg.len() < 4096 { formatted.push(" "); formatted.push(arg); } } formatted } /// Construct sudo-specific environment variables fn add_extra_env( context: &Context, cfg: &Restrictions, sudo_ps1: Option, environment: &mut Environment, ) { // current user environment.insert("SUDO_COMMAND".into(), format_command(&context.command)); environment.insert( "SUDO_UID".into(), context.current_user.uid.to_string().into(), ); environment.insert( "SUDO_GID".into(), context.current_user.gid.to_string().into(), ); environment.insert("SUDO_USER".into(), context.current_user.name.clone().into()); environment.insert("SUDO_HOME".into(), context.current_user.home.clone().into()); // target user environment .entry("MAIL".into()) .or_insert_with(|| format!("{PATH_MAILDIR}/{}", context.target_user.name).into()); // The current SHELL variable should determine the shell to run when -s is passed, if none set use passwd entry environment .entry("SHELL".into()) .or_insert_with(|| context.target_user.shell.clone().into()); // HOME' Set to the home directory of the target user if -i or -H are specified, env_reset or always_set_home are // set in sudoers, or when the -s option is specified and set_home is set in sudoers. // Since we always want to do env_reset -> always set HOME environment .entry("HOME".into()) .or_insert_with(|| context.target_user.home.clone().into()); match ( environment.get(OsStr::new("LOGNAME")), environment.get(OsStr::new("USER")), ) { // Set to the login name of the target user when the -i option is specified, // when the set_logname option is enabled in sudoers, or when the env_reset option // is enabled in sudoers (unless LOGNAME is present in the env_keep list). // Since we always want to do env_reset -> always set these except when present in env (None, None) => { environment.insert("LOGNAME".into(), context.target_user.name.clone().into()); environment.insert("USER".into(), context.target_user.name.clone().into()); } // LOGNAME should be set to the same value as USER if the latter is preserved. (None, Some(user)) => { environment.insert("LOGNAME".into(), user.clone()); } // USER should be set to the same value as LOGNAME if the latter is preserved. (Some(logname), None) => { environment.insert("USER".into(), logname.clone()); } (Some(_), Some(_)) => {} } // Overwrite PATH when secure_path is set if let Some(secure_path) = &cfg.path { // assign path by env path or secure_path configuration environment.insert("PATH".into(), secure_path.into()); } // If the PATH and TERM variables are not preserved from the user's environment, they will be set to default value environment .entry("PATH".into()) .or_insert_with(|| PATH_DEFAULT.into()); // If the TERM variable is not preserved from the user's environment, it will be set to default value environment .entry("TERM".into()) .or_insert_with(|| "unknown".into()); // The SUDO_PS1 variable requires special treatment as the PS1 variable must be set in the // target environment to the same value of SUDO_PS1 if the latter is set. if let Some(sudo_ps1_value) = sudo_ps1 { // set PS1 to the SUDO_PS1 value in the target environment environment.insert("PS1".into(), sudo_ps1_value); } } /// Check a string only contains printable (non-space) characters fn is_printable(input: &[u8]) -> bool { input .iter() .all(|c| c.is_ascii_alphanumeric() || c.is_ascii_punctuation()) } /// The TZ variable is considered unsafe if any of the following are true: /// It consists of a fully-qualified path name, optionally prefixed with a colon (‘:’), that does not match the location of the zoneinfo directory. /// It contains a .. path element. /// It contains white space or non-printable characters. /// It is longer than the value of PATH_MAX. fn is_safe_tz(value: &[u8]) -> bool { let check_value = if value.starts_with(b":") { &value[1..] } else { value }; if check_value.starts_with(b"/") { // clippy 1.79 wants to us to optimise this check away; but we don't know what this will always // be possible; and the compiler is clever enough to do that for us anyway if it can be. #[allow(clippy::const_is_empty)] if !PATH_ZONEINFO.is_empty() { if !check_value.starts_with(PATH_ZONEINFO.as_bytes()) || check_value.get(PATH_ZONEINFO.len()) != Some(&b'/') { return false; } } else { return false; } } !contains_subsequence(check_value, "..".as_bytes()) && is_printable(check_value) && check_value.len() < PATH_MAX as usize } /// Check whether the needle exists in a haystack, in which the haystack is a list of patterns, possibly containing wildcards fn in_table(needle: (&OsStr, &OsStr), haystack: &HashSet) -> bool { haystack.iter().any(|pattern| { if let Some((key, value)) = pattern.split_once('=') { wildcard_match(needle.0.as_bytes(), key.as_bytes()) && wildcard_match(needle.1.as_bytes(), value.as_bytes()) } else { wildcard_match(needle.0.as_bytes(), pattern.as_bytes()) } }) } /// Determine whether a specific environment variable should be kept fn should_keep(key: &OsStr, value: &OsStr, cfg: &Restrictions) -> bool { if value.as_bytes().starts_with("()".as_bytes()) { return false; } if cfg.path.is_some() && key == "PATH" { return false; } if key == "TZ" { return in_table((key, value), cfg.env_keep) || (in_table((key, value), cfg.env_check) && is_safe_tz(value.as_bytes())); } if in_table((key, value), cfg.env_check) { return !value.as_bytes().iter().any(|c| *c == b'%' || *c == b'/'); } in_table((key, value), cfg.env_keep) } /// Construct the final environment from the current one and a sudo context /// see for the original implementation /// see for the original documentation /// /// The HOME, MAIL, SHELL, LOGNAME and USER environment variables are initialized based on the target user /// and the SUDO_* variables are set based on the invoking user. /// /// Additional variables, such as DISPLAY, PATH and TERM, are preserved from the invoking user's /// environment if permitted by the env_check, or env_keep options /// /// If the PATH and TERM variables are not preserved from the user's environment, they will be set to default value /// /// Environment variables with a value beginning with ‘()’ are removed pub fn get_target_environment( current_env: Environment, additional_env: impl IntoIterator, user_override: Vec<(String, String)>, context: &Context, settings: &Restrictions, ) -> Result { // retrieve SUDO_PS1 value to set a PS1 value as additional environment let sudo_ps1 = current_env.get(OsStr::new("SUDO_PS1")).cloned(); // variables preserved from the invoking user's environment by the // env_keep list take precedence over those in the PAM environment let mut environment: HashMap<_, _> = additional_env.into_iter().collect(); environment.extend( current_env .into_iter() .filter(|(key, value)| should_keep(key, value, settings)), ); add_extra_env(context, settings, sudo_ps1, &mut environment); let mut rejected_vars = Vec::new(); for (key, value) in user_override { if should_keep(OsStr::new(&key), OsStr::new(&value), settings) { environment.insert(key.into(), value.into()); } else { rejected_vars.push(key); } } if !rejected_vars.is_empty() { return Err(Error::EnvironmentVar(rejected_vars)); } Ok(environment) } /// Extend the environment with user-supplied info pub fn dangerous_extend(env: &mut Environment, user_override: impl IntoIterator) where S: Into, { env.extend( user_override .into_iter() .map(|(key, value)| (key.into(), value.into())), ) } #[cfg(test)] mod tests { use super::{is_safe_tz, should_keep, PATH_ZONEINFO}; use std::{collections::HashSet, ffi::OsStr}; struct TestConfiguration { keep: HashSet, check: HashSet, path: Option, } impl TestConfiguration { pub fn check_should_keep(&self, key: &str, value: &str, expected: bool) { assert_eq!( should_keep( OsStr::new(key), OsStr::new(value), &crate::sudoers::Restrictions { env_keep: &self.keep, env_check: &self.check, path: self.path.as_deref(), chdir: crate::sudoers::DirChange::Strict(None), trust_environment: false, use_pty: true, umask: crate::exec::Umask::Preserve, #[cfg(feature = "apparmor")] apparmor_profile: None, noexec: false, noninteractive_auth: false, } ), expected, "{} should {}", key, if expected { "be kept" } else { "not be kept" } ); } } #[test] fn test_filtering() { let mut config = TestConfiguration { keep: HashSet::from(["AAP".to_string(), "NOOT".to_string()]), check: HashSet::from(["MIES".to_string(), "TZ".to_string()]), path: Some("/bin".to_string()), }; config.check_should_keep("AAP", "FOO", true); config.check_should_keep("MIES", "BAR", true); config.check_should_keep("AAP", "()=foo", false); config.check_should_keep("TZ", "Europe/Amsterdam", true); config.check_should_keep("TZ", "../Europe/Berlin", false); config.check_should_keep("MIES", "FOO/BAR", false); config.check_should_keep("MIES", "FOO/BAR", false); config.keep.insert("PATH".to_string()); config.check_should_keep("PATH", "FOO", false); config.path = None; config.check_should_keep("PATH", "FOO", true); } #[allow(clippy::useless_format)] #[allow(clippy::bool_assert_comparison)] #[test] fn test_tzinfo() { assert_eq!(is_safe_tz("Europe/Amsterdam".as_bytes()), true); assert_eq!( is_safe_tz(format!("{PATH_ZONEINFO}/Europe/London").as_bytes()), true ); assert_eq!( is_safe_tz(format!(":{PATH_ZONEINFO}/Europe/Amsterdam").as_bytes()), true ); assert_eq!( is_safe_tz(format!("/schaap/Europe/Amsterdam").as_bytes()), false ); assert_eq!( is_safe_tz(format!("{PATH_ZONEINFO}/../Europe/London").as_bytes()), false ); assert_eq!( is_safe_tz(format!("{PATH_ZONEINFO}/../Europe/London").as_bytes()), false ); } } sudo-rs-0.2.10/src/sudo/env/mod.rs000064400000000000000000000001371046102023000147570ustar 00000000000000#![forbid(unsafe_code)] pub mod environment; pub mod wildcard_match; #[cfg(test)] mod tests; sudo-rs-0.2.10/src/sudo/env/tests.rs000064400000000000000000000132011046102023000153360ustar 00000000000000use crate::common::resolve::CurrentUser; use crate::common::{CommandAndArguments, Context}; use crate::exec::Umask; use crate::sudo::{ cli::{SudoAction, SudoRunOptions}, env::environment::{get_target_environment, Environment}, }; use crate::system::interface::{GroupId, UserId}; use crate::system::{Group, Hostname, Process, User}; use std::collections::{HashMap, HashSet}; const TESTS: &str = " > env FOO=BAR HOME=/home/test HOSTNAME=test-ubuntu LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 LC_ALL=en_US.UTF-8 LS_COLORS=cd=40;33;01:*.jpg=01;35:*.mp3=00;36: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PWD=/home/test SHLVL=0 TERM=xterm _=/usr/bin/sudo > sudo env HOSTNAME=test-ubuntu LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 LC_ALL=en_US.UTF-8 LS_COLORS=cd=40;33;01:*.jpg=01;35:*.mp3=00;36: MAIL=/var/mail/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin SHELL=/bin/bash SUDO_COMMAND=/usr/bin/env SUDO_GID=1000 SUDO_UID=1000 SUDO_USER=test SUDO_HOME=/home/test HOME=/root LOGNAME=root USER=root TERM=xterm > sudo -u test env HOSTNAME=test-ubuntu LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 LC_ALL=en_US.UTF-8 LS_COLORS=cd=40;33;01:*.jpg=01;35:*.mp3=00;36: MAIL=/var/mail/test PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin SHELL=/bin/sh SUDO_COMMAND=/usr/bin/env SUDO_GID=1000 SUDO_UID=1000 SUDO_USER=test SUDO_HOME=/home/test HOME=/home/test LOGNAME=test USER=test TERM=xterm "; fn parse_env_commands(input: &str) -> Vec<(&str, Environment)> { input .trim() .split("> ") .filter(|l| !l.is_empty()) .map(|e| { let (cmd, vars) = e.split_once('\n').unwrap(); let vars = vars .lines() .map(|line| line.trim().split_once('=').unwrap()) .map(|(k, v)| (k.into(), v.into())) .collect(); (cmd, vars) }) .collect() } fn create_test_context(sudo_options: SudoRunOptions) -> Context { let path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(); let command = CommandAndArguments::build_from_args(None, sudo_options.positional_args, &path); let current_user = CurrentUser::fake(User { uid: UserId::new(1000), gid: GroupId::new(1000), name: "test".into(), home: "/home/test".into(), shell: "/bin/sh".into(), groups: vec![], }); let current_group = Group { gid: GroupId::new(1000), name: Some("test".to_string()), }; let root_user = User { uid: UserId::ROOT, gid: GroupId::new(0), name: "root".into(), home: "/root".into(), shell: "/bin/bash".into(), groups: vec![], }; let root_group = Group { gid: GroupId::new(0), name: Some("root".to_string()), }; Context { hostname: Hostname::fake("test-ubuntu"), command, current_user: current_user.clone(), target_user: if sudo_options.user.as_deref() == Some("test") { current_user.into() } else { root_user }, target_group: if sudo_options.user.as_deref() == Some("test") { current_group } else { root_group }, launch: crate::common::context::LaunchType::Direct, chdir: sudo_options.chdir, stdin: sudo_options.stdin, prompt: sudo_options.prompt, non_interactive: sudo_options.non_interactive, process: Process::new(), use_session_records: false, use_pty: true, noexec: false, bell: false, umask: Umask::Preserve, noninteractive_auth: false, files_to_edit: vec![], } } fn environment_to_set(environment: Environment) -> HashSet { HashSet::from_iter( environment .into_iter() .map(|(k, v)| format!("{}={}", k.to_str().unwrap(), v.to_str().unwrap())), ) } #[test] fn test_environment_variable_filtering() { let mut parts = parse_env_commands(TESTS); let initial_env = parts.remove(0).1; for (cmd, expected_env) in parts { let options = SudoAction::try_parse_from(cmd.split_whitespace()) .unwrap() .try_into_run() .ok() .unwrap(); let settings = crate::defaults::Settings::default(); let context = create_test_context(options); let resulting_env = get_target_environment( initial_env.clone(), HashMap::new(), Vec::new(), &context, &crate::sudoers::Restrictions { env_keep: settings.env_keep(), env_check: settings.env_check(), path: settings.secure_path(), use_pty: true, chdir: crate::sudoers::DirChange::Strict(None), trust_environment: false, umask: crate::exec::Umask::Preserve, noninteractive_auth: false, #[cfg(feature = "apparmor")] apparmor_profile: None, noexec: false, }, ) .unwrap(); let resulting_env = environment_to_set(resulting_env); let expected_env = environment_to_set(expected_env); let mut diff = resulting_env .symmetric_difference(&expected_env) .collect::>(); diff.sort(); assert!( diff.is_empty(), "\"{cmd}\" results in an environment mismatch:\n{diff:#?}", ); } } sudo-rs-0.2.10/src/sudo/env/wildcard_match.rs000064400000000000000000000056561046102023000171600ustar 00000000000000/// Match a test input with a pattern /// Only wildcard characters (*) in the pattern string have a special meaning: they match on zero or more characters pub(super) fn wildcard_match(test: &[u8], pattern: &[u8]) -> bool { let mut test_index = 0; let mut pattern_index = 0; let mut last_star = None; loop { match (pattern.get(pattern_index), test.get(test_index)) { (Some(p), Some(t)) => { if *p == b'*' { pattern_index += 1; last_star = Some((test_index, pattern_index)); } else if p == t { pattern_index += 1; test_index += 1; } else if let Some((t_index, p_index)) = last_star { test_index = t_index + 1; pattern_index = p_index; last_star = Some((test_index, pattern_index)); } else { return false; } } (None, Some(_)) => { if let Some((t_index, p_index)) = last_star { test_index = t_index + 1; pattern_index = p_index; last_star = Some((test_index, pattern_index)); } else { return false; } } (Some(b'*'), None) => { pattern_index += 1; } (None, None) => { return true; } _ => { return false; } } } } #[cfg(test)] mod tests { use super::wildcard_match; #[test] fn test_wildcard_match() { let tests = vec![ ("foo bar", "foo *", true), ("foo bar", "foo ba*", true), ("foo bar", "foo *ar", true), ("foo bar", "foo *r", true), ("foo bar", "foo *ab", false), ("foo bar", "foo r*", false), ("foo bar", "*oo bar", true), ("foo bar", "*f* bar", true), ("foo bar", "*f bar", false), ("foo ", "foo *", true), ("foo", "foo *", false), ("foo", "foo*", true), ("foo bar", "f*******r", true), ("foo******bar", "f*r", true), ("foo********bar", "foo bar", false), ("#%^$V@#TYH%&rot13%#@$%#$%", "#%^$V@#*t13%#@$%#$%", true), ("#%^$V@#TYH%&rot13%#@$%#$%", "*%^*%&rot*%#$%", true), ("#%^$V@#TYH%&rot13%#@$%#$%", "#%^$V@#TYH%&r*%#@$#$%", false), ("#%^$V@#TYH%&rot13%#@$%#$%", "#%^$V@#*******@$%#$%", true), ]; for (test, pattern, expected) in tests.into_iter() { assert_eq!( wildcard_match(test.as_bytes(), pattern.as_bytes()), expected, "\"{}\" {} match {}", test, if expected { "should" } else { "should not" }, pattern ); } } } sudo-rs-0.2.10/src/sudo/mod.rs000064400000000000000000000116341046102023000141730ustar 00000000000000#![deny(unsafe_code)] use crate::common::resolve::CurrentUser; use crate::common::Error; use crate::log::dev_info; use crate::system::interface::UserId; use crate::system::timestamp::RecordScope; use crate::system::User; use crate::system::{time::Duration, timestamp::SessionRecordFile, Process}; #[cfg(test)] pub(crate) use cli::SudoAction; #[cfg(not(test))] use cli::SudoAction; use std::path::PathBuf; mod cli; pub(crate) use cli::{SudoEditOptions, SudoListOptions, SudoRunOptions, SudoValidateOptions}; #[cfg(feature = "sudoedit")] mod edit; pub(crate) mod diagnostic; mod env; mod pam; mod pipeline; #[cfg_attr(not(feature = "dev"), allow(dead_code))] fn unstable_warning() { let check_var = std::env::var("SUDO_RS_IS_UNSTABLE").unwrap_or_else(|_| "".to_string()); if check_var != "I accept that my system may break unexpectedly" { eprintln_ignore_io_error!( "WARNING! Sudo-rs is compiled with development logs on, which means it is less secure and could potentially break your system. We recommend that you do not run this on any production environment. To turn off this warning and use sudo-rs you need to set the environment variable SUDO_RS_IS_UNSTABLE to the value `I accept that my system may break unexpectedly`." ); std::process::exit(1); } } const VERSION: &str = if let Some(version_override) = std::option_env!("SUDO_RS_VERSION") { version_override } else { std::env!("CARGO_PKG_VERSION") }; pub(crate) fn candidate_sudoers_file() -> PathBuf { let mut path = if cfg!(target_os = "freebsd") { option_env!("LOCALBASE").unwrap_or("/usr/local").into() } else { PathBuf::from("/") }; path.push("etc/sudoers-rs"); if !path.exists() { path.set_file_name("sudoers"); }; dev_info!("Running with {} file", path.display()); path } fn sudo_process() -> Result<(), Error> { crate::log::SudoLogger::new("sudo: ").into_global_logger(); dev_info!("development logs are enabled"); self_check()?; let usage_msg: &str; let long_help: fn() -> String; if cli::is_sudoedit(std::env::args().next()) { usage_msg = cli::help_edit::USAGE_MSG; long_help = cli::help_edit::long_help_message; } else { usage_msg = cli::help::USAGE_MSG; long_help = cli::help::long_help_message; } // parse cli options match SudoAction::from_env() { Ok(action) => match action { SudoAction::Help(_) => { eprintln_ignore_io_error!("{}", long_help()); std::process::exit(0); } SudoAction::Version(_) => { eprintln_ignore_io_error!("sudo-rs {VERSION}"); std::process::exit(0); } SudoAction::RemoveTimestamp(_) => { let user = CurrentUser::resolve()?; let mut record_file = SessionRecordFile::open_for_user(&user, Duration::seconds(0))?; record_file.reset()?; Ok(()) } SudoAction::ResetTimestamp(_) => { if let Some(scope) = RecordScope::for_process(&Process::new()) { let user = CurrentUser::resolve()?; let mut record_file = SessionRecordFile::open_for_user(&user, Duration::seconds(0))?; record_file.disable(scope)?; } Ok(()) } SudoAction::Validate(options) => pipeline::run_validate(options), SudoAction::Run(options) => { // special case for when no command is given if options.positional_args.is_empty() && !options.shell && !options.login { eprintln_ignore_io_error!("{}", usage_msg); std::process::exit(1); } else { #[cfg(feature = "dev")] unstable_warning(); pipeline::run(options) } } SudoAction::List(options) => pipeline::run_list(options), #[cfg(feature = "sudoedit")] SudoAction::Edit(options) => pipeline::run_edit(options), #[cfg(not(feature = "sudoedit"))] SudoAction::Edit(_) => { eprintln_ignore_io_error!("error: `--edit` flag has not yet been implemented"); std::process::exit(1); } }, Err(e) => { eprintln_ignore_io_error!("{e}\n{}", usage_msg); std::process::exit(1); } } } fn self_check() -> Result<(), Error> { let euid = User::effective_uid(); if euid == UserId::ROOT { Ok(()) } else { Err(Error::SelfCheck) } } pub fn main() { match sudo_process() { Ok(()) => (), Err(error) => { if !error.is_silent() { diagnostic::diagnostic!("{error}"); } std::process::exit(1); } } } sudo-rs-0.2.10/src/sudo/pam.rs000064400000000000000000000121351046102023000141660ustar 00000000000000use std::ffi::OsString; use crate::common::context::LaunchType; use crate::common::error::Error; use crate::log::{dev_info, user_warn}; use crate::pam::{PamContext, PamError, PamErrorType, PamResult}; use crate::system::{term::current_tty_name, time::Duration}; pub(super) struct InitPamArgs<'a> { pub(super) launch: LaunchType, pub(super) use_stdin: bool, pub(super) bell: bool, pub(super) non_interactive: bool, pub(super) password_feedback: bool, pub(super) password_timeout: Option, pub(super) auth_prompt: Option, pub(super) auth_user: &'a str, pub(super) requesting_user: &'a str, pub(super) target_user: &'a str, pub(super) hostname: &'a str, } pub(super) fn init_pam( InitPamArgs { launch, use_stdin, bell, non_interactive, password_feedback, password_timeout, auth_prompt, auth_user, requesting_user, target_user, hostname, }: InitPamArgs, ) -> PamResult { let service_name = match launch { LaunchType::Login if cfg!(feature = "pam-login") => "sudo-i", LaunchType::Login | LaunchType::Shell | LaunchType::Direct => "sudo", }; let mut pam = PamContext::new_cli( "sudo", service_name, use_stdin, bell, non_interactive, password_feedback, password_timeout, Some(auth_user), )?; pam.mark_silent(matches!(launch, LaunchType::Direct)); pam.mark_allow_null_auth_token(false); pam.set_requesting_user(requesting_user)?; match auth_prompt.as_deref() { None => {} Some("") => pam.set_auth_prompt(None), Some(auth_prompt) => { let mut final_prompt = String::new(); let mut chars = auth_prompt.chars(); while let Some(c) = chars.next() { if c != '%' { final_prompt.push(c); continue; } match chars.next() { Some('H') => final_prompt.push_str(hostname), Some('h') => final_prompt .push_str(hostname.split_once('.').map(|x| x.0).unwrap_or(hostname)), Some('p') => final_prompt.push_str(auth_user), Some('U') => final_prompt.push_str(target_user), Some('u') => final_prompt.push_str(requesting_user), Some('%') | None => final_prompt.push('%'), Some(c) => { final_prompt.push('%'); final_prompt.push(c); } } } pam.set_auth_prompt(Some(final_prompt)); } } // attempt to set the TTY this session is communicating on if let Ok(pam_tty) = current_tty_name() { pam.set_tty(&pam_tty)?; } Ok(pam) } pub(super) fn attempt_authenticate( pam: &mut PamContext, auth_user: &str, non_interactive: bool, max_tries: u16, ) -> Result<(), Error> { // Reject zero upfront so we don't ask for a password once when max_tries is 0. if max_tries == 0 { return Err(Error::MaxAuthAttempts(0)); } let mut current_try = 0; loop { current_try += 1; match pam.authenticate(auth_user) { // there was no error, so authentication succeeded Ok(_) => break, // maxtries was reached, pam does not allow any more tries Err(PamError::Pam(PamErrorType::MaxTries)) => { return Err(Error::MaxAuthAttempts(current_try)); } // there was an authentication error, we can retry Err(PamError::Pam(PamErrorType::AuthError | PamErrorType::ConversationError)) => { if current_try >= max_tries { return Err(Error::MaxAuthAttempts(current_try)); } else if non_interactive { return Err(Error::InteractionRequired); } else { user_warn!("Authentication failed, try again."); } } // there was another pam error, return the error Err(e) => { return Err(e.into()); } } } Ok(()) } pub(super) fn pre_exec( pam: &mut PamContext, target_user: &str, ) -> Result, Error> { // make sure that the user that needed to authenticate has a valid token pam.validate_account_or_change_auth_token()?; // check what the current user in PAM is let user = pam.get_user()?; if user != target_user { // switch pam over to the target user pam.set_user(target_user)?; // make sure that credentials are loaded for the target user // errors are ignored because not all modules support this functionality if let Err(e) = pam.credentials_reinitialize() { dev_info!( "PAM gave an error while trying to re-initialize credentials: {:?}", e ); } } pam.open_session()?; let env_vars = pam.env()?; Ok(env_vars) } sudo-rs-0.2.10/src/sudo/pipeline/edit.rs000064400000000000000000000044351046102023000161470ustar 00000000000000use std::process::exit; use super::super::cli::SudoEditOptions; use crate::common::{Context, Error}; use crate::exec::ExitReason; use crate::log::{user_error, user_info}; use crate::sudoers::Authorization; use crate::system::audit; pub fn run_edit(edit_opts: SudoEditOptions) -> Result<(), Error> { let policy = super::read_sudoers()?; let mut context = Context::from_edit_opts(edit_opts)?; let policy = super::judge(policy, &context)?; let Authorization::Allowed(auth, controls) = policy.authorization() else { return Err(Error::Authorization(context.current_user.name.to_string())); }; super::apply_policy_to_context(&mut context, &controls)?; let mut pam_context = super::auth_and_update_record_file(&context, auth)?; let pid = context.process.pid; let mut opened_files = Vec::with_capacity(context.files_to_edit.len()); for (path, arg) in context.files_to_edit.iter().zip(&context.command.arguments) { if let Some(path) = path { match audit::secure_open_for_sudoedit( path, &context.current_user, &context.target_user, &context.target_group, ) { Ok(file) => opened_files.push((path, file)), // ErrorKind::FilesystemLoop was only stabilized in 1.83 Err(error) if error.raw_os_error() == Some(libc::ELOOP) => { user_error!("{arg}: editing symbolic links is not permitted") } Err(error) => user_error!("error opening {arg}: {error}"), } } else { user_error!("invalid path: {arg}"); } } if opened_files.len() != context.files_to_edit.len() { user_info!("please address the problems and try again"); return Err(Error::Silent); } // run command and return corresponding exit code let command_exit_reason = { super::log_command_execution(&context); let editor = policy.preferred_editor(); crate::sudo::edit::edit_files(&editor, opened_files) }; pam_context.close_session(); match command_exit_reason? { ExitReason::Code(code) => exit(code), ExitReason::Signal(signal) => { crate::system::kill(pid, signal)?; } } Ok(()) } sudo-rs-0.2.10/src/sudo/pipeline/list.rs000064400000000000000000000113771046102023000162000ustar 00000000000000use std::{borrow::Cow, ops::ControlFlow, path::Path}; use crate::{ common::{Context, Error}, sudo::cli::SudoListOptions, sudoers::{Authorization, ListRequest, Request, Sudoers}, system::User, }; use super::auth_and_update_record_file; pub(in crate::sudo) fn run_list(cmd_opts: SudoListOptions) -> Result<(), Error> { let verbose_list_mode = cmd_opts.list.is_verbose(); let other_user = cmd_opts .other_user .as_ref() .map(|username| { User::from_name(username.as_cstr())? .ok_or_else(|| Error::UserNotFound(username.clone().into())) }) .transpose()?; let original_command = cmd_opts.positional_args.first().cloned(); let mut sudoers = super::read_sudoers()?; let context = Context::from_list_opts(cmd_opts, &mut sudoers)?; if auth_invoking_user(&context, &mut sudoers, &original_command, &other_user)?.is_break() { return Ok(()); } if let Some(original_command) = original_command { check_sudo_command_perms(&original_command, context, &other_user, &mut sudoers)?; } else { let inspected_user = other_user.as_ref().unwrap_or(&context.current_user); let mut matching_entries = sudoers .matching_entries(inspected_user, &context.hostname) .peekable(); if matching_entries.peek().is_some() { println_ignore_io_error!( "User {} may run the following commands on {}:", inspected_user.name, context.hostname ); for entry in matching_entries { if verbose_list_mode { let entry = entry.verbose(); println_ignore_io_error!("{entry}"); } else { println_ignore_io_error!("{entry}"); } } } else { println_ignore_io_error!( "User {} is not allowed to run sudo on {}.", inspected_user.name, context.hostname ); } } Ok(()) } fn auth_invoking_user( context: &Context, sudoers: &mut Sudoers, original_command: &Option, other_user: &Option, ) -> Result, Error> { let inspected_user = other_user.as_ref().unwrap_or(&context.current_user); let list_request = ListRequest { inspected_user, target_user: &context.target_user, target_group: &context.target_group, }; match sudoers.check_list_permission(&*context.current_user, &context.hostname, list_request) { Authorization::Allowed(auth, ()) => { auth_and_update_record_file(context, auth)?; Ok(ControlFlow::Continue(())) } Authorization::Forbidden => { let command = if other_user.is_none() { "sudo".into() } else { format_list_command(original_command) }; Err(Error::NotAllowed { username: context.current_user.name.clone(), command, hostname: context.hostname.clone(), other_user: other_user.as_ref().map(|user| &user.name).cloned(), }) } } } fn check_sudo_command_perms( original_command: &str, context: Context, other_user: &Option, sudoers: &mut Sudoers, ) -> Result<(), Error> { let user = other_user.as_ref().unwrap_or(&context.current_user); let request = Request { user: &context.target_user, group: &context.target_group, command: &context.command.command, arguments: &context.command.arguments, }; let judgement = sudoers.check(user, &context.hostname, request); if let Authorization::Forbidden = judgement.authorization() { return Err(Error::Silent); } else { if !context.command.resolved { return Err(Error::CommandNotFound(context.command.command)); } let command_is_relative_path = original_command.contains('/') && !Path::new(&original_command).is_absolute(); let command: Cow<_> = if command_is_relative_path { original_command.into() } else { let resolved_command = &context.command.command; resolved_command.display().to_string().into() }; if context.command.arguments.is_empty() { println_ignore_io_error!("{command}"); } else { println_ignore_io_error!("{command} {}", context.command.arguments.join(" ")); } } Ok(()) } fn format_list_command(original_command: &Option) -> Cow<'static, str> { if let Some(original_command) = original_command { format!("list {original_command}").into() } else { "list".into() } } sudo-rs-0.2.10/src/sudo/pipeline.rs000064400000000000000000000242261046102023000152220ustar 00000000000000use std::ffi::OsStr; use std::process::exit; use super::cli::{SudoRunOptions, SudoValidateOptions}; use super::diagnostic; use crate::common::resolve::{AuthUser, CurrentUser}; use crate::common::{Context, Error}; use crate::exec::ExitReason; use crate::log::{auth_info, auth_warn}; use crate::pam::PamContext; use crate::sudo::env::environment; use crate::sudo::pam::{attempt_authenticate, init_pam, pre_exec, InitPamArgs}; use crate::sudo::Duration; use crate::sudoers::{ AuthenticatingUser, Authentication, Authorization, DirChange, Judgement, Restrictions, Sudoers, }; use crate::system::term::current_tty_name; use crate::system::timestamp::{RecordScope, SessionRecordFile, TouchResult}; use crate::system::{escape_os_str_lossy, Process}; mod list; pub(super) use list::run_list; #[cfg(feature = "sudoedit")] mod edit; #[cfg(feature = "sudoedit")] pub(super) use edit::run_edit; fn read_sudoers() -> Result { let sudoers_path = &super::candidate_sudoers_file(); let (sudoers, syntax_errors) = Sudoers::open(sudoers_path).map_err(|e| Error::Configuration(format!("{e}")))?; for crate::sudoers::Error { source, location, message, } in syntax_errors { let path = source.as_deref().unwrap_or(sudoers_path); diagnostic::diagnostic!("{message}", path @ location); } Ok(sudoers) } fn judge(mut policy: Sudoers, context: &Context) -> Result { Ok(policy.check( &*context.current_user, &context.hostname, crate::sudoers::Request { user: &context.target_user, group: &context.target_group, command: &context.command.command, arguments: &context.command.arguments, }, )) } pub fn run(mut cmd_opts: SudoRunOptions) -> Result<(), Error> { let mut policy = read_sudoers()?; let user_requested_env_vars = std::mem::take(&mut cmd_opts.env_var_list); let mut context = Context::from_run_opts(cmd_opts, &mut policy)?; let policy = judge(policy, &context)?; let Authorization::Allowed(auth, controls) = policy.authorization() else { return Err(Error::Authorization(context.current_user.name.to_string())); }; apply_policy_to_context(&mut context, &controls)?; let mut pam_context = auth_and_update_record_file(&context, auth)?; // build environment let additional_env = pre_exec(&mut pam_context, &context.target_user.name)?; let current_env = environment::system_environment(); let (checked_vars, trusted_vars) = if controls.trust_environment { (vec![], user_requested_env_vars) } else { (user_requested_env_vars, vec![]) }; let mut target_env = environment::get_target_environment( current_env, additional_env, checked_vars, &context, &controls, )?; environment::dangerous_extend(&mut target_env, trusted_vars); let pid = context.process.pid; // prepare switch of apparmor profile #[cfg(feature = "apparmor")] if let Some(profile) = controls.apparmor_profile { crate::apparmor::set_profile_for_next_exec(&profile) .map_err(|err| Error::AppArmor(profile, err))?; } // run command and return corresponding exit code let command_exit_reason = if context.command.resolved { log_command_execution(&context); crate::exec::run_command( context .try_as_run_options() .map_err(|io_error| Error::Io(Some(context.command.command.clone()), io_error))?, target_env, ) .map_err(|io_error| Error::Io(Some(context.command.command), io_error)) } else { Err(Error::CommandNotFound(context.command.command)) }; pam_context.close_session(); match command_exit_reason? { ExitReason::Code(code) => exit(code), ExitReason::Signal(signal) => { crate::system::kill(pid, signal)?; } } Ok(()) } pub fn run_validate(cmd_opts: SudoValidateOptions) -> Result<(), Error> { let mut policy = read_sudoers()?; let context = Context::from_validate_opts(cmd_opts)?; match policy.check_validate_permission(&*context.current_user, &context.hostname) { Authorization::Forbidden => { return Err(Error::Authorization(context.current_user.name.to_string())); } Authorization::Allowed(auth, ()) => { auth_and_update_record_file(&context, auth)?; } } Ok(()) } fn auth_and_update_record_file( context: &Context, Authentication { must_authenticate, prior_validity, allowed_attempts, password_timeout, ref credential, pwfeedback, }: Authentication, ) -> Result { let auth_user = match credential { AuthenticatingUser::InvokingUser => { AuthUser::from_current_user(context.current_user.clone()) } AuthenticatingUser::Root => AuthUser::resolve_root_for_rootpw()?, AuthenticatingUser::TargetUser => { AuthUser::from_user_for_targetpw(context.target_user.clone()) } }; let scope = RecordScope::for_process(&Process::new()); let mut auth_status = determine_auth_status( must_authenticate, context.use_session_records, scope, &context.current_user, &auth_user, prior_validity, ); let mut pam_context = init_pam(InitPamArgs { launch: context.launch, use_stdin: context.stdin, bell: context.bell, non_interactive: context.non_interactive, password_feedback: pwfeedback, password_timeout, auth_prompt: context.prompt.clone(), auth_user: &auth_user.name, requesting_user: &context.current_user.name, target_user: &context.target_user.name, hostname: &context.hostname, })?; if auth_status.must_authenticate { if context.non_interactive && !context.noninteractive_auth { return Err(Error::InteractionRequired); } attempt_authenticate( &mut pam_context, &auth_user.name, context.non_interactive, allowed_attempts, )?; if let (Some(record_file), Some(scope)) = (&mut auth_status.record_file, scope) { match record_file.create(scope, &auth_user) { Ok(_) => (), Err(e) => { auth_warn!("Could not update session record file with new record: {e}"); } } } } Ok(pam_context) } fn apply_policy_to_context( context: &mut Context, controls: &Restrictions, ) -> Result<(), crate::common::Error> { // see if the chdir flag is permitted match controls.chdir { DirChange::Any => {} DirChange::Strict(optdir) => { if let Some(chdir) = &context.chdir { return Err(Error::ChDirNotAllowed { chdir: chdir.clone(), command: context.command.command.clone(), }); } else { context.chdir = optdir.cloned(); } } } // expand tildes in the path with the users home directory if let Some(dir) = context.chdir.take() { context.chdir = Some(dir.expand_tilde_in_path(&context.target_user.name)?) } // in case the user could set these from the commandline, something more fancy // could be needed, but here we copy these -- perhaps we should split up the Context type context.use_pty = controls.use_pty; context.noexec = controls.noexec; context.umask = controls.umask; context.noninteractive_auth = controls.noninteractive_auth; Ok(()) } /// This should determine what the authentication status for the given record /// match limit and origin/target user from the context is. fn determine_auth_status( must_policy_authenticate: bool, use_session_records: bool, record_for: Option, current_user: &CurrentUser, auth_user: &AuthUser, prior_validity: Duration, ) -> AuthStatus { if !must_policy_authenticate { AuthStatus::new(false, None) } else if let (true, Some(record_for)) = (use_session_records, record_for) { match SessionRecordFile::open_for_user(current_user, prior_validity) { Ok(mut sr) => { match sr.touch(record_for, auth_user) { // if a record was found and updated within the timeout, we do not need to authenticate Ok(TouchResult::Updated { .. }) => AuthStatus::new(false, Some(sr)), Ok(TouchResult::NotFound | TouchResult::Outdated { .. }) => { AuthStatus::new(true, Some(sr)) } Err(e) => { auth_warn!("Unexpected error while reading session information: {e}"); AuthStatus::new(true, None) } } } // if we cannot open the session record file we just assume there is none and continue as normal Err(e) => { auth_warn!("Could not use session information: {e}"); AuthStatus::new(true, None) } } } else { AuthStatus::new(true, None) } } struct AuthStatus { must_authenticate: bool, record_file: Option, } impl AuthStatus { fn new(must_authenticate: bool, record_file: Option) -> AuthStatus { AuthStatus { must_authenticate, record_file, } } } fn log_command_execution(context: &Context) { let tty_info = if let Ok(tty_name) = current_tty_name() { format!("TTY={} ;", escape_os_str_lossy(&tty_name)) } else { String::from("") }; let pwd = escape_os_str_lossy( std::env::current_dir() .as_ref() .map(|s| s.as_os_str()) .unwrap_or_else(|_| OsStr::new("unknown")), ); let user = context.target_user.name.escape_debug().collect::(); auth_info!( "{} : {} PWD={} ; USER={} ; COMMAND={}", &context.current_user.name, tty_info, pwd, user, &context.command ); } sudo-rs-0.2.10/src/sudoers/ast.rs000064400000000000000000000741661046102023000147260ustar 00000000000000use super::ast_names::UserFriendly; use super::basic_parser::*; use super::char_stream::advance; use super::tokens::*; use crate::common::SudoString; use crate::common::{ HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_VALUE_2, HARDENED_ENUM_VALUE_3, HARDENED_ENUM_VALUE_4, }; use crate::defaults; /// The Sudoers file allows negating items with the exclamation mark. #[cfg_attr(test, derive(Debug, Eq))] #[derive(Clone, PartialEq)] #[repr(u32)] pub enum Qualified { Allow(T) = HARDENED_ENUM_VALUE_0, Forbid(T) = HARDENED_ENUM_VALUE_1, } impl Qualified { #[cfg(test)] pub fn as_allow(&self) -> Option<&T> { if let Self::Allow(v) = self { Some(v) } else { None } } } /// Type aliases; many items can be replaced by ALL, aliases, and negated. pub type Spec = Qualified>; pub type SpecList = Vec>; /// A generic mapping function (only used for turning `Spec` into `Spec`) impl Spec { pub fn map(self, f: impl Fn(T) -> U) -> Spec { let transform = |meta| match meta { Meta::All => Meta::All, Meta::Alias(alias) => Meta::Alias(alias), Meta::Only(x) => Meta::Only(f(x)), }; match self { Qualified::Allow(x) => Qualified::Allow(transform(x)), Qualified::Forbid(x) => Qualified::Forbid(transform(x)), } } } /// An identifier is a name or a #number #[cfg_attr(test, derive(Clone, Debug, PartialEq, Eq))] #[repr(u32)] pub enum Identifier { Name(SudoString) = HARDENED_ENUM_VALUE_0, ID(u32) = HARDENED_ENUM_VALUE_1, } /// A userspecifier is either a username, or a (non-unix) group name, or netgroup #[cfg_attr(test, derive(Clone, Debug, PartialEq, Eq))] #[repr(u32)] pub enum UserSpecifier { User(Identifier) = HARDENED_ENUM_VALUE_0, Group(Identifier) = HARDENED_ENUM_VALUE_1, NonunixGroup(Identifier) = HARDENED_ENUM_VALUE_2, } /// The RunAs specification consists of a (possibly empty) list of userspecifiers, followed by a (possibly empty) list of groups. pub struct RunAs { pub users: SpecList, pub groups: SpecList, } // `sudo -l l` calls this the `authenticate` option #[derive(Copy, Clone, Default, PartialEq)] #[cfg_attr(test, derive(Debug, Eq))] #[repr(u32)] pub enum Authenticate { #[default] None = HARDENED_ENUM_VALUE_0, // PASSWD: Passwd = HARDENED_ENUM_VALUE_1, // NOPASSWD: Nopasswd = HARDENED_ENUM_VALUE_2, } #[derive(Copy, Clone, Default, PartialEq)] #[cfg_attr(test, derive(Debug, Eq))] #[repr(u32)] pub enum EnvironmentControl { #[default] Implicit = HARDENED_ENUM_VALUE_0, // PASSWD: Setenv = HARDENED_ENUM_VALUE_1, // NOPASSWD: Nosetenv = HARDENED_ENUM_VALUE_2, } #[derive(Copy, Clone, Default, PartialEq)] #[cfg_attr(test, derive(Debug, Eq))] #[repr(u32)] pub enum ExecControl { #[default] Implicit = HARDENED_ENUM_VALUE_0, // PASSWD: Exec = HARDENED_ENUM_VALUE_1, // NOPASSWD: Noexec = HARDENED_ENUM_VALUE_2, } /// Commands in /etc/sudoers can have attributes attached to them, such as NOPASSWD, NOEXEC, ... #[derive(Default, Clone, PartialEq)] #[cfg_attr(test, derive(Debug, Eq))] pub struct Tag { pub(super) authenticate: Authenticate, pub(super) cwd: Option, pub(super) env: EnvironmentControl, pub(super) apparmor_profile: Option, pub(super) noexec: ExecControl, } impl Tag { pub fn needs_passwd(&self) -> bool { matches!(self.authenticate, Authenticate::None | Authenticate::Passwd) } } /// Commands with attached attributes. pub struct CommandSpec(pub Vec, pub Spec); /// The main AST object for one sudoer-permission line type PairVec = Vec<(A, Vec)>; pub struct PermissionSpec { pub users: SpecList, pub permissions: PairVec, (Option, CommandSpec)>, } pub type Defs = Vec>; pub struct Def(pub String, pub SpecList); /// AST object for directive specifications (aliases, arguments, etc) #[repr(u32)] pub enum Directive { UserAlias(Defs) = HARDENED_ENUM_VALUE_0, HostAlias(Defs) = HARDENED_ENUM_VALUE_1, CmndAlias(Defs) = HARDENED_ENUM_VALUE_2, RunasAlias(Defs) = HARDENED_ENUM_VALUE_3, Defaults(Vec, ConfigScope) = HARDENED_ENUM_VALUE_4, } /// AST object for the 'context' (host, user, cmnd, runas) of a Defaults directive #[repr(u32)] pub enum ConfigScope { // "Defaults entries are parsed in the following order: // generic, host and user Defaults first, then runas Defaults and finally command defaults." Generic = HARDENED_ENUM_VALUE_0, Host(SpecList) = HARDENED_ENUM_VALUE_1, User(SpecList) = HARDENED_ENUM_VALUE_2, RunAs(SpecList) = HARDENED_ENUM_VALUE_3, Command(SpecList) = HARDENED_ENUM_VALUE_4, } /// The Sudoers file can contain permissions and directives #[repr(u32)] pub enum Sudo { Spec(PermissionSpec) = HARDENED_ENUM_VALUE_0, Decl(Directive) = HARDENED_ENUM_VALUE_1, Include(String, Span) = HARDENED_ENUM_VALUE_2, IncludeDir(String, Span) = HARDENED_ENUM_VALUE_3, LineComment = HARDENED_ENUM_VALUE_4, } impl Sudo { #[cfg(test)] pub fn is_spec(&self) -> bool { matches!(self, Self::Spec(..)) } #[cfg(test)] pub fn is_decl(&self) -> bool { matches!(self, Self::Decl(..)) } #[cfg(test)] pub fn is_line_comment(&self) -> bool { matches!(self, Self::LineComment) } #[cfg(test)] pub fn is_include(&self) -> bool { matches!(self, Self::Include(..)) } #[cfg(test)] pub fn is_include_dir(&self) -> bool { matches!(self, Self::IncludeDir(..)) } #[cfg(test)] pub fn as_include(&self) -> &str { if let Self::Include(v, _) = self { v } else { panic!() } } #[cfg(test)] pub fn as_spec(&self) -> Option<&PermissionSpec> { if let Self::Spec(v) = self { Some(v) } else { None } } } /// grammar: /// ```text /// identifier = name /// | # /// ``` impl Parse for Identifier { fn parse(stream: &mut CharStream) -> Parsed { if stream.eat_char('#') { let Digits(guid) = expect_nonterminal(stream)?; make(Identifier::ID(guid)) } else { let Username(name) = try_nonterminal(stream)?; make(Identifier::Name(name)) } } } impl Many for Identifier {} /// grammar: /// ```text /// qualified = T | "!", qualified /// ``` /// /// This computes the correct negation with multiple exclamation marks in the parsing stage so we /// are not bothered by it later. impl Parse for Qualified { fn parse(stream: &mut CharStream) -> Parsed { if is_syntax('!', stream)? { let mut neg = true; while is_syntax('!', stream)? { neg = !neg; } let ident = expect_nonterminal(stream)?; if neg { make(Qualified::Forbid(ident)) } else { make(Qualified::Allow(ident)) } } else { let ident = try_nonterminal(stream)?; make(Qualified::Allow(ident)) } } } impl Many for Qualified { const SEP: char = T::SEP; const LIMIT: usize = T::LIMIT; } /// Helper function for parsing `Meta` things where T is not a token fn parse_meta( stream: &mut CharStream, embed: impl FnOnce(SudoString) -> T, ) -> Parsed> { if let Some(meta) = try_nonterminal(stream)? { make(match meta { Meta::All => Meta::All, Meta::Alias(alias) => Meta::Alias(alias), Meta::Only(Username(name)) => Meta::Only(embed(name)), }) } else { make(Meta::Only(T::parse(stream)?)) } } /// Since Identifier is not a token, add the parser for `Meta` impl Parse for Meta { fn parse(stream: &mut CharStream) -> Parsed { parse_meta(stream, Identifier::Name) } } /// grammar: /// ```text /// userspec = identifier /// | %identifier /// | %:identifier /// | +netgroup /// ``` impl Parse for UserSpecifier { fn parse(stream: &mut CharStream) -> Parsed { fn parse_user(stream: &mut CharStream) -> Parsed { let userspec = if stream.eat_char('%') { let ctor = if stream.eat_char(':') { UserSpecifier::NonunixGroup } else { UserSpecifier::Group }; // in this case we must fail 'hard', since input has been consumed ctor(expect_nonterminal(stream)?) } else if stream.eat_char('+') { // TODO Netgroups unrecoverable!(stream, "netgroups are not supported yet"); } else { // in this case we must fail 'softly', since no input has been consumed yet UserSpecifier::User(try_nonterminal(stream)?) }; make(userspec) } // if we see a quote, first parse the quoted text as a token and then // re-parse whatever we found inside; this is a lazy solution but it works if stream.eat_char('"') { let begin_pos = stream.get_pos(); let Unquoted(text, _): Unquoted = expect_nonterminal(stream)?; let result = parse_user(&mut CharStream::new_with_pos(&text, begin_pos))?; expect_syntax('"', stream)?; Ok(result) } else { parse_user(stream) } } } impl Many for UserSpecifier {} /// UserSpecifier is not a token, implement the parser for `Meta` impl Parse for Meta { fn parse(stream: &mut CharStream) -> Parsed { parse_meta(stream, |name| UserSpecifier::User(Identifier::Name(name))) } } /// grammar: /// ```text /// runas = "(", userlist, (":", grouplist?)?, ")" /// ``` impl Parse for RunAs { fn parse(stream: &mut CharStream) -> Parsed { try_syntax('(', stream)?; let users = try_nonterminal(stream).unwrap_or_default(); let groups = maybe(try_syntax(':', stream).and_then(|_| try_nonterminal(stream)))? .unwrap_or_default(); expect_syntax(')', stream)?; make(RunAs { users, groups }) } } /// Implementing the trait Parse for `Meta`. Wrapped in an own object to avoid /// conflicting with a potential future generic parse definition for [Meta]. /// /// The reason for combining a parser for these two unrelated categories is that this is one spot /// where the sudoer grammar isn't nicely LL(1); so at the same place where "NOPASSWD" can appear, /// we could also see "ALL". struct MetaOrTag(Meta); /// A `Modifier` is something that updates the `Tag`. pub type Modifier = Box; // note: at present, "ALL" can be distinguished from a tag using a lookup of 1, since no tag starts with an "A"; but this feels like hanging onto // the parseability by a thread (although the original sudo also has some ugly parts, like 'sha224' being an illegal user name). // to be more general, we impl Parse for Meta so a future tag like "AFOOBAR" can be added with no problem impl Parse for MetaOrTag { fn parse(stream: &mut CharStream) -> Parsed { use Meta::*; let start_pos = stream.get_pos(); let AliasName(keyword) = try_nonterminal(stream)?; let mut switch = |modifier: fn(&mut Tag)| { expect_syntax(':', stream)?; make(Box::new(modifier)) }; let result: Modifier = match keyword.as_str() { // we do not support this, and that should make sudo-rs "fail safe" "INTERCEPT" => unrecoverable!( pos = start_pos, stream, "INTERCEPT is not supported by sudo-rs" ), // this is less fatal "LOG_INPUT" | "NOLOG_INPUT" | "LOG_OUTPUT" | "NOLOG_OUTPUT" | "MAIL" | "NOMAIL" | "FOLLOW" => { eprintln_ignore_io_error!( "warning: {} tags are ignored by sudo-rs", keyword.as_str() ); switch(|_| {})? } // 'NOFOLLOW' and 'NOINTERCEPT' are the default behaviour. "NOFOLLOW" | "NOINTERCEPT" => switch(|_| {})?, "EXEC" => switch(|tag| tag.noexec = ExecControl::Exec)?, "NOEXEC" => switch(|tag| tag.noexec = ExecControl::Noexec)?, "SETENV" => switch(|tag| tag.env = EnvironmentControl::Setenv)?, "NOSETENV" => switch(|tag| tag.env = EnvironmentControl::Nosetenv)?, "PASSWD" => switch(|tag| tag.authenticate = Authenticate::Passwd)?, "NOPASSWD" => switch(|tag| tag.authenticate = Authenticate::Nopasswd)?, "CWD" => { expect_syntax('=', stream)?; let path: ChDir = expect_nonterminal(stream)?; Box::new(move |tag| tag.cwd = Some(path.clone())) } // we do not support these, and that should make sudo-rs "fail safe" spec @ ("CHROOT" | "TIMEOUT" | "NOTBEFORE" | "NOTAFTER") => unrecoverable!( pos = start_pos, stream, "{spec} is not supported by sudo-rs" ), "ROLE" | "TYPE" => unrecoverable!( pos = start_pos, stream, "SELinux role based access control is not yet supported by sudo-rs" ), "APPARMOR_PROFILE" => { expect_syntax('=', stream)?; let StringParameter(profile) = expect_nonterminal(stream)?; Box::new(move |tag| tag.apparmor_profile = Some(profile.clone())) } "ALL" => return make(MetaOrTag(All)), alias => { if is_syntax('=', stream)? { unrecoverable!(pos = start_pos, stream, "unsupported modifier '{}'", alias); } else { return make(MetaOrTag(Alias(alias.to_string()))); } } }; make(MetaOrTag(Only(result))) } } /// grammar: /// ```text /// commandspec = [tag modifiers]*, command /// ``` impl Parse for CommandSpec { fn parse(stream: &mut CharStream) -> Parsed { use Qualified::Allow; let mut tags = vec![]; while let Some(MetaOrTag(keyword)) = try_nonterminal(stream)? { match keyword { Meta::Only(modifier) => tags.push(modifier), Meta::All => return make(CommandSpec(tags, Allow(Meta::All))), Meta::Alias(name) => return make(CommandSpec(tags, Allow(Meta::Alias(name)))), } if tags.len() > Identifier::LIMIT { unrecoverable!(stream, "too many tags for command specifier") } } let cmd: Spec = expect_nonterminal(stream)?; make(CommandSpec(tags, cmd)) } } /// Parsing for a tuple of hostname, runas specifier and commandspec. /// grammar: /// ```text /// (host,runas,commandspec) = hostlist, "=", [runas?, commandspec]+ /// ``` impl Parse for (SpecList, Vec<(Option, CommandSpec)>) { fn parse(stream: &mut CharStream) -> Parsed { let hosts = try_nonterminal(stream)?; expect_syntax('=', stream)?; let runas_cmds = expect_nonterminal(stream)?; make((hosts, runas_cmds)) } } /// A hostname, runas specifier, commandspec combination can occur multiple times in a single /// sudoer line (separated by ":") impl Many for (SpecList, Vec<(Option, CommandSpec)>) { const SEP: char = ':'; } /// Parsing for a tuple of hostname, runas specifier and commandspec. /// grammar: /// ```text /// (runas,commandspec) = runas?, commandspec /// ``` impl Parse for (Option, CommandSpec) { fn parse(stream: &mut CharStream) -> Parsed { let runas: Option = try_nonterminal(stream)?; let cmd = if runas.is_some() { expect_nonterminal(stream)? } else { try_nonterminal(stream)? }; make((runas, cmd)) } } /// A runas specifier, commandspec combination can occur multiple times in a single /// sudoer line (separated by ","); there is some ambiguity in the original grammar: /// commands can also occur multiple times; we parse that here as if they have an omitted /// "runas" specifier (which has to be placed correctly during the AST analysis phase) impl Many for (Option, CommandSpec) {} /// grammar: /// ```text /// sudo = permissionspec /// | Keyword_Alias identifier = identifier_list /// | Defaults (name [+-]?= ...)+ /// ``` /// There is a syntactical ambiguity in the sudoer Directive and Permission specifications, so we /// have to parse them 'together' and do a delayed decision on which category we are in. impl Parse for Sudo { // note: original sudo would reject: // "User_Alias, user machine = command" // but accept: // "user, User_Alias machine = command"; this does the same fn parse(stream: &mut CharStream) -> Parsed { if stream.eat_char('@') { return parse_include(stream); } // the existence of "#include" forces us to handle lines that start with # explicitly if stream.peek() == Some('#') { return if let Ok(ident) = try_nonterminal::(stream) { let first_user = Qualified::Allow(Meta::Only(UserSpecifier::User(ident))); let users = if is_syntax(',', stream)? { // parse the rest of the userlist and add the already-parsed user in front let mut rest = expect_nonterminal::>(stream)?; rest.insert(0, first_user); rest } else { vec![first_user] }; // no need to check get_directive as no other directive starts with # let permissions = expect_nonterminal(stream)?; make(Sudo::Spec(PermissionSpec { users, permissions })) } else { // the failed "try_nonterminal::" will have consumed the '#' // the most ignominious part of sudoers: having to parse bits of comments parse_include(stream).or_else(|_| { stream.skip_to_newline(); make(Sudo::LineComment) }) }; } let start_pos = stream.get_pos(); if stream.peek() == Some('"') { // a quoted userlist follows; this forces us to read a userlist let users = expect_nonterminal(stream)?; let permissions = expect_nonterminal(stream)?; make(Sudo::Spec(PermissionSpec { users, permissions })) } else if let Some(users) = maybe(try_nonterminal::>(stream))? { // this could be the start of a Defaults or Alias definition, so distinguish. // element 1 always exists (parse_list fails on an empty list) let key = &users[0]; if let Some(directive) = maybe(get_directive(key, stream, start_pos))? { if users.len() != 1 { unrecoverable!(pos = start_pos, stream, "invalid user name list"); } make(Sudo::Decl(directive)) } else { let permissions = expect_nonterminal(stream)?; make(Sudo::Spec(PermissionSpec { users, permissions })) } } else { // this will leave whatever could not be parsed on the input stream make(Sudo::LineComment) } } } /// Parse the include/include dir part that comes after the '#' or '@' prefix symbol fn parse_include(stream: &mut CharStream) -> Parsed { fn get_path(stream: &mut CharStream, key_pos: (usize, usize)) -> Parsed<(String, Span)> { let path = if stream.eat_char('"') { let QuotedIncludePath(path) = expect_nonterminal(stream)?; expect_syntax('"', stream)?; path } else { let value_pos = stream.get_pos(); let IncludePath(path) = expect_nonterminal(stream)?; if stream.peek() != Some('\n') { unrecoverable!( pos = value_pos, stream, "use quotes around filenames or escape whitespace" ) } path }; make(( path, Span { start: key_pos, end: stream.get_pos(), }, )) } let key_pos = stream.get_pos(); let result = match try_nonterminal(stream)? { Some(Username(key)) if key == "include" => { let (path, span) = get_path(stream, key_pos)?; Sudo::Include(path, span) } Some(Username(key)) if key == "includedir" => { let (path, span) = get_path(stream, key_pos)?; Sudo::IncludeDir(path, span) } _ => unrecoverable!(pos = key_pos, stream, "unknown directive"), }; make(result) } /// grammar: /// ```text /// name = definition [ : name = definition [ : ... ] ] /// ``` /// impl Parse for Def where T: UserFriendly, Meta: Parse + Many, { fn parse(stream: &mut CharStream) -> Parsed { let begin_pos = stream.get_pos(); let AliasName(name) = try_nonterminal(stream)?; if name == "ALL" { unrecoverable!( pos = begin_pos, stream, "the reserved alias ALL cannot be redefined" ); } expect_syntax('=', stream)?; make(Def(name, expect_nonterminal(stream)?)) } } impl Many for Def { const SEP: char = ':'; } // NOTE: This function is a bit of a hack, since it relies on the fact that all directives // occur in the spot of a username, and are of a form that would otherwise be a legal user name. // I.e. after a valid username has been parsed, we check if it isn't actually a valid start of a // directive. A more robust solution would be to use the approach taken by the `MetaOrTag` above. fn get_directive( perhaps_keyword: &Spec, stream: &mut CharStream, begin_pos: (usize, usize), ) -> Parsed { use super::ast::Directive::*; use super::ast::Meta::*; use super::ast::Qualified::*; use super::ast::UserSpecifier::*; let Allow(Only(User(Identifier::Name(keyword)))) = perhaps_keyword else { return reject(); }; match keyword.as_str() { "User_Alias" => make(UserAlias(expect_nonterminal(stream)?)), "Host_Alias" => make(HostAlias(expect_nonterminal(stream)?)), "Cmnd_Alias" | "Cmd_Alias" => make(CmndAlias(expect_nonterminal(stream)?)), "Runas_Alias" => make(RunasAlias(expect_nonterminal(stream)?)), _ if keyword.starts_with("Defaults") => { //HACK #1: no space is allowed between "Defaults" and '!>@:'. The below avoids having to //add "Defaults!" etc as separate tokens; but relying on positional information during //parsing is of course, cheating. //HACK #2: '@' can be part of a username, so it will already have been parsed; //an acceptable hostname is subset of an acceptable username, so that's actually OK. //This resolves an ambiguity in the grammar similarly to how MetaOrTag does that. const DEFAULTS_LEN: usize = "Defaults".len(); let allow_scope_modifier = stream.get_pos().0 == begin_pos.0 && (stream.get_pos().1 - begin_pos.1 == DEFAULTS_LEN || keyword.len() > DEFAULTS_LEN); let scope = if allow_scope_modifier { if keyword[DEFAULTS_LEN..].starts_with('@') { let inner_stream = &mut CharStream::new_with_pos( &keyword[DEFAULTS_LEN + 1..], advance(begin_pos, DEFAULTS_LEN + 1), ); ConfigScope::Host(expect_nonterminal(inner_stream)?) } else if is_syntax(':', stream)? { ConfigScope::User(expect_nonterminal(stream)?) } else if is_syntax('!', stream)? { ConfigScope::Command(expect_nonterminal(stream)?) } else if is_syntax('>', stream)? { ConfigScope::RunAs(expect_nonterminal(stream)?) } else { ConfigScope::Generic } } else { ConfigScope::Generic }; make(Defaults(expect_nonterminal(stream)?, scope)) } _ => reject(), } } /// grammar: /// ```text /// parameter = name [+-]?= ... /// ``` impl Parse for defaults::SettingsModifier { fn parse(stream: &mut CharStream) -> Parsed { let id_pos = stream.get_pos(); // Parse multiple entries enclosed in quotes (for list-like Defaults-settings) let parse_vars = |stream: &mut CharStream| -> Parsed> { if stream.eat_char('"') { let mut result = Vec::new(); while let Some(EnvVar(name)) = try_nonterminal(stream)? { if is_syntax('=', stream)? { let StringParameter(value) = expect_nonterminal(stream)?; result.push(name + "=" + &value); } else { result.push(name); } if result.len() > Identifier::LIMIT { unrecoverable!(stream, "environment variable list too long") } } expect_syntax('"', stream)?; if result.is_empty() { unrecoverable!(stream, "empty string not allowed"); } make(result) } else { let EnvVar(name) = expect_nonterminal(stream)?; if is_syntax('=', stream)? { unrecoverable!(stream, "double quotes are required for VAR=value pairs") } else { make(vec![name]) } } }; // Parse the remainder of a list variable let list_items = |mode: defaults::ListMode, name: String, cfg: defaults::SettingKind, stream: &mut _| { expect_syntax('=', stream)?; let defaults::SettingKind::List(checker) = cfg else { unrecoverable!(pos = id_pos, stream, "{name} is not a list parameter"); }; make(checker(mode, parse_vars(stream)?)) }; // Parse a text parameter let text_item = |stream: &mut CharStream| { if stream.eat_char('"') { let QuotedStringParameter(text) = expect_nonterminal(stream)?; expect_syntax('"', stream)?; make(text) } else { let StringParameter(name) = expect_nonterminal(stream)?; make(name) } }; if is_syntax('!', stream)? { let value_pos = stream.get_pos(); let DefaultName(name) = expect_nonterminal(stream)?; let Some(modifier) = defaults::negate(&name) else { if defaults::set(&name).is_some() { unrecoverable!( pos = value_pos, stream, "'{name}' cannot be used in a boolean context" ); } else { unrecoverable!(pos = value_pos, stream, "unknown setting: '{name}'"); } }; make(modifier) } else { let DefaultName(name) = try_nonterminal(stream)?; let Some(cfg) = defaults::set(&name) else { unrecoverable!(pos = id_pos, stream, "unknown setting: '{name}'"); }; if is_syntax('+', stream)? { list_items(defaults::ListMode::Add, name, cfg, stream) } else if is_syntax('-', stream)? { list_items(defaults::ListMode::Del, name, cfg, stream) } else if is_syntax('=', stream)? { let value_pos = stream.get_pos(); match cfg { defaults::SettingKind::Flag(_) => { unrecoverable!(stream, "can't assign to boolean setting '{name}'") } defaults::SettingKind::Integer(checker) => { let Numeric(denotation) = expect_nonterminal(stream)?; if let Some(modifier) = checker(&denotation) { make(modifier) } else { unrecoverable!( pos = value_pos, stream, "'{denotation}' is not a valid value for {name}" ); } } defaults::SettingKind::List(checker) => { let items = parse_vars(stream)?; make(checker(defaults::ListMode::Set, items)) } defaults::SettingKind::Text(checker) => { let text = text_item(stream)?; let Some(modifier) = checker(&text) else { unrecoverable!( pos = value_pos, stream, "'{text}' is not a valid value for {name}" ); }; make(modifier) } } } else { let defaults::SettingKind::Flag(modifier) = cfg else { unrecoverable!(pos = id_pos, stream, "'{name}' is not a boolean setting"); }; make(modifier) } } } } impl Many for defaults::SettingsModifier {} sudo-rs-0.2.10/src/sudoers/ast_names.rs000064400000000000000000000072371046102023000161040ustar 00000000000000//! This module contains user-friendly names for the various items in the AST, to report in case they are missing pub trait UserFriendly { const DESCRIPTION: &'static str; } // this is in a submodule so it can be switched off and replaced by a blanket implementation for test-cases #[cfg(not(test))] mod names { use super::*; use crate::defaults; use crate::sudoers::ast::*; use crate::sudoers::tokens; impl UserFriendly for tokens::Digits { const DESCRIPTION: &'static str = "number"; } impl UserFriendly for tokens::Numeric { const DESCRIPTION: &'static str = "nonnegative number"; } impl UserFriendly for Identifier { const DESCRIPTION: &'static str = "identifier"; } impl UserFriendly for Vec { const DESCRIPTION: &'static str = T::DESCRIPTION; } impl UserFriendly for tokens::Meta { const DESCRIPTION: &'static str = T::DESCRIPTION; } impl UserFriendly for Qualified { const DESCRIPTION: &'static str = T::DESCRIPTION; } impl UserFriendly for tokens::Command { const DESCRIPTION: &'static str = "path to binary (or sudoedit)"; } impl UserFriendly for tokens::SimpleCommand { const DESCRIPTION: &'static str = "path to binary (or sudoedit)"; } impl UserFriendly for ( SpecList, Vec<(Option, CommandSpec)>, ) { const DESCRIPTION: &'static str = tokens::Hostname::DESCRIPTION; } impl UserFriendly for (Option, CommandSpec) { const DESCRIPTION: &'static str = "(users:groups) specification"; } // this can never happen, as parse always succeeds impl UserFriendly for Sudo { const DESCRIPTION: &'static str = "nothing"; } impl UserFriendly for UserSpecifier { const DESCRIPTION: &'static str = "user"; } impl UserFriendly for tokens::Username { const DESCRIPTION: &'static str = "user"; } impl UserFriendly for tokens::Hostname { const DESCRIPTION: &'static str = "host name"; } impl UserFriendly for tokens::QuotedStringParameter { const DESCRIPTION: &'static str = "non-empty string"; } impl UserFriendly for tokens::QuotedIncludePath { const DESCRIPTION: &'static str = "non-empty string"; } impl UserFriendly for tokens::StringParameter { const DESCRIPTION: &'static str = tokens::QuotedStringParameter::DESCRIPTION; } impl UserFriendly for tokens::IncludePath { const DESCRIPTION: &'static str = "path to file"; } impl UserFriendly for tokens::AliasName { const DESCRIPTION: &'static str = "alias name"; } impl UserFriendly for tokens::DefaultName { const DESCRIPTION: &'static str = "configuration option"; } impl UserFriendly for tokens::EnvVar { const DESCRIPTION: &'static str = "environment variable"; } impl UserFriendly for CommandSpec { const DESCRIPTION: &'static str = tokens::Command::DESCRIPTION; } impl UserFriendly for tokens::ChDir { const DESCRIPTION: &'static str = "directory or '*'"; } impl UserFriendly for defaults::SettingsModifier { const DESCRIPTION: &'static str = "parameter"; } impl UserFriendly for Def { const DESCRIPTION: &'static str = "alias definition"; } impl UserFriendly for tokens::Unquoted { const DESCRIPTION: &'static str = T::DESCRIPTION; } } #[cfg(test)] impl UserFriendly for T { const DESCRIPTION: &'static str = "elem"; } sudo-rs-0.2.10/src/sudoers/basic_parser.rs000064400000000000000000000330751046102023000165660ustar 00000000000000//! Building blocks for a recursive descent LL(1) parsing method. //! //! The general idea is that a grammar (without left recursion) is translated to a series of //! conditional and unconditional 'acceptance' methods. //! //! For example, assuming we have a parser for integers: //! //! sum = integer | integer + sum //! //! Can get translated as: (representing a sum as `LinkedList`): //! //! ```ignore //! impl Parse for LinkedList { //! fn parse(stream: &mut CharStream) -> Parsed> { //! let x = try_nonterminal(stream)?; //! let mut tail = if is_syntax('+', stream)? { //! expect_nonterminal(stream)? //! } else { //! LinkedList::new() //! }; //! tail.push_front(x); //! //! make(tail) //! } //! } //! ``` /// Type holding a parsed object (or error information if parsing failed) pub type Parsed = Result; #[derive(Copy, Clone)] #[cfg_attr(test, derive(Debug, PartialEq))] pub struct Span { pub start: (usize, usize), pub end: (usize, usize), } #[cfg_attr(test, derive(Debug, PartialEq))] pub enum Status { Fatal(Span, String), // not recoverable; stream in inconsistent state Reject, // parsing failed by no input consumed } pub fn make(value: T) -> Parsed { Ok(value) } pub fn reject() -> Parsed { Err(Status::Reject) } macro_rules! unrecoverable { (pos=$pos:expr, $stream:ident, $($str:expr),*) => { return Err(crate::sudoers::basic_parser::Status::Fatal(Span { start: $pos, end: CharStream::get_pos($stream)}, format![$($str),*])) }; ($stream:ident, $($str:expr),*) => {{ let pos = CharStream::get_pos($stream); return Err(crate::sudoers::basic_parser::Status::Fatal(Span { start: pos, end: pos }, format![$($str),*])) }}; ($($str:expr),*) => { return Err(crate::basic_parser::Status::Fatal(Default::default(), format![$($str),*])) }; } pub(super) use unrecoverable; /// This recovers from a failed parsing. pub fn maybe(status: Parsed) -> Parsed> { match status { Ok(x) => Ok(Some(x)), Err(Status::Reject) => Ok(None), Err(err) => Err(err), } } pub use super::char_stream::CharStream; /// All implementations of the Parse trait must satisfy this contract: /// If the `parse` method of this trait returns None, the iterator is not advanced; otherwise it is /// advanced beyond the accepted part of the input. i.e. if some input is consumed the method /// *MUST* be producing a `Some` value. pub trait Parse { fn parse(stream: &mut CharStream) -> Parsed where Self: Sized; } /// Structures representing whitespace (trailing whitespace can contain comments) #[cfg_attr(test, derive(PartialEq, Eq))] struct LeadingWhitespace; #[cfg_attr(test, derive(PartialEq, Eq))] struct TrailingWhitespace; #[cfg_attr(test, derive(Debug, PartialEq, Eq))] struct Comment; /// Accept zero or more whitespace characters; fails if the whitespace is not "leading" to something /// (which can be used to detect end-of-input). impl Parse for LeadingWhitespace { fn parse(stream: &mut CharStream) -> Parsed { let eat_space = |stream: &mut CharStream| stream.next_if(|c| "\t ".contains(c)); while eat_space(stream).is_some() {} if stream.peek().is_some() { make(LeadingWhitespace {}) } else { unrecoverable!(stream, "superfluous whitespace") } } } /// Accept zero or more whitespace characters; since this accepts zero characters, it /// always succeeds (unless some serious error occurs). This parser also accepts comments, /// since those can form part of trailing white space. impl Parse for TrailingWhitespace { fn parse(stream: &mut CharStream) -> Parsed { loop { let _ = LeadingWhitespace::parse(stream); // don't propagate any errors // line continuations if stream.eat_char('\\') { // do the equivalent of expect_syntax('\n', stream)?, without recursion if !stream.eat_char('\n') { unrecoverable!(stream, "stray escape sequence") } } else { break; } } make(TrailingWhitespace {}) } } /// Parses a comment impl Parse for Comment { fn parse(stream: &mut CharStream) -> Parsed { if !stream.eat_char('#') { return Err(Status::Reject); } stream.skip_to_newline(); make(Comment {}) } } fn skip_trailing_whitespace(stream: &mut CharStream) -> Parsed<()> { TrailingWhitespace::parse(stream)?; make(()) } /// Adheres to the contract of the [Parse] trait, accepts one character and consumes trailing whitespace. pub fn try_syntax(syntax: char, stream: &mut CharStream) -> Parsed<()> { if !stream.eat_char(syntax) { return Err(Status::Reject); } skip_trailing_whitespace(stream)?; make(()) } /// Similar to [try_syntax], but aborts parsing if the expected character is not found. pub fn expect_syntax(syntax: char, stream: &mut CharStream) -> Parsed<()> { if try_syntax(syntax, stream).is_err() { let str = if let Some(c) = stream.peek() { c.to_string() } else { "EOF".to_string() }; unrecoverable!(stream, "expecting '{syntax}' but found '{str}'") } make(()) } /// Convenience function: usually try_syntax is called as a test criterion; if this returns true, the input was consumed. pub fn is_syntax(syntax: char, stream: &mut CharStream) -> Parsed { let result = maybe(try_syntax(syntax, stream))?; make(result.is_some()) } /// Interface for working with types that implement the [Parse] trait; this allows parsing to use /// type inference. Use this instead of calling [Parse::parse] directly. pub fn try_nonterminal(stream: &mut CharStream) -> Parsed { let result = T::parse(stream)?; skip_trailing_whitespace(stream)?; make(result) } /// Interface for working with types that implement the [Parse] trait; this expects to parse /// the given type or gives a fatal parse error if this did not succeed. use super::ast_names::UserFriendly; pub fn expect_nonterminal(stream: &mut CharStream) -> Parsed { let begin_pos = stream.get_pos(); match try_nonterminal(stream) { Err(Status::Reject) => { unrecoverable!(pos = begin_pos, stream, "expected {}", T::DESCRIPTION) } result => result, } } /// Something that implements the Token trait is a token (i.e. a string of characters defined by a /// maximum length, character classes, and possible escaping). The class for the first character of /// the token can be different than that of the rest. pub trait Token: Sized { const MAX_LEN: usize = 255; fn construct(s: String) -> Result; fn accept(c: char) -> bool; fn accept_1st(c: char) -> bool { Self::accept(c) } const ALLOW_ESCAPE: bool = false; fn escaped(_: char) -> bool { false } } /// Implementation of the [Parse] trait for anything that implements [Token] impl Parse for T { fn parse(stream: &mut CharStream) -> Parsed { fn accept_escaped( pred: fn(char) -> bool, stream: &mut CharStream, ) -> Parsed { const ESCAPE: char = '\\'; if T::ALLOW_ESCAPE && stream.eat_char(ESCAPE) { if let Some(c) = stream.next_if(T::escaped) { Ok(c) } else if pred(ESCAPE) { Ok(ESCAPE) } else if stream.eat_char('\n') { // we've just consumed some whitespace, we should end // parsing the token but not abort it reject() } else { unrecoverable!(stream, "illegal escape sequence") } } else if let Some(c) = stream.next_if(pred) { Ok(c) } else { reject() } } let start_pos = stream.get_pos(); let mut str = accept_escaped::(T::accept_1st, stream)?.to_string(); while let Some(c) = maybe(accept_escaped::(T::accept, stream))? { if str.len() >= T::MAX_LEN { unrecoverable!(stream, "token exceeds maximum length") } str.push(c) } match T::construct(str) { Ok(result) => make(result), Err(msg) => unrecoverable!(pos = start_pos, stream, "{msg}"), } } } /// Parser for `Option` (this can be used to make the code more readable) impl Parse for Option { fn parse(stream: &mut CharStream) -> Parsed { maybe(T::parse(stream)) } } /// Parsing method for lists of items separated by a given character; this adheres to the contract of the [Parse] trait. pub(super) fn parse_list( sep_by: char, max: usize, stream: &mut CharStream, ) -> Parsed> { let mut elems = Vec::new(); elems.push(try_nonterminal(stream)?); while maybe(try_syntax(sep_by, stream))?.is_some() { if elems.len() >= max { unrecoverable!(stream, "too many items in list") } elems.push(expect_nonterminal(stream)?); } make(elems) } /// Types that implement the Many trait can be parsed multiple tokens into a `Vec`; they are /// separated by `SEP`. There should also be a limit on the number of items. pub trait Many { const SEP: char = ','; const LIMIT: usize = 127; } /// Generic implementation for parsing multiple items of a type `T` that implements the [Parse] and /// [Many] traits. impl Parse for Vec { fn parse(stream: &mut CharStream) -> Parsed { parse_list(T::SEP, T::LIMIT, stream) } } /// Entry point utility function; parse a `Vec` but with fatal error recovery per line pub fn parse_lines(stream: &mut CharStream) -> Vec> where T: Parse + UserFriendly, { let mut result = Vec::new(); // this will terminate; if the inner accept_if is an error, either a character will be consumed // by the second accept_if (making progress), or the end of the stream will have been reacherd // (which will cause the next iteration to fall through) while LeadingWhitespace::parse(stream).is_ok() { let item = expect_nonterminal(stream); let parsed_item_ok = item.is_ok(); result.push(item); let _ = maybe(Comment::parse(stream)); if !stream.eat_char('\n') { if parsed_item_ok { let msg = if stream.peek().is_none() { "missing line terminator at end of file" } else { "garbage at end of line" }; let error = |stream: &mut CharStream| unrecoverable!(stream, "{msg}"); result.push(error(stream)); } stream.skip_to_newline(); } } result } #[cfg(test)] fn expect_complete(stream: &mut CharStream) -> Parsed { let result = expect_nonterminal(stream)?; if let Some(c) = stream.peek() { unrecoverable!(stream, "garbage at end of line: {c}") } make(result) } /// Convenience function (especially useful for writing test cases, to avoid having to write out the /// AST constructors by hand. #[cfg(test)] pub fn parse_string(text: &str) -> Parsed { expect_complete(&mut CharStream::new(text)) } #[cfg(test)] pub fn parse_eval(text: &str) -> T { parse_string(text).unwrap() } #[cfg(test)] mod test { use super::*; impl Token for String { fn construct(val: String) -> Result { Ok(val) } fn accept(c: char) -> bool { c.is_ascii_alphanumeric() } } impl Many for String {} #[test] fn comment_test() { assert_eq!(parse_eval::("# hello"), Comment); } #[test] #[should_panic] fn comment_test_fail() { assert_eq!(parse_eval::("# hello\nsomething"), Comment); } #[test] fn lines_test() { let input = |text: &str| parse_lines(&mut CharStream::new(text)); let s = |text: &str| Ok(text.to_string()); assert_eq!(input("hello\nworld\n"), vec![s("hello"), s("world")]); assert_eq!(input(" hello\nworld\n"), vec![s("hello"), s("world")]); assert_eq!(input("hello \nworld\n"), vec![s("hello"), s("world")]); assert_eq!(input("hello\n world\n"), vec![s("hello"), s("world")]); assert_eq!(input("hello\nworld \n"), vec![s("hello"), s("world")]); assert_eq!(input("hello\nworld")[0..2], vec![s("hello"), s("world")]); let Err(_) = input("hello\nworld")[2] else { panic!() }; let Err(_) = input("hello\nworld:\n")[2] else { panic!() }; } #[test] fn whitespace_test() { assert_eq!( parse_eval::>("hello,something"), vec!["hello", "something"] ); assert_eq!( parse_eval::>("hello , something"), vec!["hello", "something"] ); assert_eq!( parse_eval::>("hello, something"), vec!["hello", "something"] ); assert_eq!( parse_eval::>("hello ,something"), vec!["hello", "something"] ); assert_eq!( parse_eval::>("hello\\\n,something"), vec!["hello", "something"] ); } } sudo-rs-0.2.10/src/sudoers/char_stream.rs000064400000000000000000000034121046102023000164110ustar 00000000000000pub struct CharStream<'a> { iter: std::iter::Peekable>, line: usize, col: usize, } /// Advance the given position by `n` horizontal steps pub fn advance(pos: (usize, usize), n: usize) -> (usize, usize) { (pos.0, pos.1 + n) } impl<'a> CharStream<'a> { pub fn new_with_pos(src: &'a str, (line, col): (usize, usize)) -> Self { CharStream { iter: src.chars().peekable(), line, col, } } pub fn new(src: &'a str) -> Self { Self::new_with_pos(src, (1, 1)) } } impl CharStream<'_> { pub fn next_if(&mut self, f: impl FnOnce(char) -> bool) -> Option { let item = self.iter.next_if(|&c| f(c)); match item { Some('\n') => { self.line += 1; self.col = 1; } Some(_) => self.col += 1, _ => {} } item } pub fn eat_char(&mut self, expect_char: char) -> bool { self.next_if(|c| c == expect_char).is_some() } pub fn skip_to_newline(&mut self) { while self.next_if(|c| c != '\n').is_some() {} } pub fn peek(&mut self) -> Option { self.iter.peek().cloned() } pub fn get_pos(&self) -> (usize, usize) { (self.line, self.col) } } #[cfg(test)] mod test { use super::*; #[test] fn test_iter() { let mut stream = CharStream::new("12\n3\n"); assert_eq!(stream.peek(), Some('1')); assert!(stream.eat_char('1')); assert_eq!(stream.peek(), Some('2')); assert!(stream.eat_char('2')); assert!(stream.eat_char('\n')); assert_eq!(stream.peek(), Some('3')); assert!(stream.eat_char('3')); assert_eq!(stream.get_pos(), (2, 2)); } } sudo-rs-0.2.10/src/sudoers/entry/verbose.rs000064400000000000000000000041561046102023000167350ustar 00000000000000use core::fmt; use crate::sudoers::{ ast::{Authenticate, RunAs, Tag}, tokens::ChDir, }; use super::Entry; pub struct Verbose<'a>(pub Entry<'a>); impl fmt::Display for Verbose<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let Self(Entry { run_as, cmd_specs, cmd_alias, }) = self; let root_runas = super::root_runas(); let run_as = run_as.unwrap_or(&root_runas); let mut last_tag = None; for (tag, cmd_spec) in cmd_specs { if last_tag != Some(tag) { let is_first_iteration = last_tag.is_none(); if !is_first_iteration { f.write_str("\n")?; } write_entry_header(run_as, f)?; write_tag(f, tag)?; f.write_str("\n Commands:")?; } last_tag = Some(tag); f.write_str("\n\t")?; super::write_spec(f, cmd_spec, cmd_alias.iter().rev(), true, "\n\t")?; } Ok(()) } } fn write_entry_header(run_as: &RunAs, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("\nSudoers entry:")?; write_users(run_as, f)?; write_groups(run_as, f) } fn write_users(run_as: &RunAs, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("\n RunAsUsers: ")?; super::write_users(run_as, f) } fn write_groups(run_as: &RunAs, f: &mut fmt::Formatter<'_>) -> fmt::Result { if run_as.groups.is_empty() { return Ok(()); } f.write_str("\n RunAsGroups: ")?; super::write_groups(run_as, f) } fn write_tag(f: &mut fmt::Formatter, tag: &Tag) -> fmt::Result { if tag.authenticate != Authenticate::None { f.write_str("\n Options: ")?; if tag.authenticate != Authenticate::Passwd { f.write_str("!")?; } f.write_str("authenticate")?; } if let Some(cwd) = &tag.cwd { f.write_str("\n Cwd: ")?; match cwd { ChDir::Path(path) => write!(f, "{}", path.display())?, ChDir::Any => f.write_str("*")?, } } Ok(()) } sudo-rs-0.2.10/src/sudoers/entry.rs000064400000000000000000000171651046102023000152740ustar 00000000000000use core::fmt; use crate::sudoers::{ ast::{Identifier, Qualified, UserSpecifier}, tokens::{ChDir, Meta}, VecOrd, }; use crate::{ common::{resolve::CurrentUser, SudoString}, system::{interface::UserId, User}, }; use self::verbose::Verbose; use super::{ ast::{Authenticate, Def, EnvironmentControl, ExecControl, RunAs, Tag}, tokens::Command, }; mod verbose; pub struct Entry<'a> { run_as: Option<&'a RunAs>, cmd_specs: Vec<(Tag, &'a Qualified>)>, cmd_alias: &'a VecOrd>, } impl<'a> Entry<'a> { pub(super) fn new( run_as: Option<&'a RunAs>, cmd_specs: Vec<(Tag, &'a Qualified>)>, cmd_alias: &'a VecOrd>, ) -> Self { debug_assert!(!cmd_specs.is_empty()); Self { run_as, cmd_specs, cmd_alias, } } pub fn verbose(self) -> impl fmt::Display + 'a { Verbose(self) } } fn root_runas() -> RunAs { let name = User::from_uid(UserId::ROOT) .ok() .flatten() .map(|u| u.name) .unwrap_or(SudoString::new("root".into()).unwrap()); let name = UserSpecifier::User(Identifier::Name(name)); let name = Qualified::Allow(Meta::Only(name)); RunAs { users: vec![name], groups: vec![], } } impl fmt::Display for Entry<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let Self { run_as, cmd_specs, cmd_alias, } = self; let root_runas = root_runas(); let run_as = run_as.unwrap_or(&root_runas); f.write_str(" (")?; write_users(run_as, f)?; if !run_as.groups.is_empty() { f.write_str(" : ")?; } write_groups(run_as, f)?; f.write_str(") ")?; let mut last_tag = None; for (tag, spec) in cmd_specs { let is_first_iteration = last_tag.is_none(); if !is_first_iteration { f.write_str(", ")?; } write_tag(f, tag, &mut last_tag, spec)?; // cmd_alias is to be topologically sorted (dependencies come before dependents), // the argument to write_spec needs to have dependents before dependencies. write_spec(f, spec, cmd_alias.iter().rev(), true, ", ")?; } Ok(()) } } fn write_users(run_as: &RunAs, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { if run_as.users.is_empty() { match CurrentUser::resolve() { Ok(u) => f.write_str(&u.name)?, _ => f.write_str("?")?, }; } let mut is_first_user = true; for user in &run_as.users { if !is_first_user { f.write_str(", ")?; } is_first_user = false; let meta = match user { Qualified::Allow(meta) => meta, Qualified::Forbid(meta) => { f.write_str("!")?; meta } }; match meta { Meta::All => f.write_str("ALL")?, Meta::Only(user) => { let ident = match user { UserSpecifier::User(ident) => ident, UserSpecifier::Group(ident) => { f.write_str("%")?; ident } UserSpecifier::NonunixGroup(ident) => { f.write_str("%:")?; ident } }; match ident { Identifier::Name(name) => f.write_str(name)?, Identifier::ID(id) => write!(f, "#{id}")?, } } Meta::Alias(alias) => f.write_str(alias)?, } } Ok(()) } fn write_groups(run_as: &RunAs, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { let mut is_first_group = true; for group in &run_as.groups { if !is_first_group { f.write_str(", ")?; } is_first_group = false; let meta = match group { Qualified::Allow(meta) => meta, Qualified::Forbid(meta) => { f.write_str("!")?; meta } }; match meta { Meta::All => f.write_str("ALL")?, Meta::Only(ident) => match ident { Identifier::Name(name) => f.write_str(name)?, Identifier::ID(id) => write!(f, "#{id}")?, }, Meta::Alias(alias) => f.write_str(alias)?, } } Ok(()) } fn write_tag( f: &mut fmt::Formatter, tag: &Tag, last_tag: &mut Option, spec: &Qualified>, ) -> fmt::Result { let last_tag = last_tag.get_or_insert(Tag::default()); if tag.apparmor_profile != last_tag.apparmor_profile { f.write_str("APPARMOR_PROFILE=")?; let profile = tag .apparmor_profile .as_ref() .expect("sudoers spec turned off"); f.write_str(profile)?; f.write_str(" ")?; } if tag.cwd != last_tag.cwd { f.write_str("CWD=")?; match tag.cwd.as_ref().expect("sudoers spec turned off") { ChDir::Path(path) => write!(f, "{}", path.display())?, ChDir::Any => f.write_str("*")?, } f.write_str(" ")?; } let mut write_tag = |text, status: bool| { if !status { f.write_str("NO")?; }; f.write_str(text)?; f.write_str(": ") }; if tag.env != last_tag.env && !(matches!(spec, Qualified::Allow(Meta::All)) && tag.env == EnvironmentControl::Setenv) { write_tag("SETENV", tag.env == EnvironmentControl::Setenv)?; } if tag.noexec != last_tag.noexec { write_tag("EXEC", tag.noexec == ExecControl::Exec)?; } if tag.authenticate != last_tag.authenticate { write_tag("PASSWD", tag.authenticate != Authenticate::Nopasswd)?; } *last_tag = tag.clone(); Ok(()) } fn write_spec<'a>( f: &mut fmt::Formatter, spec: &Qualified>, mut alias_list: impl Iterator> + Clone, mut sign: bool, separator: &str, ) -> fmt::Result { let meta = match spec { Qualified::Allow(meta) => meta, Qualified::Forbid(meta) => { sign = !sign; meta } }; match meta { Meta::All | Meta::Only(_) if !sign => f.write_str("!")?, _ => {} } match meta { Meta::All => f.write_str("ALL")?, Meta::Only((cmd, args)) => { write!(f, "{cmd}")?; if let Some(args) = args { for arg in args.iter() { write!(f, " {arg}")?; } } } Meta::Alias(alias) => { if let Some(Def(_, spec_list)) = alias_list.find(|Def(id, _)| id == alias) { let mut is_first_iteration = true; for spec in spec_list { if !is_first_iteration { f.write_str(separator)?; } // 1) this recursion will terminate, since "alias_list" has become smaller // by the "alias_list.find()" above // 2) to get the correct macro expansion, alias_list has to be (reverse-)topologically // sorted so that "later" definitions do not refer back to "earlier" definitions. write_spec(f, spec, alias_list.clone(), sign, separator)?; is_first_iteration = false; } } else { f.write_str("???")? } } } Ok(()) } sudo-rs-0.2.10/src/sudoers/mod.rs000064400000000000000000000774141046102023000147150ustar 00000000000000#![forbid(unsafe_code)] //! Code that checks (and in the future: lists) permissions in the sudoers file mod ast; mod ast_names; mod basic_parser; mod char_stream; mod entry; mod tokens; use std::collections::{HashMap, HashSet}; use std::io; use std::path::{Path, PathBuf}; use crate::common::resolve::{is_valid_executable, resolve_path}; use crate::defaults; use crate::log::auth_warn; use crate::system::interface::{GroupId, UnixGroup, UnixUser, UserId}; use crate::system::{self, audit}; use ast::*; use tokens::*; pub type Settings = defaults::Settings; pub use basic_parser::Span; /// How many nested include files do we allow? const INCLUDE_LIMIT: u8 = 128; /// Export some necessary symbols from modules pub struct Error { pub source: Option, pub location: Option, pub message: String, } /// A "Customiser" represents a "Defaults" setting that has 'late binding'; i.e. /// cannot be determined simply by reading a sudoers configuration. This is used /// for Defaults@host, Defaults:user, Defaults>runas and Defaults!cmd. /// /// I.e. the Setting modifications in the second part of the tuple only apply for /// items explicitly matched by the first part of the tuple. type Customiser = (Scope, Vec); #[derive(Default)] pub struct Sudoers { rules: Vec, aliases: AliasTable, settings: Settings, customisers: CustomiserTable, } /// A structure that represents what the user wants to do pub struct Request<'a, User: UnixUser, Group: UnixGroup> { pub user: &'a User, pub group: &'a Group, pub command: &'a Path, pub arguments: &'a [String], } pub struct ListRequest<'a, User: UnixUser, Group: UnixGroup> { pub inspected_user: &'a User, pub target_user: &'a User, pub target_group: &'a Group, } #[derive(Default)] #[cfg_attr(test, derive(Clone))] pub struct Judgement { flags: Option, settings: Settings, } mod policy; pub use policy::{AuthenticatingUser, Authentication, Authorization, DirChange, Restrictions}; pub use self::entry::Entry; type MatchedCommand<'a> = (Option<&'a RunAs>, (Tag, &'a Spec)); /// This function takes a file argument for a sudoers file and processes it. impl Sudoers { pub fn open(path: impl AsRef) -> Result<(Sudoers, Vec), io::Error> { let sudoers = open_sudoers(path.as_ref())?; Ok(analyze(path.as_ref(), sudoers)) } pub fn read>( reader: R, path: P, ) -> Result<(Sudoers, Vec), io::Error> { let sudoers = read_sudoers(reader)?; Ok(analyze(path.as_ref(), sudoers)) } fn specify_host_user_runas>( &mut self, hostname: &system::Hostname, requesting_user: &User, target_user: Option<&User>, ) { let customisers = std::mem::take(&mut self.customisers.non_cmnd); let host_matcher = &match_token(hostname); let host_aliases = get_aliases(&self.aliases.host, host_matcher); let user_matcher = &match_user(requesting_user); let user_aliases = get_aliases(&self.aliases.user, user_matcher); let runas_matcher_aliases = target_user.map(|target_user| { let runas_matcher = match_user(target_user); let runas_aliases = get_aliases(&self.aliases.runas, &runas_matcher); (runas_matcher, runas_aliases) }); let match_scope = |scope| match scope { ConfigScope::Generic => true, ConfigScope::Host(list) => find_item(&list, host_matcher, &host_aliases).is_some(), ConfigScope::User(list) => find_item(&list, user_matcher, &user_aliases).is_some(), ConfigScope::RunAs(list) => { runas_matcher_aliases .as_ref() .is_some_and(|(runas_matcher, runas_aliases)| { find_item(&list, runas_matcher, runas_aliases).is_some() }) } ConfigScope::Command(_list) => { unreachable!("command-specific defaults are filtered out") } }; for (scope, modifiers) in customisers { if match_scope(scope) { for modifier in modifiers { modifier(&mut self.settings); } } } } fn specify_command(&mut self, command: &Path, arguments: &[String]) { let customisers = std::mem::take(&mut self.customisers.cmnd); let cmnd_matcher = &match_command((command, arguments)); let cmnd_aliases = get_aliases(&self.aliases.cmnd, cmnd_matcher); for (scope, modifiers) in customisers { if find_item(&scope, cmnd_matcher, &cmnd_aliases).is_some() { for modifier in modifiers { modifier(&mut self.settings); } } } } pub fn check, Group: UnixGroup>( &mut self, am_user: &User, on_host: &system::Hostname, request: Request, ) -> Judgement { self.specify_host_user_runas(on_host, am_user, Some(request.user)); self.specify_command(request.command, request.arguments); // exception: if user is root or does not switch users, NOPASSWD is implied let skip_passwd = am_user.is_root() || (request.user == am_user && in_group(am_user, request.group)); let mut flags = check_permission(self, am_user, on_host, request); if let Some(Tag { authenticate, .. }) = flags.as_mut() { if skip_passwd { *authenticate = Authenticate::Nopasswd; } } Judgement { flags, settings: self.settings.clone(), } } pub fn check_list_permission, Group: UnixGroup>( &mut self, invoking_user: &User, hostname: &system::Hostname, request: ListRequest, ) -> Authorization { let skip_passwd; let mut flags = if request.inspected_user != invoking_user { skip_passwd = invoking_user.is_root(); self.check( invoking_user, hostname, Request { user: request.inspected_user, group: &request.inspected_user.group(), command: Path::new("list"), arguments: &[], }, ) .flags .or(invoking_user.is_root().then(Tag::default)) } else { skip_passwd = invoking_user.is_root() || (request.target_user == invoking_user && in_group(invoking_user, request.target_group)); self.matching_user_specs(invoking_user, hostname) .flatten() .map(|(_, (tag, _))| tag) .max_by_key(|tag| !tag.needs_passwd()) }; if let Some(tag) = flags.as_mut() { if skip_passwd { tag.authenticate = Authenticate::Nopasswd; } Authorization::Allowed(self.settings.to_auth(tag), ()) } else { Authorization::Forbidden } } pub fn check_validate_permission>( &mut self, invoking_user: &User, hostname: &system::Hostname, ) -> Authorization { self.specify_host_user_runas(hostname, invoking_user, None); // exception: if user is root, NOPASSWD is implied let skip_passwd = invoking_user.is_root(); let mut flags = self .matching_user_specs(invoking_user, hostname) .flatten() .map(|(_, (tag, _))| tag) .max_by_key(|tag| tag.needs_passwd()); if let Some(tag) = flags.as_mut() { if skip_passwd { tag.authenticate = Authenticate::Nopasswd; } Authorization::Allowed(self.settings.to_auth(tag), ()) } else { Authorization::Forbidden } } /// returns `User_Spec`s that match `invoking_user` and `hostname` /// /// it also distributes `Tag_Spec`s across the `Cmnd_Spec` list of each `User_Spec` /// /// the outer iterator are the `User_Spec`s; the inner iterator are the `Cmnd_Spec`s of /// said `User_Spec`s fn matching_user_specs<'a, User: UnixUser + PartialEq>( &'a self, invoking_user: &'a User, hostname: &'a system::Hostname, ) -> impl Iterator>> { let Self { rules, aliases, .. } = self; let user_aliases = get_aliases(&aliases.user, &match_user(invoking_user)); let host_aliases = get_aliases(&aliases.host, &match_token(hostname)); rules .iter() .filter_map(move |sudo| { find_item(&sudo.users, &match_user(invoking_user), &user_aliases)?; Some(&sudo.permissions) }) .flatten() .filter_map(move |(hosts, runas_cmds)| { find_item(hosts, &match_token(hostname), &host_aliases)?; Some(distribute_tags(runas_cmds)) }) } pub fn matching_entries<'a, User: UnixUser + PartialEq>( &'a self, invoking_user: &'a User, hostname: &'a system::Hostname, ) -> impl Iterator> { let user_specs = self.matching_user_specs(invoking_user, hostname); user_specs.flat_map(|cmd_specs| group_cmd_specs_per_runas(cmd_specs, &self.aliases.cmnd)) } pub(crate) fn visudo_editor_path>( mut self, on_host: &system::Hostname, am_user: &User, target_user: &User, ) -> PathBuf { self.specify_host_user_runas(on_host, am_user, Some(target_user)); select_editor(&self.settings, self.settings.env_editor()) } } /// Retrieve the chosen editor from a settings object, filtering based on whether the /// environment is trusted (sudoedit) or maybe less so (visudo) fn select_editor(settings: &Settings, trusted_env: bool) -> PathBuf { let blessed_editors = settings.editor(); let is_whitelisted = |path: &Path| -> bool { trusted_env || blessed_editors.split(':').any(|x| Path::new(x) == path) }; // find editor in environment, if possible for key in ["SUDO_EDITOR", "VISUAL", "EDITOR"] { if let Some(editor) = std::env::var_os(key) { let editor = PathBuf::from(editor); let editor = if is_valid_executable(&editor) { editor } else if let Some(editor) = resolve_path( &editor, &std::env::var("PATH").unwrap_or(env!("SUDO_PATH_DEFAULT").to_string()), ) { editor } else { continue; }; if is_whitelisted(&editor) { return editor; } } } // no acceptable editor found in environment, fallback on config for editor in blessed_editors.split(':') { let editor = PathBuf::from(editor); if is_valid_executable(&editor) { return editor; } } // fallback on hardcoded path -- always provide something to the caller PathBuf::from(defaults::SYSTEM_EDITOR) } // a `take_while` variant that does not consume the first non-matching item fn peeking_take_while<'a, T>( iter: &'a mut std::iter::Peekable>, pred: impl Fn(&T) -> bool + 'a, ) -> impl Iterator + 'a { std::iter::from_fn(move || iter.next_if(&pred)) } fn group_cmd_specs_per_runas<'a>( cmnd_specs: impl Iterator, (Tag, &'a Spec))>, cmnd_aliases: &'a VecOrd>, ) -> impl Iterator> { // `distribute_tags` will have given every spec a reference to the "runas specification" // that applies to it. The output of sudo --list splits the CmndSpec list based on that: // every line only has a single "runas" specifier. So we need to combine them for that. // // But sudo --list also outputs lines that are from different lines in the sudoers file on // different lines in the output of sudo --list, so we cannot compare "by value". Luckily, // once a RunAs is parsed, it will have a unique identifier in the form of its address. let origin = |runas: Option<&RunAs>| runas.map(|r| r as *const _); let mut cmnd_specs = cmnd_specs.peekable(); std::iter::from_fn(move || { if let Some(&(cur_runas, _)) = cmnd_specs.peek() { let specs = peeking_take_while(&mut cmnd_specs, |&(runas, _)| { origin(runas) == origin(cur_runas) }); Some(Entry::new( cur_runas, specs.map(|x| x.1).collect(), cmnd_aliases, )) } else { None } }) } fn read_sudoers(mut reader: R) -> io::Result>> { // it's a bit frustrating that BufReader.chars() does not exist let mut buffer = String::new(); reader.read_to_string(&mut buffer)?; use basic_parser::parse_lines; use char_stream::*; Ok(parse_lines(&mut CharStream::new(&buffer))) } fn open_sudoers(path: &Path) -> io::Result>> { let source = audit::secure_open_sudoers(path, false)?; read_sudoers(source) } fn open_subsudoers(path: &Path) -> io::Result>> { let source = audit::secure_open_sudoers(path, true)?; read_sudoers(source) } // note: trying to DRY using GAT's is tempting but doesn't make the code any shorter #[derive(Default)] struct AliasTable { user: VecOrd>, host: VecOrd>, cmnd: VecOrd>, runas: VecOrd>, } #[derive(Default)] struct CustomiserTable { non_cmnd: Vec>, cmnd: Vec>>, } /// A vector with a list defining the order in which it needs to be processed struct VecOrd(Vec, Vec); impl Default for VecOrd { fn default() -> Self { VecOrd(Vec::default(), Vec::default()) } } impl VecOrd { fn iter(&self) -> impl DoubleEndedIterator + Clone { self.0.iter().map(|&i| &self.1[i]) } } /// Check if the user `am_user` is allowed to run `cmdline` on machine `on_host` as the requested /// user/group. Not that in the sudoers file, later permissions override earlier restrictions. /// The `cmdline` argument should already be ready to essentially feed to an exec() call; or be /// a special command like 'sudoedit'. // This code is structure to allow easily reading the 'happy path'; i.e. as soon as something // doesn't match, we escape using the '?' mechanism. fn check_permission, Group: UnixGroup>( sudoers: &Sudoers, am_user: &User, on_host: &system::Hostname, request: Request, ) -> Option { let cmdline = (request.command, request.arguments); let aliases = &sudoers.aliases; let cmnd_aliases = get_aliases(&aliases.cmnd, &match_command(cmdline)); let runas_user_aliases = get_aliases(&aliases.runas, &match_user(request.user)); let runas_group_aliases = get_aliases(&aliases.runas, &match_group_alias(request.group)); let matching_user_specs = sudoers.matching_user_specs(am_user, on_host).flatten(); let allowed_commands = matching_user_specs.filter_map(|(runas, cmdspec)| { if let Some(RunAs { users, groups }) = runas { let stays_in_group = in_group(request.user, request.group); if request.user != am_user || (stays_in_group && !users.is_empty()) { find_item(users, &match_user(request.user), &runas_user_aliases)? } if !stays_in_group { find_item(groups, &match_group(request.group), &runas_group_aliases)? } } else if !(request.user.is_root() && in_group(request.user, request.group)) { None?; } Some(cmdspec) }); find_item(allowed_commands, &match_command(cmdline), &cmnd_aliases) } /// Process a raw parsed AST bit of RunAs + Command specifications: /// - RunAs specifications distribute over the commands that follow (until overridden) /// - Tags accumulate over the entire line fn distribute_tags( runas_cmds: &[(Option, CommandSpec)], ) -> impl Iterator, (Tag, &Spec))> { runas_cmds.iter().scan( (None, Default::default()), |(last_runas, tag), (runas, CommandSpec(mods, cmd))| { *last_runas = runas.as_ref().or(*last_runas); for f in mods { f(tag); } let this_tag = match cmd { Qualified::Allow(Meta::All) if tag.env != EnvironmentControl::Nosetenv => Tag { // "ALL" has an implicit "SETENV" that doesn't distribute env: EnvironmentControl::Setenv, ..tag.clone() }, _ => tag.clone(), }; Some((*last_runas, (this_tag, cmd))) }, ) } /// A type to represent positive or negative association with an alias; i.e. if a key maps to true, /// the alias affirms membership, if a key maps to false, the alias denies membership; if a key /// isn't present membership is affirmed nor denied type FoundAliases = HashMap; /// Find an item matching a certain predicate in an collection (optionally attributed) list of /// identifiers; identifiers can be directly identifying, wildcards, and can either be positive or /// negative (i.e. preceeded by an even number of exclamation marks in the sudoers file) fn find_item<'a, Predicate, Iter, T: 'a>( items: Iter, matches: &Predicate, aliases: &FoundAliases, ) -> Option<::Info> where Predicate: Fn(&T) -> bool, Iter: IntoIterator, Iter::Item: WithInfo>, { let mut result = None; for item in items { let (judgement, who) = match item.as_inner() { Qualified::Forbid(x) => (false, x), Qualified::Allow(x) => (true, x), }; let info = || item.into_info(); match who { Meta::All => result = judgement.then(info), Meta::Only(ident) if matches(ident) => result = judgement.then(info), Meta::Alias(id) if aliases.contains_key(id) => { result = if aliases[id] { judgement.then(info) } else { // in this case, an explicit negation in the alias applies (!judgement).then(info) } } _ => {} }; } result } /// A interface to access optional "satellite data" trait WithInfo { type Item; type Info; fn as_inner(&self) -> Self::Item; fn into_info(self) -> Self::Info; } /// A specific interface for `Spec` --- we can't make a generic one; /// A `Spec` does not contain any additional information. impl<'a, T> WithInfo for &'a Spec { type Item = &'a Spec; type Info = (); fn as_inner(&self) -> &'a Spec { self } fn into_info(self) {} } /// A commandspec can be "tagged" impl<'a> WithInfo for (Tag, &'a Spec) { type Item = &'a Spec; type Info = Tag; fn as_inner(&self) -> &'a Spec { self.1 } fn into_info(self) -> Tag { self.0 } } /// Now follow a collection of functions used as closures for `find_item` fn match_user(user: &impl UnixUser) -> impl Fn(&UserSpecifier) -> bool + '_ { move |spec| match spec { UserSpecifier::User(id) => match_identifier(user, id), UserSpecifier::Group(Identifier::Name(name)) => user.in_group_by_name(name.as_cstr()), UserSpecifier::Group(Identifier::ID(num)) => user.in_group_by_gid(GroupId::new(*num)), // nonunix-groups, netgroups, etc. are not implemented UserSpecifier::NonunixGroup(group) => { match group { Identifier::Name(name) => auth_warn!("warning: non-unix group {name} was ignored"), Identifier::ID(num) => auth_warn!("warning: non-unix group #{num} was ignored"), } false } } } fn in_group(user: &impl UnixUser, group: &impl UnixGroup) -> bool { user.in_group_by_gid(group.as_gid()) } fn match_group(group: &impl UnixGroup) -> impl Fn(&Identifier) -> bool + '_ { move |id| match id { Identifier::ID(num) => group.as_gid() == GroupId::new(*num), Identifier::Name(name) => group.try_as_name().is_some_and(|s| name == s), } } fn match_group_alias(group: &impl UnixGroup) -> impl Fn(&UserSpecifier) -> bool + '_ { move |spec| match spec { UserSpecifier::User(ident) => match_group(group)(ident), /* the parser does not allow this, but can happen due to Runas_Alias, * see https://github.com/trifectatechfoundation/sudo-rs/issues/13 */ _ => { auth_warn!("warning: ignoring %group syntax in runas_alias for checking sudo -g"); false } } } fn match_token>( text: &str, ) -> impl Fn(&T) -> bool + '_ { move |token| token.as_str() == text } fn match_command<'a>((cmd, args): (&'a Path, &'a [String])) -> impl Fn(&Command) -> bool + 'a { let opts = glob::MatchOptions { require_literal_separator: true, ..glob::MatchOptions::new() }; move |(cmdpat, argpat)| { cmdpat.matches_path_with(cmd, opts) && argpat.as_ref().map_or(true, |vec| args == vec.as_ref()) } } /// Find all the aliases that a object is a member of; this requires [sanitize_alias_table] to have run first; /// I.e. this function should not be "pub". fn get_aliases(table: &VecOrd>, pred: &Predicate) -> FoundAliases where Predicate: Fn(&T) -> bool, { use std::iter::once; let all = Qualified::Allow(Meta::All); let mut set = HashMap::new(); for Def(id, list) in table.iter() { if find_item(list, &pred, &set).is_some() { set.insert(id.clone(), true); } else if find_item(once(&all).chain(list), &pred, &set).is_none() { // the item wasn't found even if we prepend ALL to the list of definitions; that means // it is explicitly excluded by the alias definition. set.insert(id.clone(), false); } } set } /// Code to map an ast::Identifier to the UnixUser trait fn match_identifier(user: &impl UnixUser, ident: &ast::Identifier) -> bool { match ident { Identifier::Name(name) => user.has_name(name), Identifier::ID(num) => user.has_uid(UserId::new(*num)), } } /// Process a sudoers-parsing file into a workable AST fn analyze( path: &Path, sudoers: impl IntoIterator>, ) -> (Sudoers, Vec) { use Directive::*; let mut result: Sudoers = Default::default(); fn resolve_relative(base: &Path, path: impl AsRef) -> PathBuf { if path.as_ref().is_relative() { // there should always be a parent since we start with /etc/sudoers, and make every other path // absolute based on previous inputs; not having a parent is therefore a serious bug base.parent() .expect("invalid hardcoded path in sudo-rs") .join(path) } else { path.as_ref().into() } } fn include( cfg: &mut Sudoers, parent: &Path, span: Span, path: &Path, diagnostics: &mut Vec, count: &mut u8, ) { if *count >= INCLUDE_LIMIT { diagnostics.push(Error { source: Some(parent.to_owned()), location: Some(span), message: format!("include file limit reached opening '{}'", path.display()), }) // FIXME: this will cause an error in `visudo` if we open a non-privileged sudoers file // that includes another non-privileged sudoer files. } else { match open_subsudoers(path) { Ok(subsudoer) => { *count += 1; process(cfg, path, subsudoer, diagnostics, count) } Err(e) => { let message = if e.kind() == io::ErrorKind::NotFound { // improve the error message in this case format!("cannot open sudoers file '{}'", path.display()) } else { e.to_string() }; diagnostics.push(Error { source: Some(parent.to_owned()), location: Some(span), message, }) } } } } fn process( cfg: &mut Sudoers, cur_path: &Path, sudoers: impl IntoIterator>, diagnostics: &mut Vec, safety_count: &mut u8, ) { for item in sudoers { match item { Ok(line) => match line { Sudo::LineComment => {} Sudo::Spec(permission) => cfg.rules.push(permission), Sudo::Decl(HostAlias(mut def)) => cfg.aliases.host.1.append(&mut def), Sudo::Decl(UserAlias(mut def)) => cfg.aliases.user.1.append(&mut def), Sudo::Decl(RunasAlias(mut def)) => cfg.aliases.runas.1.append(&mut def), Sudo::Decl(CmndAlias(mut def)) => cfg.aliases.cmnd.1.append(&mut def), Sudo::Decl(Defaults(params, scope)) => { if let ConfigScope::Command(specs) = scope { cfg.customisers.cmnd.push(( specs .into_iter() .map(|spec| spec.map(|simple_command| (simple_command, None))) .collect(), params, )); } else { cfg.customisers.non_cmnd.push((scope, params)); } } Sudo::Include(path, span) => include( cfg, cur_path, span, &resolve_relative(cur_path, path), diagnostics, safety_count, ), Sudo::IncludeDir(path, span) => { if path.contains("%h") { diagnostics.push(Error { source: Some(cur_path.to_owned()), location: Some(span), message: format!( "cannot open sudoers file {path}: \ percent escape %h in includedir is unsupported" ), }); continue; } let path = resolve_relative(cur_path, path); let Ok(files) = std::fs::read_dir(&path) else { diagnostics.push(Error { source: Some(cur_path.to_owned()), location: Some(span), message: format!("cannot open sudoers file {}", path.display()), }); continue; }; let mut safe_files = files .filter_map(|direntry| { let path = direntry.ok()?.path(); let text = path.file_name()?.to_str()?; if text.ends_with('~') || text.contains('.') { None } else { Some(path) } }) .collect::>(); safe_files.sort(); for file in safe_files { include( cfg, cur_path, span, file.as_ref(), diagnostics, safety_count, ) } } }, Err(basic_parser::Status::Fatal(pos, message)) => diagnostics.push(Error { source: Some(cur_path.to_owned()), location: Some(pos), message, }), Err(_) => panic!("internal parser error"), } } } let mut diagnostics = vec![]; process(&mut result, path, sudoers, &mut diagnostics, &mut 0); let alias = &mut result.aliases; alias.user.0 = sanitize_alias_table(&alias.user.1, &mut diagnostics); alias.host.0 = sanitize_alias_table(&alias.host.1, &mut diagnostics); alias.cmnd.0 = sanitize_alias_table(&alias.cmnd.1, &mut diagnostics); alias.runas.0 = sanitize_alias_table(&alias.runas.1, &mut diagnostics); (result, diagnostics) } /// Alias definition inin a Sudoers file can come in any order; and aliases can refer to other aliases, etc. /// It is much easier if they are presented in a "definitional order" (i.e. aliases that use other aliases occur later) /// At the same time, this is a good place to detect problems in the aliases, such as unknown aliases and cycles. fn sanitize_alias_table(table: &Vec>, diagnostics: &mut Vec) -> Vec { fn remqualify(item: &Qualified) -> &U { match item { Qualified::Allow(x) => x, Qualified::Forbid(x) => x, } } // perform a topological sort (hattip david@tweedegolf.com) to produce a derangement struct Visitor<'a, T> { seen: HashSet, table: &'a Vec>, order: Vec, diagnostics: &'a mut Vec, } impl Visitor<'_, T> { fn complain(&mut self, text: String) { self.diagnostics.push(Error { source: None, location: None, message: text, }) } fn visit(&mut self, pos: usize) { if self.seen.insert(pos) { let Def(_, members) = &self.table[pos]; for elem in members { let Meta::Alias(name) = remqualify(elem) else { continue; }; let Some(dependency) = self.table.iter().position(|Def(id, _)| id == name) else { self.complain(format!("undefined alias: '{name}'")); continue; }; self.visit(dependency); } self.order.push(pos); } else if !self.order.contains(&pos) { let Def(id, _) = &self.table[pos]; self.complain(format!("recursive alias: '{id}'")); } } } let mut visitor = Visitor { seen: HashSet::new(), table, order: Vec::with_capacity(table.len()), diagnostics, }; let mut dupe = HashSet::new(); for (i, Def(name, _)) in table.iter().enumerate() { if !dupe.insert(name) { visitor.complain(format!("multiple occurrences of '{name}'")); } else { visitor.visit(i); } } visitor.order } #[cfg(test)] mod test; sudo-rs-0.2.10/src/sudoers/policy.rs000064400000000000000000000203011046102023000154140ustar 00000000000000use super::Sudoers; use super::Judgement; use crate::common::{ SudoPath, HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_VALUE_2, }; use crate::exec::Umask; use crate::sudoers::ast::{ExecControl, Tag}; use crate::system::{time::Duration, Hostname, User}; /// Data types and traits that represent what the "terms and conditions" are after a successful /// permission check. /// /// The trait definitions can be part of some global crate in the future, if we support more /// than just the sudoers file. use std::collections::HashSet; #[must_use] #[cfg_attr(test, derive(Debug, PartialEq))] #[repr(u32)] pub enum Authorization { Allowed(Authentication, T) = HARDENED_ENUM_VALUE_0, Forbidden = HARDENED_ENUM_VALUE_1, } #[cfg_attr(test, derive(Debug, PartialEq))] #[must_use] pub struct Authentication { pub must_authenticate: bool, pub credential: AuthenticatingUser, pub allowed_attempts: u16, pub prior_validity: Duration, pub pwfeedback: bool, pub password_timeout: Option, } impl super::Settings { pub(super) fn to_auth(&self, tag: &Tag) -> Authentication { Authentication { must_authenticate: tag.needs_passwd(), allowed_attempts: self.passwd_tries().try_into().unwrap(), prior_validity: Duration::seconds(self.timestamp_timeout()), pwfeedback: self.pwfeedback(), password_timeout: match self.passwd_timeout() { 0 => None, timeout => Some(Duration::seconds(timeout)), }, credential: if self.rootpw() { AuthenticatingUser::Root } else if self.targetpw() { AuthenticatingUser::TargetUser } else { AuthenticatingUser::InvokingUser }, } } } #[must_use] #[cfg_attr(test, derive(Debug, PartialEq))] pub struct Restrictions<'a> { pub use_pty: bool, pub trust_environment: bool, pub noexec: bool, pub env_keep: &'a HashSet, pub env_check: &'a HashSet, pub chdir: DirChange<'a>, pub path: Option<&'a str>, pub umask: Umask, pub noninteractive_auth: bool, #[cfg(feature = "apparmor")] pub apparmor_profile: Option, } #[must_use] #[cfg_attr(test, derive(Debug, PartialEq))] #[repr(u32)] pub enum DirChange<'a> { Strict(Option<&'a SudoPath>) = HARDENED_ENUM_VALUE_0, Any = HARDENED_ENUM_VALUE_1, } #[cfg_attr(test, derive(Debug, PartialEq))] #[repr(u32)] pub enum AuthenticatingUser { InvokingUser = HARDENED_ENUM_VALUE_0, Root = HARDENED_ENUM_VALUE_1, TargetUser = HARDENED_ENUM_VALUE_2, } impl Judgement { pub fn authorization(&self) -> Authorization> { // NOTE: we should add conditional compilation to the DSL; this avoids getting // an unused warning message #[cfg(not(feature = "apparmor"))] let _ = &self.settings.apparmor_profile(); if let Some(tag) = &self.flags { Authorization::Allowed( self.settings.to_auth(tag), Restrictions { use_pty: self.settings.use_pty(), trust_environment: match tag.env { super::EnvironmentControl::Implicit => self.settings.setenv(), super::EnvironmentControl::Setenv => true, super::EnvironmentControl::Nosetenv => false, }, noexec: match tag.noexec { ExecControl::Implicit => self.settings.noexec(), ExecControl::Exec => false, ExecControl::Noexec => true, }, env_keep: self.settings.env_keep(), env_check: self.settings.env_check(), chdir: match tag.cwd.as_ref() { None => DirChange::Strict(None), Some(super::ChDir::Any) => DirChange::Any, Some(super::ChDir::Path(path)) => DirChange::Strict(Some(path)), }, path: self.settings.secure_path(), umask: { let mask = self .settings .umask() .try_into() .expect("the umask parser should have prevented overflow"); if mask == 0o777 { Umask::Preserve } else if self.settings.umask_override() { Umask::Override(mask) } else { Umask::Extend(mask) } }, noninteractive_auth: self.settings.noninteractive_auth(), #[cfg(feature = "apparmor")] apparmor_profile: tag .apparmor_profile .as_deref() .or(self.settings.apparmor_profile()) .map(|x| x.to_string()), }, ) } else { Authorization::Forbidden } } #[cfg_attr(not(feature = "sudoedit"), allow(unused))] pub(crate) fn preferred_editor(&self) -> std::path::PathBuf { super::select_editor(&self.settings, true) } } impl Sudoers { pub fn search_path( &mut self, on_host: &Hostname, current_user: &User, target_user: &User, ) -> Option<&str> { self.specify_host_user_runas(on_host, current_user, Some(target_user)); self.settings.secure_path() } } #[cfg(test)] mod test { use super::*; use crate::sudoers::{ ast::{Authenticate, Tag}, tokens::ChDir, }; impl Judgement { fn mod_flag(&mut self, mut modify: impl FnMut(&mut Tag)) { let mut tag: Tag = self.flags.clone().unwrap_or_default(); modify(&mut tag); self.flags = Some(tag); } } #[test] fn authority_xlat_test() { let mut judge: Judgement = Default::default(); assert_eq!(judge.authorization(), Authorization::Forbidden); judge.mod_flag(|tag| tag.authenticate = Authenticate::Passwd); let Authorization::Allowed(auth, restrictions) = judge.authorization() else { panic!(); }; assert_eq!( auth, Authentication { must_authenticate: true, allowed_attempts: 3, prior_validity: Duration::minutes(15), credential: AuthenticatingUser::InvokingUser, pwfeedback: false, password_timeout: Some(Duration::seconds(300)), }, ); let mut judge = judge.clone(); judge.mod_flag(|tag| tag.authenticate = Authenticate::Nopasswd); let Authorization::Allowed(auth, restrictions2) = judge.authorization() else { panic!(); }; assert_eq!( auth, Authentication { must_authenticate: false, allowed_attempts: 3, prior_validity: Duration::minutes(15), credential: AuthenticatingUser::InvokingUser, pwfeedback: false, password_timeout: Some(Duration::seconds(300)), }, ); assert_eq!(restrictions, restrictions2); } #[test] fn chdir_test() { let mut judge = Judgement { flags: Some(Tag::default()), ..Default::default() }; fn chdir(judge: &mut Judgement) -> DirChange<'_> { let Authorization::Allowed(_, ctl) = judge.authorization() else { panic!() }; ctl.chdir } assert_eq!(chdir(&mut judge), DirChange::Strict(None)); judge.mod_flag(|tag| tag.cwd = Some(ChDir::Any)); assert_eq!(chdir(&mut judge), DirChange::Any); judge.mod_flag(|tag| tag.cwd = Some(ChDir::Path("/usr".into()))); assert_eq!(chdir(&mut judge), (DirChange::Strict(Some(&"/usr".into())))); judge.mod_flag(|tag| tag.cwd = Some(ChDir::Path("/bin".into()))); assert_eq!(chdir(&mut judge), (DirChange::Strict(Some(&"/bin".into())))); } } sudo-rs-0.2.10/src/sudoers/test/mod.rs000064400000000000000000000726311046102023000156700ustar 00000000000000use std::ffi::CStr; use super::ast; use super::char_stream::CharStream; use super::*; use basic_parser::{parse_eval, parse_lines, parse_string}; #[derive(PartialEq)] struct Named(&'static str); fn dummy_cksum(name: &str) -> u32 { if name == "root" { 0 } else { 1000 + name.chars().fold(0, |x, y| (x * 97 + y as u32) % 1361) } } impl UnixUser for Named { fn has_name(&self, name: &str) -> bool { self.0 == name } fn has_uid(&self, uid: UserId) -> bool { UserId::new(dummy_cksum(self.0)) == uid } fn in_group_by_name(&self, name: &CStr) -> bool { self.has_name(name.to_str().unwrap()) } fn in_group_by_gid(&self, gid: GroupId) -> bool { GroupId::new(dummy_cksum(self.0)) == gid } fn is_root(&self) -> bool { self.0 == "root" } type Group = Named; fn group(&self) -> Named { Self(self.0) } } impl UnixGroup for Named { fn as_gid(&self) -> GroupId { GroupId::new(dummy_cksum(self.0)) } fn try_as_name(&self) -> Option<&str> { Some(self.0) } } macro_rules! request { ($user:ident) => { (&Named(stringify!($user)), &Named(stringify!($user))) }; ($user:ident, $group:ident) => { (&Named(stringify!($user)), &Named(stringify!($group))) }; } macro_rules! sudoer { ($($e:expr),*) => { parse_lines(&mut CharStream::new(&[$($e),*, ""].join("\n"))) .into_iter() .map(|x| Ok::<_,basic_parser::Status>(x.unwrap())) } } // alternative to parse_eval, but goes through sudoer! directly #[must_use] fn parse_line(s: &str) -> Sudo { sudoer![s].next().unwrap().unwrap() } /// Returns `None` if a syntax error is encountered fn try_parse_line(s: &str) -> Option { parse_lines(&mut CharStream::new(&[s, ""].join(""))) .into_iter() .next()? .ok() } #[test] fn ambiguous_spec() { assert!(parse_eval::("marc, User_Alias ALL = ALL").is_spec()); } #[test] fn permission_test() { let root = || (&Named("root"), &Named("root")); let realpath = |path: &Path| crate::common::resolve::canonicalize(path).unwrap_or(path.to_path_buf()); macro_rules! FAIL { ([$($sudo:expr),*], $user:expr => $req:expr, $server:expr; $command:expr) => { let (Sudoers { rules,aliases,settings, customisers }, _) = analyze(Path::new("/etc/fakesudoers"), sudoer![$($sudo),*]); let cmdvec = $command.split_whitespace().map(String::from).collect::>(); let req = Request { user: $req.0, group: $req.1, command: &realpath(cmdvec[0].as_ref()), arguments: &cmdvec[1..].to_vec() }; assert_eq!(Sudoers { rules, aliases, settings, customisers }.check(&Named($user), &system::Hostname::fake($server), req).flags, None); } } macro_rules! pass { ([$($sudo:expr),*], $user:expr => $req:expr, $server:expr; $command:expr $(=> [$($key:ident : $val:expr),*])?) => { let (Sudoers { rules,aliases,settings, customisers }, _) = analyze(Path::new("/etc/fakesudoers"), sudoer![$($sudo),*]); let cmdvec = $command.split_whitespace().map(String::from).collect::>(); let req = Request { user: $req.0, group: $req.1, command: &realpath(cmdvec[0].as_ref()), arguments: &cmdvec[1..].to_vec() }; let result = Sudoers { rules, aliases, settings, customisers }.check(&Named($user), &system::Hostname::fake($server), req).flags; assert!(!result.is_none()); $( let result = result.unwrap(); $(assert_eq!(result.$key, $val);)* )? } } macro_rules! SYNTAX { ([$sudo:expr]) => { assert!(parse_string::($sudo).is_err()) }; } SYNTAX!(["ALL ALL = (;) ALL"]); FAIL!(["user ALL=(ALL:ALL) ALL"], "nobody" => root(), "server"; "/bin/hello"); pass!(["user ALL=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => root(), "server"; "/bin/foo" => [authenticate: Authenticate::None]); FAIL!(["user ALL=(ALL:ALL) /bin/foo"], "user" => root(), "server"; "/bin/hello"); pass!(["user ALL=(ALL:ALL) PASSWD: /bin/foo"], "user" => root(), "server"; "/bin/foo" => [authenticate: Authenticate::Passwd]); pass!(["user ALL=(ALL:ALL) NOPASSWD: PASSWD: /bin/foo"], "user" => root(), "server"; "/bin/foo" => [authenticate: Authenticate::Passwd]); pass!(["user ALL=(ALL:ALL) PASSWD: NOPASSWD: /bin/foo"], "user" => root(), "server"; "/bin/foo" => [authenticate: Authenticate::Nopasswd]); pass!(["user ALL=(ALL:ALL) /bin/foo, NOPASSWD: /bin/bar"], "user" => root(), "server"; "/bin/foo" => [authenticate: Authenticate::None]); pass!(["user ALL=(ALL:ALL) /bin/foo, NOPASSWD: /bin/bar"], "user" => root(), "server"; "/bin/bar" => [authenticate: Authenticate::Nopasswd]); pass!(["user ALL=(ALL:ALL) NOPASSWD: /bin/foo, /bin/bar"], "user" => root(), "server"; "/bin/bar" => [authenticate: Authenticate::Nopasswd]); pass!(["user ALL=(ALL:ALL) CWD=/ /bin/foo, /bin/bar"], "user" => root(), "server"; "/bin/bar" => [cwd: Some(ChDir::Path("/".into()))]); pass!(["user ALL=(ALL:ALL) CWD=/ /bin/foo, CWD=* /bin/bar"], "user" => root(), "server"; "/bin/bar" => [cwd: Some(ChDir::Any)]); pass!(["user ALL=(ALL:ALL) CWD=/bin CWD=* /bin/foo"], "user" => root(), "server"; "/bin/foo" => [cwd: Some(ChDir::Any)]); pass!(["user ALL=(ALL:ALL) CWD=/usr/bin NOPASSWD: /bin/foo"], "user" => root(), "server"; "/bin/foo" => [authenticate: Authenticate::Nopasswd, cwd: Some(ChDir::Path("/usr/bin".into()))]); //note: original sudo does not allow the below pass!(["user ALL=(ALL:ALL) NOPASSWD: CWD=/usr/bin /bin/foo"], "user" => root(), "server"; "/bin/foo" => [authenticate: Authenticate::Nopasswd, cwd: Some(ChDir::Path("/usr/bin".into()))]); pass!(["user ALL=/bin/e##o"], "user" => root(), "vm"; "/bin/e"); SYNTAX!(["ALL ALL=(ALL) /bin/\n/echo"]); pass!(["user server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); FAIL!(["user laptop=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); pass!(["user ALL=!/bin/hello", "user ALL=/bin/hello"], "user" => root(), "server"; "/bin/hello"); FAIL!(["user ALL=/bin/hello", "user ALL=!/bin/hello"], "user" => root(), "server"; "/bin/hello"); for alias in [ "User_Alias GROUP=user1, user2", "User_Alias GROUP=ALL,!user3", ] { pass!([alias,"GROUP ALL=/bin/hello"], "user1" => root(), "server"; "/bin/hello"); pass!([alias,"GROUP ALL=/bin/hello"], "user2" => root(), "server"; "/bin/hello"); FAIL!([alias,"GROUP ALL=/bin/hello"], "user3" => root(), "server"; "/bin/hello"); } pass!(["user ALL=/bin/hello arg"], "user" => root(), "server"; "/bin/hello arg"); pass!(["user ALL=/bin/hello arg"], "user" => root(), "server"; "/bin/hello arg"); pass!(["user ALL=/bin/hello arg"], "user" => root(), "server"; "/bin/hello arg"); FAIL!(["user ALL=/bin/hello arg"], "user" => root(), "server"; "/bin/hello boo"); // several test cases with globbing in the arguments are explicitly not supported by sudo-rs //pass!(["user ALL=/bin/hello a*g"], "user" => root(), "server"; "/bin/hello aaaarg"); //FAIL!(["user ALL=/bin/hello a*g"], "user" => root(), "server"; "/bin/hello boo"); pass!(["user ALL=/bin/hello"], "user" => root(), "server"; "/bin/hello boo"); FAIL!(["user ALL=/bin/hello \"\""], "user" => root(), "server"; "/bin/hello boo"); pass!(["user ALL=/bin/hello \"\""], "user" => root(), "server"; "/bin/hello"); pass!(["user ALL=/bin/hel*"], "user" => root(), "server"; "/bin/hello"); pass!(["user ALL=/bin/hel*"], "user" => root(), "server"; "/bin/help"); pass!(["user ALL=/bin/hel*"], "user" => root(), "server"; "/bin/help me"); //pass!(["user ALL=/bin/hel* *"], "user" => root(), "server"; "/bin/help"); FAIL!(["user ALL=/bin/hel* me"], "user" => root(), "server"; "/bin/help"); pass!(["user ALL=/bin/hel* me"], "user" => root(), "server"; "/bin/help me"); FAIL!(["user ALL=/bin/hel* me"], "user" => root(), "server"; "/bin/help me please"); pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => root(), "server"; "/bin/foo" => [authenticate: Authenticate::None]); pass!(["root ALL=(ALL:ALL) /bin/foo"], "root" => root(), "server"; "/bin/foo" => [authenticate: Authenticate::Nopasswd]); pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => request! { user, user }, "server"; "/bin/foo" => [authenticate: Authenticate::Nopasswd]); pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => request! { user, root }, "server"; "/bin/foo" => [authenticate: Authenticate::None]); assert_eq!(Named("user").as_gid(), GroupId::new(1466)); pass!(["#1466 server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); pass!(["%#1466 server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); FAIL!(["#1466 server=(ALL:ALL) ALL"], "root" => root(), "server"; "/bin/hello"); FAIL!(["%#1466 server=(ALL:ALL) ALL"], "root" => root(), "server"; "/bin/hello"); pass!(["#1466,#1234,foo server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); pass!(["#1234,foo,#1466 server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); pass!(["foo,#1234,#1466 server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); FAIL!(["foo,#1234,#1366 server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); FAIL!(["#1366,#1234,foo server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello"); pass!(["user ALL=(ALL:#1466) /bin/foo"], "user" => request! { root, root }, "server"; "/bin/foo"); FAIL!(["user ALL=(ALL:#1466) /bin/foo"], "user" => request! { root, other }, "server"; "/bin/foo"); pass!(["user ALL=(ALL:#1466) /bin/foo"], "user" => request! { root, user }, "server"; "/bin/foo"); pass!(["user ALL=(root,user:ALL) /bin/foo"], "user" => request! { root, wheel }, "server"; "/bin/foo"); pass!(["user ALL=(root,user:ALL) /bin/foo"], "user" => request! { user, wheel }, "server"; "/bin/foo"); FAIL!(["user ALL=(root,user:ALL) /bin/foo"], "user" => request! { sudo, wheel }, "server"; "/bin/foo"); FAIL!(["user ALL=(#0:wheel) /bin/foo"], "user" => request! { sudo, wheel }, "server"; "/bin/foo"); pass!(["user ALL=(#0:wheel) /bin/foo"], "user" => request! { root, root }, "server"; "/bin/foo"); FAIL!(["user ALL=(%#1466:wheel) /bin/foo"], "user" => request! { root, root }, "server"; "/bin/foo"); pass!(["user ALL=(%#1466:wheel) /bin/foo"], "user" => request! { user, user }, "server"; "/bin/foo"); // tests with a 'singular' runas spec FAIL!(["user ALL=(ALL) /bin/foo"], "user" => request! { sudo, wheel }, "server"; "/bin/foo"); pass!(["user ALL=(ALL) /bin/foo"], "user" => request! { sudo, sudo }, "server"; "/bin/foo"); // tests without a runas spec FAIL!(["user ALL=/bin/foo"], "user" => request! { sudo, sudo }, "server"; "/bin/foo"); FAIL!(["user ALL=/bin/foo"], "user" => request! { sudo, root }, "server"; "/bin/foo"); FAIL!(["user ALL=/bin/foo"], "user" => request! { root, sudo }, "server"; "/bin/foo"); pass!(["user ALL=/bin/foo"], "user" => request! { root, root }, "server"; "/bin/foo"); // slightly counterintuitive test which simulates only -g being passed pass!(["user ALL=(sudo:sudo) /bin/foo"], "user" => request! { user, sudo }, "server"; "/bin/foo"); // tests with multiple runas specs pass!(["user ALL=(root) /bin/ls, (sudo) /bin/true"], "user" => request! { root }, "server"; "/bin/ls"); pass!(["user ALL=(root) NOPASSWD: /bin/ls, (sudo) /bin/true"], "user" => request! { sudo }, "server"; "/bin/true" => [authenticate: Authenticate::Nopasswd]); FAIL!(["user ALL=(root) /bin/ls, (sudo) /bin/true"], "user" => request! { sudo }, "server"; "/bin/ls"); FAIL!(["user ALL=(root) /bin/ls, (sudo) /bin/true"], "user" => request! { root }, "server"; "/bin/true"); pass!(["user ALL=(root) NOPASSWD: /bin/ls, (sudo) /bin/ls, /bin/true"], "user" => request! { sudo }, "server"; "/bin/true"); SYNTAX!(["User_Alias, marc ALL = ALL"]); pass!(["User_Alias FULLTIME=ALL,!marc","FULLTIME ALL=ALL"], "user" => root(), "server"; "/bin/bash"); FAIL!(["User_Alias FULLTIME=ALL,!marc","FULLTIME ALL=ALL"], "marc" => root(), "server"; "/bin/bash"); FAIL!(["User_Alias FULLTIME=ALL,!marc","ALL,!FULLTIME ALL=ALL"], "user" => root(), "server"; "/bin/bash"); pass!(["User_Alias FULLTIME=ALL,!!!marc","ALL,!FULLTIME ALL=ALL"], "marc" => root(), "server"; "/bin/bash"); pass!(["Host_Alias MACHINE=laptop,server","user MACHINE=ALL"], "user" => root(), "server"; "/bin/bash"); pass!(["Host_Alias MACHINE=laptop,server","user MACHINE=ALL"], "user" => root(), "laptop"; "/bin/bash"); FAIL!(["Host_Alias MACHINE=laptop,server","user MACHINE=ALL"], "user" => root(), "desktop"; "/bin/bash"); pass!(["Cmnd_Alias WHAT=/bin/dd, /bin/rm","user ALL=WHAT"], "user" => root(), "server"; "/bin/rm"); pass!(["Cmd_Alias WHAT=/bin/dd,/bin/rm","user ALL=WHAT"], "user" => root(), "laptop"; "/bin/dd"); FAIL!(["Cmnd_Alias WHAT=/bin/dd,/bin/rm","user ALL=WHAT"], "user" => root(), "desktop"; "/bin/bash"); pass!(["User_Alias A=B","User_Alias B=user","A ALL=ALL"], "user" => root(), "vm"; "/bin/ls"); pass!(["Host_Alias A=B","Host_Alias B=vm","ALL A=ALL"], "user" => root(), "vm"; "/bin/ls"); pass!(["Cmnd_Alias A=B","Cmnd_Alias B=/bin/ls","ALL ALL=A"], "user" => root(), "vm"; "/bin/ls"); FAIL!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=() ALL"], "user" => request!{ sudo, sudo }, "vm"; "/bin/ls"); pass!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=(TIME) ALL"], "user" => request! { sudo, sudo }, "vm"; "/bin/ls"); FAIL!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=(:TIME) ALL"], "user" => request! { sudo, sudo }, "vm"; "/bin/ls"); pass!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=(:TIME) ALL"], "user" => request! { user, sudo }, "vm"; "/bin/ls"); pass!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=(TIME) ALL"], "user" => request! { wheel, wheel }, "vm"; "/bin/ls"); pass!(["Runas_Alias \\"," TIME=%wheel \\",",sudo # hallo","user ALL \\","=(TIME) ALL"], "user" => request! { wheel, wheel }, "vm"; "/bin/ls"); // test the less-intuitive "substitution-like" alias mechanism FAIL!(["User_Alias FOO=!user", "ALL, FOO ALL=ALL"], "user" => root(), "vm"; "/bin/ls"); pass!(["User_Alias FOO=!user", "!FOO ALL=ALL"], "user" => root(), "vm"; "/bin/ls"); // quoting pass!(["a\\,b ALL=ALL"], "a,b" => request! { root, root }, "server"; "/bin/foo"); pass!(["\"a,b\" ALL=ALL"], "a,b" => request! { root, root }, "server"; "/bin/foo"); pass!(["\"a\\b\" ALL=ALL"], "a\\b" => request! { root, root }, "server"; "/bin/foo"); // special chacters pass!(["foo@machine.name ALL=ALL"], "foo@machine.name" => request! { root, root }, "server"; "/bin/foo"); pass!(["fnord$ ALL=ALL"], "fnord$" => request! { root, root }, "server"; "/bin/foo"); pass!(["ALL ALL=/foo/command --bar=1"], "user" => request! { root, root }, "server"; "/foo/command --bar=1"); // apparmor #[cfg(feature = "apparmor")] pass!(["ALL ALL=(ALL:ALL) APPARMOR_PROFILE=unconfined ALL"], "user" => root(), "server"; "/bin/bar" => [apparmor_profile: Some("unconfined".to_string())]); // list pass!(["ALL ALL=(ALL:ALL) /bin/ls, list"], "user" => root(), "server"; "list"); FAIL!(["ALL ALL=(ALL:ALL) ALL, !list"], "user" => root(), "server"; "list"); } #[test] fn default_bool_test() { let (mut sudoers, _) = analyze( Path::new("/etc/fakesudoers"), sudoer![ "Defaults env_editor", "Defaults !use_pty", "Defaults use_pty", "Defaults !env_keep", "Defaults !secure_path", "Defaults !env_editor" ], ); sudoers.specify_host_user_runas( &system::Hostname::fake("host"), &Named("user"), Some(&Named("root")), ); assert!(!sudoers.settings.env_editor()); assert!(sudoers.settings.use_pty()); assert!(sudoers.settings.env_keep().is_empty()); assert_eq!(sudoers.settings.secure_path(), None); assert!(!sudoers.settings.env_editor()); } #[test] fn default_set_test() { let (mut sudoers, _) = analyze( Path::new("/etc/fakesudoers"), sudoer![ "Defaults env_keep = \"FOO HUK BAR\"", "Defaults env_keep -= HUK", "Defaults !env_check", "Defaults env_check += \"FOO\"", "Defaults env_check += \"XYZZY\"", "Defaults passwd_tries = 5", "Defaults secure_path = /etc" ], ); sudoers.specify_host_user_runas( &system::Hostname::fake("host"), &Named("user"), Some(&Named("root")), ); assert_eq!( sudoers.settings.env_keep(), &["FOO", "BAR"].into_iter().map(|x| x.to_string()).collect() ); assert_eq!( sudoers.settings.env_check(), &["FOO", "XYZZY"] .into_iter() .map(|x| x.to_string()) .collect() ); assert_eq!(sudoers.settings.secure_path(), Some("/etc")); assert_eq!(sudoers.settings.passwd_tries(), 5); assert!(parse_string::("Defaults verifypw = \"sometimes\"").is_err()); assert!(parse_string::("Defaults verifypw = sometimes").is_err()); assert!(parse_string::("Defaults verifypw = never").is_ok()); } #[test] fn default_multi_test() { let (mut sudoers, _) = analyze( Path::new("/etc/fakesudoers"), sudoer![ "Defaults !env_editor, use_pty, env_keep = \"FOO BAR\", env_keep -= BAR, secure_path=/etc" ], ); sudoers.specify_host_user_runas( &system::Hostname::fake("host"), &Named("user"), Some(&Named("root")), ); assert!(!sudoers.settings.env_editor()); assert!(sudoers.settings.use_pty()); assert_eq!(sudoers.settings.secure_path(), Some("/etc")); assert_eq!( sudoers.settings.env_keep(), &["FOO".to_string()].into_iter().collect() ); } #[test] #[should_panic] fn invalid_directive() { parse_eval::("User_Alias, user Alias = user1, user2"); } #[test] #[should_panic = "embedded $ in username"] fn invalid_username() { parse_eval::("User_Alias FOO = $dollar"); } #[test] fn inclusive_username() { let UserSpecifier::User(Identifier::Name(sirin)) = parse_eval::("şirin") else { panic!(); }; assert_eq!(sirin, "şirin"); } #[test] fn sudoedit_recognized() { let CommandSpec(_, Qualified::Allow(Meta::Only((cmd, args)))) = parse_eval::("sudoedit /etc/tmux.conf") else { panic!(); }; assert_eq!(cmd.as_str(), "sudoedit"); assert_eq!(args.unwrap().as_ref(), &["/etc/tmux.conf"][..]); } #[test] #[should_panic = "list does not take arguments"] fn list_does_not_take_args() { parse_eval::("list /etc/tmux.conf"); } #[test] fn directive_test() { let y = parse_eval::>; match parse_eval::("User_Alias HENK = user1, user2") { Sudo::Decl(Directive::UserAlias(defs)) => { let [Def(name, list)] = &defs[..] else { panic!("incorrectly parsed") }; assert_eq!(name, "HENK"); assert_eq!(*list, vec![y("user1"), y("user2")]); } _ => panic!("incorrectly parsed"), } match parse_eval::("Runas_Alias FOO = foo : BAR = bar") { Sudo::Decl(Directive::RunasAlias(defs)) => { let [Def(name1, list1), Def(name2, list2)] = &defs[..] else { panic!("incorrectly parsed") }; assert_eq!(name1, "FOO"); assert_eq!(*list1, vec![y("foo")]); assert_eq!(name2, "BAR"); assert_eq!(*list2, vec![y("bar")]); } _ => panic!("incorrectly parsed"), } } #[test] // the overloading of '#' causes a lot of issues fn hashsign_test() { assert!(parse_line("#42 ALL=ALL").is_spec()); assert!(parse_line("ALL ALL=(#42) ALL").is_spec()); assert!(parse_line("ALL ALL=(%#42) ALL").is_spec()); assert!(parse_line("ALL ALL=(:#42) ALL").is_spec()); assert!(parse_line("User_Alias FOO=#42, %#0, #3").is_decl()); assert!(parse_line("").is_line_comment()); assert!(parse_line("#this is a comment").is_line_comment()); assert!(parse_line("#include foo").is_include()); assert!(parse_line("#includedir foo").is_include_dir()); assert_eq!("foo bar", parse_line("#include \"foo bar\"").as_include()); // this is fine assert!(parse_line("#inlcudedir foo").is_line_comment()); assert!(parse_line("@include foo").is_include()); assert!(parse_line("@includedir foo").is_include_dir()); assert_eq!("foo bar", parse_line("@include \"foo bar\"").as_include()); } #[test] fn gh674_at_include_quoted_backslash() { assert!(parse_line(r#"@include "/etc/sudo\ers" "#).is_include()); assert!(parse_line(r#"@includedir "/etc/sudo\ers.d" "#).is_include_dir()); } #[test] fn gh676_percent_h_escape_unsupported() { let (_, errs) = analyze( Path::new("/etc/fakesudoers"), sudoer!(r#"@includedir "/etc/%h" "#), ); assert_eq!(errs.len(), 1); assert_eq!( errs[0].message, "cannot open sudoers file /etc/%h: percent escape %h in includedir is unsupported" ); assert_eq!( errs[0].location, Some(Span { start: (1, 2), end: (1, 23) }) ); } #[test] fn gh1295_escaped_equal_argument_ok() { assert!(try_parse_line("Cmd_Alias FOO_CMD = /bin/foo --bar=1").is_some()); assert!(try_parse_line(r"Cmd_Alias FOO_CMD = /bin/foo --bar\=1").is_some()); } #[test] fn hashsign_error() { assert!(parse_line("#include foo bar").is_line_comment()); } #[test] fn include_regression() { assert!(try_parse_line("#4,#include foo").is_none()); } #[test] fn nullbyte_regression() { assert!(try_parse_line("ferris ALL=(ALL:ferris\0) ALL").is_none()); } #[test] fn alias_all_regression() { assert!(try_parse_line("User_Alias ALL = sudouser").is_none()) } #[test] fn defaults_regression() { assert!(try_parse_line("Defaults .mymachine=ALL").is_none()) } #[test] fn specific_defaults() { assert!(parse_line("Defaults !use_pty").is_decl()); assert!(try_parse_line("Defaults!use_pty").is_none()); assert!(parse_line("Defaults!/bin/bash !use_pty").is_decl()); assert!(try_parse_line("Defaults!/bin/bash!use_pty").is_none()); assert!(try_parse_line("Defaults !/bin/bash !use_pty").is_none()); assert!(try_parse_line("Defaults !/bin/bash").is_none()); assert!(parse_line("Defaults@host !use_pty").is_decl()); assert!(parse_line("Defaults@host!use_pty").is_decl()); assert!(try_parse_line("Defaults @host!use_pty").is_none()); assert!(try_parse_line("Defaults @host !use_pty").is_none()); assert!(parse_line("Defaults:user !use_pty").is_decl()); assert!(parse_line("Defaults:user!use_pty").is_decl()); assert!(try_parse_line("Defaults :user!use_pty").is_none()); assert!(try_parse_line("Defaults :user !use_pty").is_none()); assert!(parse_line("Defaults>user !use_pty").is_decl()); assert!(parse_line("Defaults>user!use_pty").is_decl()); assert!(try_parse_line("Defaults >user!use_pty").is_none()); assert!(try_parse_line("Defaults >user !use_pty").is_none()); } #[test] fn at_sign_ambiguity() { assert!(parse_line("Defaults@host env_keep=ALL").is_decl()); assert!(parse_line("defaults@host env_keep=ALL").is_spec()); } #[test] fn default_specific_test() { let sudoers = || { analyze( Path::new("/etc/fakesudoers"), sudoer![ "Defaults!RR use_pty", "Defaults env_editor", "Defaults@host !env_editor", "Defaults !use_pty", "Defaults:user use_pty", "Defaults !secure_path", "Defaults>runas secure_path=\"/bin\"", "Defaults!/bin/foo !env_keep", "Cmnd_Alias RR=/usr/bin/rr twice" ], ) }; let (mut base_sudoers, _) = sudoers(); base_sudoers.specify_host_user_runas( &system::Hostname::fake("generic"), &Named("generic"), Some(&Named("generic")), ); assert!(base_sudoers.settings.env_editor()); assert!(!base_sudoers.settings.use_pty()); assert!(base_sudoers.settings.env_keep().contains("COLORS")); assert_eq!(base_sudoers.settings.secure_path(), None); let (mut mod_sudoers, _) = sudoers(); mod_sudoers.specify_host_user_runas( &system::Hostname::fake("host"), &Named("user"), Some(&Named("root")), ); assert!(!mod_sudoers.settings.env_editor()); assert!(mod_sudoers.settings.use_pty()); assert!(mod_sudoers.settings.env_keep().contains("COLORS")); assert_eq!(mod_sudoers.settings.secure_path(), None); let (mut mod_sudoers, _) = sudoers(); mod_sudoers.specify_host_user_runas( &system::Hostname::fake("machine"), &Named("admin"), Some(&Named("runas")), ); assert!(mod_sudoers.settings.env_editor()); assert!(!mod_sudoers.settings.use_pty()); assert!(mod_sudoers.settings.env_keep().contains("COLORS")); assert_eq!(mod_sudoers.settings.secure_path(), Some("/bin")); mod_sudoers.specify_command(Path::new("/bin/foo"), &["".to_string(), "a".to_string()]); assert!(mod_sudoers.settings.env_keep().is_empty()); let (mut mod_sudoers, _) = sudoers(); mod_sudoers.specify_host_user_runas( &system::Hostname::fake("machine"), &Named("admin"), Some(&Named("self")), ); mod_sudoers.specify_command(Path::new("/usr/bin/rr"), &["thrice".to_string()]); assert!(mod_sudoers.settings.env_editor()); assert!(!mod_sudoers.settings.use_pty()); assert!(mod_sudoers.settings.env_keep().contains("COLORS")); assert_eq!(mod_sudoers.settings.secure_path(), None); let (mut mod_sudoers, _) = sudoers(); mod_sudoers.specify_command(Path::new("/usr/bin/rr"), &["twice".to_string()]); assert!(mod_sudoers.settings.use_pty()); } #[test] fn useralias_underscore_regression() { let sudo = parse_line("FOO_BAR ALL=ALL"); let spec = sudo.as_spec().expect("`Sudo::Spec`"); assert!(spec.users[0] .as_allow() .expect("`Qualified::Allow`") .is_alias()); } #[test] fn regression_check_recursion() { let (_, error) = analyze( Path::new("/etc/fakesudoers"), sudoer!["User_Alias A=user, B", "User_Alias B=A"], ); assert!(!error.is_empty()); } fn test_topo_sort(n: usize) { let alias = |s: &str| Qualified::Allow(Meta::::Alias(s.to_string())); let stop = || Qualified::Allow(Meta::::All); type Elem = Spec; let test_case = |x1: Elem, x2: Elem, x3: Elem| { let table = vec![ Def("AAP".to_string(), vec![x1]), Def("NOOT".to_string(), vec![x2]), Def("MIES".to_string(), vec![x3]), ]; let mut err = vec![]; let order = sanitize_alias_table(&table, &mut err); assert!(err.is_empty()); let mut seen = HashSet::new(); for Def(id, defns) in order.iter().map(|&i| &table[i]) { if defns.iter().any(|spec| { let Qualified::Allow(Meta::Alias(id2)) = spec else { return false; }; !seen.contains(id2) }) { panic!("forward reference encountered after sorting"); } seen.insert(id); } }; match n { 0 => test_case(alias("AAP"), alias("NOOT"), stop()), 1 => test_case(alias("AAP"), stop(), alias("NOOT")), 2 => test_case(alias("NOOT"), alias("AAP"), stop()), 3 => test_case(alias("NOOT"), stop(), alias("AAP")), 4 => test_case(stop(), alias("AAP"), alias("NOOT")), 5 => test_case(stop(), alias("NOOT"), alias("AAP")), _ => panic!("error in test case"), } } #[test] fn test_topo_positive() { test_topo_sort(3); test_topo_sort(4); } #[test] #[should_panic] fn test_topo_fail0() { test_topo_sort(0); } #[test] #[should_panic] fn test_topo_fail1() { test_topo_sort(1); } #[test] #[should_panic] fn test_topo_fail2() { test_topo_sort(2); } #[test] #[should_panic] fn test_topo_fail5() { test_topo_sort(5); } fn fuzz_topo_sort(siz: usize) { for mut n in 0..(1..siz).reduce(|x, y| x * y).unwrap() { let name = |s: u8| std::str::from_utf8(&[65 + s]).unwrap().to_string(); let alias = |s: String| Qualified::Allow(Meta::::Alias(s)); let stop = || Qualified::Allow(Meta::::All); let mut data = (0..siz - 1) .map(|i| alias(name(i as u8))) .collect::>(); data.push(stop()); for i in (1..=siz).rev() { data.swap(i - 1, n % i); n /= i; } let table = data .into_iter() .enumerate() .map(|(i, x)| Def(name(i as u8), vec![x])) .collect(); let mut err = vec![]; let order = sanitize_alias_table(&table, &mut err); if !err.is_empty() { return; } let mut seen = HashSet::new(); for Def(id, defns) in order.iter().map(|&i| &table[i]) { if defns.iter().any(|spec| { let Qualified::Allow(Meta::Alias(id2)) = spec else { return false; }; !seen.contains(id2) }) { panic!("forward reference encountered after sorting"); } seen.insert(id); } assert!(seen.len() == siz); } } #[test] fn fuzz_topo_sort7() { fuzz_topo_sort(7) } sudo-rs-0.2.10/src/sudoers/tokens.rs000064400000000000000000000270341046102023000154320ustar 00000000000000//! Various tokens use crate::common::{SudoPath, SudoString}; use super::basic_parser::{Many, Token}; use crate::common::{HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_VALUE_2}; #[cfg_attr(test, derive(Clone, PartialEq, Eq))] pub struct Username(pub SudoString); /// A username consists of alphanumeric characters as well as ".", "-", "_". /// Furthermore, it may contain embedded "@" characters (but not start with them) and end in a "$". // See: https://systemd.io/USER_NAMES/ impl Token for Username { fn construct(text: String) -> Result { // if a '$' occurs in a username, it has to be the final character if text.strip_suffix('$').unwrap_or(&text).contains('$') { return Err("embedded $ in username".to_string()); } SudoString::new(text) .map_err(|e| e.to_string()) .map(Username) } fn accept(c: char) -> bool { c.is_alphanumeric() || ".-_@$".contains(c) } fn accept_1st(c: char) -> bool { c != '@' && Self::accept(c) } const ALLOW_ESCAPE: bool = true; fn escaped(c: char) -> bool { matches!(c, '\\' | '"' | ',' | ':' | '=' | '!' | '(' | ')' | ' ') } } impl Many for Username {} pub struct Digits(pub u32); impl Token for Digits { const MAX_LEN: usize = 9; fn construct(s: String) -> Result { Ok(Digits(s.parse().unwrap())) } fn accept(c: char) -> bool { c.is_ascii_digit() } } pub struct Numeric(pub String); impl Token for Numeric { const MAX_LEN: usize = 18; fn construct(s: String) -> Result { Ok(Numeric(s)) } fn accept(c: char) -> bool { c.is_ascii_hexdigit() || c == '.' } } /// A hostname consists of alphanumeric characters and ".", "-", "_" pub struct Hostname(pub String); impl std::ops::Deref for Hostname { type Target = String; fn deref(&self) -> &Self::Target { &self.0 } } impl Token for Hostname { fn construct(text: String) -> Result { Ok(Hostname(text)) } fn accept(c: char) -> bool { c.is_ascii_alphanumeric() || ".-_".contains(c) } } impl Many for Hostname {} /// This enum allows items to use the ALL wildcard or be specified with aliases, or directly. /// (Maybe this is better defined not as a Token but simply directly as an implementation of [crate::sudoers::basic_parser::Parse]) #[cfg_attr(test, derive(Debug, PartialEq, Eq))] #[repr(u32)] pub enum Meta { All = HARDENED_ENUM_VALUE_0, Only(T) = HARDENED_ENUM_VALUE_1, Alias(String) = HARDENED_ENUM_VALUE_2, } impl Meta { #[cfg(test)] pub fn is_alias(&self) -> bool { matches!(self, Self::Alias(..)) } } impl Token for Meta { fn construct(raw: String) -> Result { // `T` may accept whitespace resulting in `raw` having trailing whitespace which would make // the first two checks below fail. this `cooked` version has no trailing whitespace let cooked = raw.trim_end().to_string(); Ok(if cooked == "ALL" { Meta::All } else if cooked.starts_with(AliasName::accept_1st) && cooked.chars().skip(1).all(AliasName::accept) { Meta::Alias(cooked) } else { Meta::Only(T::construct(raw)?) }) } const MAX_LEN: usize = T::MAX_LEN; fn accept(c: char) -> bool { T::accept(c) || c.is_uppercase() } fn accept_1st(c: char) -> bool { T::accept_1st(c) || c.is_uppercase() } const ALLOW_ESCAPE: bool = T::ALLOW_ESCAPE; fn escaped(c: char) -> bool { T::escaped(c) } } impl Many for Meta { const SEP: char = T::SEP; const LIMIT: usize = T::LIMIT; } /// An identifier that consists of only uppercase characters. pub struct AliasName(pub String); impl Token for AliasName { fn construct(s: String) -> Result { Ok(AliasName(s)) } fn accept_1st(c: char) -> bool { c.is_ascii_uppercase() } fn accept(c: char) -> bool { Self::accept_1st(c) || c.is_ascii_digit() || c == '_' } } /// A struct that represents valid command strings; this can contain escape sequences and are /// limited to 1024 characters. pub type Command = (SimpleCommand, Option>); /// A type that is specific to 'only commands', that can only happen in "Defaults!command" contexts; /// which is essentially a subset of "Command" pub type SimpleCommand = glob::Pattern; impl Token for Command { const MAX_LEN: usize = 1024; fn construct(s: String) -> Result { // the tokenizer should not give us a token that consists of only whitespace let mut cmd_iter = s.split_whitespace(); let cmd = cmd_iter.next().unwrap().to_string(); let mut args = cmd_iter.map(String::from).collect::>(); let command = SimpleCommand::construct(cmd)?; let argpat = if args.is_empty() { // if no arguments are mentioned, anything is allowed None } else { if args.last().map(|x| -> &str { x }) == Some("\"\"") { // if the magic "" appears, no (further) arguments are allowed args.pop(); } Some(args.into_boxed_slice()) }; if command.as_str() == "list" && argpat.is_some() { return Err("list does not take arguments".to_string()); } Ok((command, argpat)) } // all commands start with "/" except "sudoedit" or "list" fn accept_1st(c: char) -> bool { SimpleCommand::accept_1st(c) } fn accept(c: char) -> bool { SimpleCommand::accept(c) || c == ' ' } const ALLOW_ESCAPE: bool = SimpleCommand::ALLOW_ESCAPE; fn escaped(c: char) -> bool { SimpleCommand::escaped(c) } } impl Token for SimpleCommand { const MAX_LEN: usize = 1024; fn construct(mut cmd: String) -> Result { let cvt_err = |pat: Result<_, glob::PatternError>| { pat.map_err(|err| format!("wildcard pattern error {err}")) }; // detect the two edges cases if cmd == "list" || cmd == "sudoedit" { return cvt_err(glob::Pattern::new(&cmd)); } else if cmd.starts_with("sha") { return Err("digest specifications are not supported".to_string()); } else if !cmd.starts_with('/') { return Err("fully qualified path needed".to_string()); } // record if the cmd ends in a slash and remove it if it does let is_dir = cmd.ends_with('/') && { cmd.pop(); true }; // canonicalize path (if possible) if let Ok(real_cmd) = crate::common::resolve::canonicalize(&cmd) { cmd = real_cmd .to_str() .ok_or("non-UTF8 characters in filesystem")? .to_string(); } // if the cmd ends with a slash, any command in that directory is allowed if is_dir { cmd.push_str("/*"); } cvt_err(glob::Pattern::new(&cmd)) } // all commands start with "/" except "sudoedit" or "list" fn accept_1st(c: char) -> bool { c == '/' || c == 's' || c == 'l' } fn accept(c: char) -> bool { // '=' is allowed both escaped and un-escaped (!Self::escaped(c) && !c.is_control()) || c == '=' } const ALLOW_ESCAPE: bool = true; fn escaped(c: char) -> bool { matches!(c, '\\' | ',' | ':' | '=' | '#' | ' ') } } impl Many for Command {} impl Many for SimpleCommand {} pub struct DefaultName(pub String); impl Token for DefaultName { fn construct(text: String) -> Result { Ok(DefaultName(text)) } fn accept(c: char) -> bool { c.is_ascii_alphanumeric() || c == '_' } } pub struct EnvVar(pub String); impl Token for EnvVar { fn construct(text: String) -> Result { Ok(EnvVar(text)) } fn accept(c: char) -> bool { !c.is_control() && !c.is_whitespace() && !Self::escaped(c) } const ALLOW_ESCAPE: bool = true; fn escaped(c: char) -> bool { matches!(c, '\\' | '=' | '#' | '"' | ',') } } /// A token with a very liberal inner tokenizer; compare StringParameter below pub struct QuotedStringParameter(pub String); impl Token for QuotedStringParameter { const MAX_LEN: usize = 1024; fn construct(s: String) -> Result { Ok(Self(s)) } fn accept(c: char) -> bool { !Self::escaped(c) } const ALLOW_ESCAPE: bool = true; fn escaped(c: char) -> bool { matches!(c, '\\' | '"') || c.is_control() } } /// Similar to QuotedStringParameter but treats backslashes differently /// Compare IncludePath below. // `@include "some/path"` // ^^^^^^^^^^^ pub struct QuotedIncludePath(pub String); impl Token for QuotedIncludePath { const MAX_LEN: usize = 1024; fn construct(s: String) -> Result { Ok(Self(s)) } fn accept(c: char) -> bool { !Self::escaped(c) } const ALLOW_ESCAPE: bool = true; fn escaped(c: char) -> bool { matches!(c, '"') || c.is_control() } } pub struct IncludePath(pub String); impl Token for IncludePath { const MAX_LEN: usize = 1024; fn construct(s: String) -> Result { Ok(IncludePath(s)) } fn accept(c: char) -> bool { !c.is_control() && !Self::escaped(c) } const ALLOW_ESCAPE: bool = true; fn escaped(c: char) -> bool { matches!(c, '\\' | '"' | ' ') } } // used for Defaults where quotes around some items are optional pub struct StringParameter(pub String); impl Token for StringParameter { const MAX_LEN: usize = QuotedStringParameter::MAX_LEN; fn construct(s: String) -> Result { Ok(StringParameter(s)) } fn accept(c: char) -> bool { !c.is_control() && !Self::escaped(c) } const ALLOW_ESCAPE: bool = true; fn escaped(c: char) -> bool { matches!(c, '\\' | '"' | ' ' | '#' | ',') } } // a path used for in CWD and CHROOT specs #[derive(Clone, PartialEq)] #[cfg_attr(test, derive(Debug, Eq))] #[repr(u32)] pub enum ChDir { Path(SudoPath) = HARDENED_ENUM_VALUE_0, Any = HARDENED_ENUM_VALUE_1, } impl Token for ChDir { const MAX_LEN: usize = 1024; fn construct(s: String) -> Result { if s == "*" { Ok(ChDir::Any) } else if s.contains('*') { Err("path cannot contain '*'".to_string()) } else { Ok(ChDir::Path( SudoPath::try_from(s).map_err(|e| e.to_string())?, )) } } fn accept(c: char) -> bool { !c.is_control() && !Self::escaped(c) } fn accept_1st(c: char) -> bool { "~/*".contains(c) } const ALLOW_ESCAPE: bool = true; fn escaped(c: char) -> bool { matches!(c, '\\' | '"' | ' ') } } /// Some tokens that support escape characters also support being surrounded by quotes to avoid escaping directly. pub struct Unquoted(pub String, pub std::marker::PhantomData); impl Token for Unquoted { const MAX_LEN: usize = 1024; fn construct(text: String) -> Result { let mut quoted = String::new(); for ch in text.chars() { if T::escaped(ch) { quoted.push('\\'); } quoted.push(ch); } Ok(Self(quoted, std::marker::PhantomData)) } fn accept(c: char) -> bool { c != '"' && !c.is_control() } } sudo-rs-0.2.10/src/system/audit.rs000064400000000000000000000320171046102023000150720ustar 00000000000000#![cfg_attr(not(feature = "sudoedit"), allow(dead_code))] use std::collections::HashSet; use std::ffi::{CStr, CString}; use std::fs::{DirBuilder, File, Metadata, OpenOptions}; use std::io::{self, Error, ErrorKind}; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd}; use std::os::unix::{ ffi::OsStrExt, fs::{DirBuilderExt, MetadataExt, PermissionsExt}, prelude::OpenOptionsExt, }; use std::path::{Component, Path}; use super::{ cerr, inject_group, interface::UnixUser, set_supplementary_groups, Group, GroupId, User, UserId, }; use crate::common::resolve::CurrentUser; /// Temporary change privileges --- essentially a 'mini sudo' /// This is only used for sudoedit. pub(crate) fn sudo_call( target_user: &User, target_group: &Group, operation: impl FnOnce() -> T, ) -> io::Result { const KEEP_UID: libc::uid_t = -1i32 as libc::uid_t; const KEEP_GID: libc::gid_t = -1i32 as libc::gid_t; // SAFETY: these libc functions are always safe to call let (cur_user_id, cur_group_id) = unsafe { (UserId::new(libc::geteuid()), GroupId::new(libc::getegid())) }; let cur_groups = { // SAFETY: calling with size 0 does not modify through the pointer, and is // a documented way of getting the length needed. let len = cerr(unsafe { libc::getgroups(0, std::ptr::null_mut()) })?; let mut buf: Vec = vec![GroupId::new(KEEP_GID); len as usize]; // SAFETY: we pass a correct pointer to a slice of the given length cerr(unsafe { // We can cast to gid_t because `GroupId` is marked as transparent libc::getgroups(len, buf.as_mut_ptr().cast::()) })?; buf }; let mut target_groups = target_user.groups.clone(); inject_group(target_group.gid, &mut target_groups); if cfg!(test) && target_user.uid == cur_user_id && target_group.gid == cur_group_id && target_groups.iter().collect::>() == cur_groups.iter().collect::>() { // we are not actually switching users, simply run the closure // (this would also be safe in production mode, but it is a needless check) return Ok(operation()); } struct ResetUserGuard(UserId, GroupId, Vec); impl Drop for ResetUserGuard { fn drop(&mut self) { // restore privileges in reverse order (|| { // SAFETY: this function is always safe to call cerr(unsafe { libc::setresuid(KEEP_UID, UserId::inner(&self.0), KEEP_UID) })?; // SAFETY: this function is always safe to call cerr(unsafe { libc::setresgid(KEEP_GID, GroupId::inner(&self.1), KEEP_GID) })?; set_supplementary_groups(&self.2) })() .expect("could not restore to saved user id"); } } let guard = ResetUserGuard(cur_user_id, cur_group_id, cur_groups); set_supplementary_groups(&target_groups)?; // SAFETY: this function is always safe to call cerr(unsafe { libc::setresgid(KEEP_GID, GroupId::inner(&target_group.gid), KEEP_GID) })?; // SAFETY: this function is always safe to call cerr(unsafe { libc::setresuid(KEEP_UID, UserId::inner(&target_user.uid), KEEP_UID) })?; let result = operation(); std::mem::drop(guard); Ok(result) } // of course we can also write "file & 0o040 != 0", but this makes the intent explicit enum Op { Read = 4, Write = 2, Exec = 1, } enum Category { Owner = 2, Group = 1, World = 0, } fn mode(who: Category, what: Op) -> u32 { (what as u32) << (3 * who as u32) } /// Open sudo configuration using various security checks pub fn secure_open_sudoers(path: impl AsRef, check_parent_dir: bool) -> io::Result { let mut open_options = OpenOptions::new(); open_options.read(true); secure_open_impl(path.as_ref(), &mut open_options, check_parent_dir, false) } /// Open a timestamp cookie file using various security checks pub fn secure_open_cookie_file(path: impl AsRef) -> io::Result { let mut open_options = OpenOptions::new(); open_options .read(true) .write(true) .create(true) .truncate(false) .mode(mode(Category::Owner, Op::Write) | mode(Category::Owner, Op::Read)); secure_open_impl(path.as_ref(), &mut open_options, true, true) } fn checks(path: &Path, meta: Metadata) -> io::Result<()> { let error = |msg| Error::new(ErrorKind::PermissionDenied, msg); let path_mode = meta.permissions().mode(); if meta.uid() != 0 { Err(error(format!("{} must be owned by root", path.display()))) } else if meta.gid() != 0 && (path_mode & mode(Category::Group, Op::Write) != 0) { Err(error(format!( "{} cannot be group-writable", path.display() ))) } else if path_mode & mode(Category::World, Op::Write) != 0 { Err(error(format!( "{} cannot be world-writable", path.display() ))) } else { Ok(()) } } // Open `path` with options `open_options`, provided that it is "secure". // "Secure" means that it passes the `checks` function above. // If `check_parent_dir` is set, also check that the parent directory is "secure" also. // If `create_parent_dirs` is set, create the path to the file if it does not already exist. fn secure_open_impl( path: &Path, open_options: &mut OpenOptions, check_parent_dir: bool, create_parent_dirs: bool, ) -> io::Result { let error = |msg| Error::new(ErrorKind::PermissionDenied, msg); if check_parent_dir || create_parent_dirs { if let Some(parent_dir) = path.parent() { // if we should create parent dirs and it does not yet exist, create it if create_parent_dirs && !parent_dir.exists() { DirBuilder::new() .recursive(true) .mode( mode(Category::Owner, Op::Write) | mode(Category::Owner, Op::Read) | mode(Category::Owner, Op::Exec) | mode(Category::Group, Op::Exec) | mode(Category::World, Op::Exec), ) .create(parent_dir)?; } if check_parent_dir { let parent_meta = std::fs::metadata(parent_dir)?; checks(parent_dir, parent_meta)?; } } else { return Err(error(format!( "{} has no valid parent directory", path.display() ))); } } let file = open_options.open(path)?; let meta = file.metadata()?; checks(path, meta)?; Ok(file) } fn open_at(parent: BorrowedFd, file_name: &CStr, create: bool) -> io::Result { let flags = if create { libc::O_NOFOLLOW | libc::O_RDWR | libc::O_CREAT } else { libc::O_NOFOLLOW | libc::O_RDONLY }; // the mode for files that are created is hardcoded, as it is in ogsudo let mode = libc::S_IRUSR | libc::S_IWUSR | libc::S_IRGRP | libc::S_IROTH; // SAFETY: by design, a correct CStr pointer is passed to openat; only if this call succeeds // is the file descriptor it returns (which is then necessarily valid) passed to from_raw_fd unsafe { let fd = cerr(libc::openat( parent.as_raw_fd(), file_name.as_ptr(), flags, libc::c_uint::from(mode), ))?; Ok(OwnedFd::from_raw_fd(fd)) } } /// This opens a file for sudoedit, performing security checks (see below) and /// opening with reduced privileges. pub fn secure_open_for_sudoedit( path: impl AsRef, current_user: &CurrentUser, target_user: &User, target_group: &Group, ) -> io::Result { sudo_call(target_user, target_group, || { if current_user.is_root() { OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .open(path) } else { traversed_secure_open(path, current_user) } })? } /// This opens a file making sure that /// - no directory leading up to the file is editable by the user /// - no components are a symbolic link fn traversed_secure_open(path: impl AsRef, forbidden_user: &User) -> io::Result { let path = path.as_ref(); let Some(file_name) = path.file_name() else { return Err(io::Error::new(ErrorKind::InvalidInput, "invalid path")); }; let mut components = path.parent().unwrap_or(Path::new("")).components(); if components.next() != Some(Component::RootDir) { return Err(io::Error::new( ErrorKind::InvalidInput, "path must be absolute", )); } let user_cannot_write = |file: &File| -> io::Result<()> { let meta = file.metadata()?; let perms = meta.permissions().mode(); if perms & mode(Category::World, Op::Write) != 0 || (perms & mode(Category::Group, Op::Write) != 0) && forbidden_user.in_group_by_gid(GroupId::new(meta.gid())) || (perms & mode(Category::Owner, Op::Write) != 0) && forbidden_user.uid.inner() == meta.uid() { Err(io::Error::new( ErrorKind::PermissionDenied, "cannot open a file in a path writable by the user", )) } else { Ok(()) } }; let mut cur = File::open("/")?; user_cannot_write(&cur)?; for component in components { let dir: CString = match component { Component::Normal(dir) => CString::new(dir.as_bytes())?, Component::CurDir => cstr!(".").to_owned(), Component::ParentDir => cstr!("..").to_owned(), _ => { return Err(io::Error::new( ErrorKind::InvalidInput, "error in provided path", )) } }; cur = open_at(cur.as_fd(), &dir, false)?.into(); user_cannot_write(&cur)?; } cur = open_at(cur.as_fd(), &CString::new(file_name.as_bytes())?, true)?.into(); user_cannot_write(&cur)?; Ok(cur) } #[cfg(test)] mod test { use super::*; #[test] fn secure_open_is_predictable() { // /etc/hosts should be readable and "secure" (if this test fails, you have been compromised) assert!(std::fs::File::open("/etc/hosts").is_ok()); assert!(secure_open_sudoers("/etc/hosts", false).is_ok()); // /tmp should be readable, but not secure (writable by group other than root) assert!(std::fs::File::open("/tmp").is_ok()); assert!(secure_open_sudoers("/tmp", false).is_err()); #[cfg(target_os = "linux")] { // /var/log/wtmp should be readable, but not secure (writable by group other than root) // It doesn't exist on many non-Linux systems however. if std::fs::File::open("/var/log/wtmp").is_ok() { assert!(secure_open_sudoers("/var/log/wtmp", false).is_err()); } } // /etc/shadow should not be readable assert!(std::fs::File::open("/etc/shadow").is_err()); assert!(secure_open_sudoers("/etc/shadow", false).is_err()); } #[test] fn test_secure_open_cookie_file() { assert!(secure_open_cookie_file("/etc/hosts").is_err()); } #[test] fn test_traverse_secure_open_negative() { use crate::common::resolve::CurrentUser; let root = User::from_name(cstr!("root")).unwrap().unwrap(); let user = CurrentUser::resolve().unwrap(); // not allowed -- invalid assert!(traversed_secure_open("/", &root).is_err()); // not allowed since the path is not absolute assert!(traversed_secure_open("./hello.txt", &root).is_err()); // not allowed since root can write to "/" assert!(traversed_secure_open("/hello.txt", &root).is_err()); // not allowed since "/tmp" is a directory assert!(traversed_secure_open("/tmp", &user).is_err()); // not allowed since anybody can write to "/tmp" assert!(traversed_secure_open("/tmp/foo/hello.txt", &user).is_err()); // not allowed since "/bin" is a symlink assert!(traversed_secure_open("/bin/hello.txt", &user).is_err()); } #[test] fn test_traverse_secure_open_positive() { use crate::common::resolve::CurrentUser; use crate::system::{GroupId, UserId}; let other_user = CurrentUser::fake(User { uid: UserId::new(1042), gid: GroupId::new(1042), name: "test".into(), home: "/home/test".into(), shell: "/bin/sh".into(), groups: vec![], }); // allowed! let path = std::env::current_dir() .unwrap() .join("sudo-rs-test-file.txt"); let file = traversed_secure_open(&path, &other_user).unwrap(); if file.metadata().is_ok_and(|meta| meta.len() == 0) { std::fs::remove_file(path).unwrap(); } } } sudo-rs-0.2.10/src/system/file/chown.rs000064400000000000000000000010761046102023000160220ustar 00000000000000use std::{fs::File, io, os::fd::AsRawFd}; use crate::{ cutils::cerr, system::interface::{GroupId, UserId}, }; pub(crate) trait Chown { fn chown(&self, uid: UserId, gid: GroupId) -> io::Result<()>; } impl Chown for File { fn chown(&self, owner: UserId, group: GroupId) -> io::Result<()> { let fd = self.as_raw_fd(); // SAFETY: `fchown` is passed a proper file descriptor; and even if the user/group id // is invalid, it will not cause UB. cerr(unsafe { libc::fchown(fd, owner.inner(), group.inner()) }).map(|_| ()) } } sudo-rs-0.2.10/src/system/file/lock.rs000064400000000000000000000031531046102023000156320ustar 00000000000000use std::{ fs::File, io::Result, os::fd::{AsRawFd, RawFd}, }; use crate::cutils::cerr; pub(crate) struct FileLock { fd: RawFd, } impl FileLock { /// Get an exclusive lock on the file, waits if there is currently a lock /// on the file if `nonblocking` is true. pub(crate) fn exclusive(file: &File, nonblocking: bool) -> Result { let fd = file.as_raw_fd(); flock(fd, LockOp::LockExclusive, nonblocking)?; Ok(Self { fd }) } /// Release the lock on the file. pub(crate) fn unlock(self) -> Result<()> { flock(self.fd, LockOp::Unlock, false) } } impl Drop for FileLock { fn drop(&mut self) { flock(self.fd, LockOp::Unlock, false).ok(); } } #[derive(Clone, Copy, Debug)] enum LockOp { LockExclusive, Unlock, } impl LockOp { fn as_flock_operation(self) -> libc::c_int { match self { LockOp::LockExclusive => libc::LOCK_EX, LockOp::Unlock => libc::LOCK_UN, } } } fn flock(fd: RawFd, action: LockOp, nonblocking: bool) -> Result<()> { let mut operation = action.as_flock_operation(); if nonblocking { operation |= libc::LOCK_NB; } // SAFETY: even if `fd` would not be a valid file descriptor, that would merely // result in an error condition, not UB cerr(unsafe { libc::flock(fd, operation) })?; Ok(()) } #[cfg(test)] mod tests { use crate::system::tests::tempfile; use super::*; #[test] fn test_locking_of_tmp_file() { let f = tempfile().unwrap(); FileLock::exclusive(&f, false).unwrap().unlock().unwrap(); } } sudo-rs-0.2.10/src/system/file/mod.rs000064400000000000000000000002131046102023000154530ustar 00000000000000mod chown; mod lock; mod tmpdir; pub(crate) use chown::Chown; pub(crate) use lock::FileLock; pub(crate) use tmpdir::create_temporary_dir; sudo-rs-0.2.10/src/system/file/tmpdir.rs000064400000000000000000000012441046102023000162000ustar 00000000000000use std::ffi::{CString, OsString}; use std::io; use std::os::unix::ffi::OsStringExt; use std::path::PathBuf; pub(crate) fn create_temporary_dir() -> io::Result { let template = cstr!("/tmp/sudoers-XXXXXX").to_owned(); // SAFETY: mkdtemp is passed a valid null-terminated C string let ptr = unsafe { libc::mkdtemp(template.into_raw()) }; if ptr.is_null() { return Err(io::Error::last_os_error()); } // SAFETY: ptr is the same pointer produced by into_raw() above, and it // is still pointing to a zero-terminated C string let path = OsString::from_vec(unsafe { CString::from_raw(ptr) }.into_bytes()).into(); Ok(path) } sudo-rs-0.2.10/src/system/interface.rs000064400000000000000000000125111046102023000157210ustar 00000000000000use std::{ffi::CStr, fmt::Display, num::ParseIntError, str::FromStr}; /// Represents a group ID in the system. /// /// `GroupId` is transparent because the memory mapping should stay the same as the underlying /// type, so we can safely cast as a pointer. /// See the implementation in `system::mod::set_target_user`. #[repr(transparent)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct GroupId(libc::gid_t); /// Represents a user ID in the system. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct UserId(libc::uid_t); /// Represents a process ID in the system. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ProcessId(libc::pid_t); /// Represents a device ID in the system. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct DeviceId(libc::dev_t); impl GroupId { pub fn new(id: libc::gid_t) -> Self { Self(id) } pub fn inner(&self) -> libc::gid_t { self.0 } } impl UserId { pub fn new(id: libc::uid_t) -> Self { Self(id) } pub fn inner(&self) -> libc::uid_t { self.0 } pub const ROOT: Self = Self(0); } impl ProcessId { pub fn new(id: libc::pid_t) -> Self { Self(id) } pub fn inner(&self) -> libc::pid_t { self.0 } pub fn is_valid(&self) -> bool { self.0 != 0 } } impl DeviceId { pub fn new(id: libc::dev_t) -> Self { Self(id) } pub fn inner(&self) -> libc::dev_t { self.0 } } impl Display for GroupId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl Display for UserId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl Display for ProcessId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl Display for DeviceId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl FromStr for GroupId { type Err = ParseIntError; fn from_str(s: &str) -> Result { s.parse::().map(GroupId::new) } } impl FromStr for UserId { type Err = ParseIntError; fn from_str(s: &str) -> Result { s.parse::().map(UserId::new) } } /// This trait/module is here to not make this crate independent (at the present time) in the idiosyncrasies of user representation details /// (which we may decide over time), as well as to make explicit what functionality a user-representation must have; this /// interface is not set in stone and "easy" to change. pub trait UnixUser { fn has_name(&self, _name: &str) -> bool; fn has_uid(&self, _uid: UserId) -> bool; fn is_root(&self) -> bool; fn in_group_by_name(&self, _name: &CStr) -> bool; fn in_group_by_gid(&self, _gid: GroupId) -> bool; type Group: UnixGroup; fn group(&self) -> Self::Group; } pub trait UnixGroup { fn as_gid(&self) -> GroupId; fn try_as_name(&self) -> Option<&str>; } impl UnixUser for super::User { fn has_name(&self, name: &str) -> bool { self.name == name } fn has_uid(&self, uid: UserId) -> bool { self.uid == uid } fn is_root(&self) -> bool { self.has_uid(UserId::ROOT) } fn in_group_by_name(&self, name_c: &CStr) -> bool { if let Ok(Some(group)) = super::Group::from_name(name_c) { self.in_group_by_gid(group.gid) } else { false } } fn in_group_by_gid(&self, gid: GroupId) -> bool { self.groups.contains(&gid) } type Group = super::Group; fn group(&self) -> super::Group { Self::Group { gid: self.gid, name: None, } } } impl UnixGroup for super::Group { fn as_gid(&self) -> GroupId { self.gid } fn try_as_name(&self) -> Option<&str> { self.name.as_deref() } } #[cfg(test)] mod test { use super::*; use crate::system::{Group, User, ROOT_GROUP_NAME}; use std::ffi::CString; fn test_user(user: impl UnixUser, name_c: &CStr) { let name = name_c.to_str().unwrap(); assert!(user.has_name(name)); if user.has_name("root") { assert!(user.in_group_by_name(CString::new(ROOT_GROUP_NAME).unwrap().as_c_str())); assert!(user.is_root()); } else { assert!(user.in_group_by_name(name_c)); assert!(!user.is_root()); } assert_eq!(user.is_root(), name == "root"); } fn test_group(group: impl UnixGroup, name: &str) { assert_eq!(name == ROOT_GROUP_NAME, group.as_gid() == GroupId::new(0)); assert_eq!(group.try_as_name(), Some(name)); } #[test] fn test_unix_user() { let user = |name| User::from_name(name).unwrap().unwrap(); test_user(user(cstr!("root")), cstr!("root")); test_user(user(cstr!("daemon")), cstr!("daemon")); } #[test] fn test_unix_group() { let group = |name| Group::from_name(name).unwrap().unwrap(); let root_group_cstr = CString::new(ROOT_GROUP_NAME).unwrap(); test_group(group(root_group_cstr.as_c_str()), ROOT_GROUP_NAME); test_group(group(cstr!("daemon")), "daemon"); } } sudo-rs-0.2.10/src/system/mod.rs000064400000000000000000001150421046102023000145430ustar 00000000000000// TODO: remove unused attribute when system is cleaned up #[cfg(target_os = "linux")] use std::str::FromStr; use std::{ ffi::{c_int, c_uint, CStr}, fmt, fs, io, mem::MaybeUninit, ops, os::unix, path::PathBuf, }; use crate::{ common::{Error, SudoPath, SudoString}, cutils::*, }; use interface::{DeviceId, GroupId, ProcessId, UserId}; pub use libc::PATH_MAX; use libc::{CLOSE_RANGE_CLOEXEC, EINVAL, ENOSYS, STDERR_FILENO}; use time::ProcessCreateTime; use self::signal::SignalNumber; pub(crate) mod audit; // generalized traits for when we want to hide implementations pub mod interface; pub mod file; pub mod time; pub mod timestamp; pub mod signal; pub mod term; pub mod wait; #[cfg(not(any(target_os = "freebsd", target_os = "linux")))] compile_error!("sudo-rs only works on Linux and FreeBSD"); pub(crate) fn _exit(status: libc::c_int) -> ! { // SAFETY: this function is safe to call unsafe { libc::_exit(status) } } /// Mark every file descriptor that is not one of the IO streams as CLOEXEC. pub(crate) fn mark_fds_as_cloexec() -> io::Result<()> { let lowfd = STDERR_FILENO + 1; // SAFETY: this function is safe to call: // - any errors while closing a specific fd will be effectively ignored #[allow(clippy::diverging_sub_expression)] let res = unsafe { 'a: { #[cfg(not(target_os = "linux"))] break 'a cerr(libc::close_range( lowfd as c_uint, c_uint::MAX, CLOSE_RANGE_CLOEXEC as c_int, )); // on Linux, close_range was only added in glibc 2.34, and is not // part of musl, so we go perform a straight syscall instead #[cfg(target_os = "linux")] break 'a cerr(libc::syscall( libc::SYS_close_range, lowfd as c_uint, c_uint::MAX, CLOSE_RANGE_CLOEXEC as c_uint, )); } }; match res { Err(err) if err.raw_os_error() == Some(ENOSYS) || err.raw_os_error() == Some(EINVAL) => { // The kernel doesn't support close_range or CLOSE_RANGE_CLOEXEC, // fallback to finding all open fds using /proc/self/fd. // FIXME use /dev/fd on macOS for entry in fs::read_dir("/proc/self/fd")? { let entry = entry?; let file_name = entry.file_name(); let file_name = file_name.to_str().ok_or(io::Error::new( io::ErrorKind::InvalidData, "procfs returned non-integer fd name", ))?; if file_name == "." || file_name == ".." { continue; } let fd = file_name.parse::().map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, "procfs returned non-integer fd name", ) })?; if fd < lowfd { continue; } // SAFETY: This only sets the CLOEXEC flag for the given fd. Nothing is // going to need it after exec. unsafe { cerr(libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC))?; } } Ok(()) } Err(err) => Err(err), Ok(_) => Ok(()), } } pub(crate) enum ForkResult { // Parent process branch with the child process' PID. Parent(ProcessId), // Child process branch. Child, } /// Create a new process. /// /// # Safety /// /// Must not be called in multithreaded programs. pub(crate) unsafe fn fork() -> io::Result { // FIXME add debug assertion that we are not currently using multiple threads. // SAFETY: Calling async-signal-unsafe functions after fork is safe as the program is single // threaded at this point according to the safety invariant of this function. let pid = cerr(unsafe { libc::fork() })?; if pid == 0 { Ok(ForkResult::Child) } else { Ok(ForkResult::Parent(ProcessId::new(pid))) } } /// Create a new process with extra precautions for usage in tests. /// /// # Safety /// /// In a multithreaded program, only async-signal-safe functions are guaranteed to work in the /// child process until a call to `execve` or a similar function is done. #[cfg(test)] unsafe fn fork_for_test(child_func: impl FnOnce() -> std::convert::Infallible) -> ProcessId { use std::io::Write; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::process::exit; // SAFETY: Not really safe, but this is test only code. match unsafe { fork() }.unwrap() { ForkResult::Child => { // Make sure that panics in the child always abort the process. let err = match catch_unwind(AssertUnwindSafe(child_func)) { Ok(res) => match res {}, Err(err) => err, }; let s = if let Some(s) = err.downcast_ref::<&str>() { s } else if let Some(s) = err.downcast_ref::() { s } else { "Box" }; let _ = writeln!(std::io::stderr(), "{s}"); exit(101); } ForkResult::Parent(pid) => pid, } } pub fn setsid() -> io::Result { // SAFETY: this function is memory-safe to call Ok(ProcessId::new(cerr(unsafe { libc::setsid() })?)) } #[derive(Clone)] #[cfg_attr(test, derive(PartialEq))] pub struct Hostname { inner: String, } impl fmt::Debug for Hostname { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("Hostname").field(&self.inner).finish() } } impl fmt::Display for Hostname { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.inner) } } impl ops::Deref for Hostname { type Target = str; fn deref(&self) -> &str { &self.inner } } impl Hostname { #[cfg(test)] pub fn fake(hostname: &str) -> Self { Self { inner: hostname.to_string(), } } pub fn resolve() -> Self { // see `man 2 gethostname` const MAX_HOST_NAME_SIZE_ACCORDING_TO_SUSV2: libc::c_long = 255; // POSIX.1 systems limit hostnames to `HOST_NAME_MAX` bytes // not including null-byte in the count let max_hostname_size = sysconf(libc::_SC_HOST_NAME_MAX) .unwrap_or(MAX_HOST_NAME_SIZE_ACCORDING_TO_SUSV2) as usize; let buffer_size = max_hostname_size + 1 /* null byte delimiter */ ; let mut buf = vec![0; buffer_size]; // SAFETY: we are passing a valid pointer to gethostname match cerr(unsafe { libc::gethostname(buf.as_mut_ptr(), buffer_size) }) { Ok(_) => Self { // SAFETY: gethostname succeeded, so `buf` will hold a null-terminated C string inner: unsafe { string_from_ptr(buf.as_ptr()) }, }, // ENAMETOOLONG is returned when hostname is greater than `buffer_size` Err(_) => { // but we have chosen a `buffer_size` larger than `max_hostname_size` so no truncation error is possible panic!("Unexpected error while retrieving hostname, this should not happen"); } } } } pub fn syslog(priority: libc::c_int, facility: libc::c_int, message: &CStr) { const MSG: *const libc::c_char = match CStr::from_bytes_until_nul(b"%s\0") { Ok(cstr) => cstr.as_ptr(), Err(_) => panic!("syslog formatting string is not null-terminated"), }; // SAFETY: // - "MSG" is a constant expression that is a null-terminated C string that represents "%s"; // this also means that to achieve safety we MUST pass one more argument to syslog that is a proper // pointer to a null-terminated C string // - message.as_ptr() is a pointer to a proper null-terminated C string (message being a &CStr) // for more info: read the manpage for syslog(2) unsafe { libc::syslog(priority | facility, MSG, message.as_ptr()); } } /// Makes sure that that the target is included in the groups, and is its first element fn inject_group(target: GroupId, groups: &mut Vec) { if let Some(index) = groups.iter().position(|id| id == &target) { // make sure the requested group id is the first in the list (necessary on FreeBSD) groups.swap(0, index) } else { // add target group to list of additional groups if not present groups.insert(0, target); } } /// Set the supplementary groups -- returns a c_int to mimic a libc function fn set_supplementary_groups(groups: &[GroupId]) -> io::Result<()> { // On FreeBSD, setgruops expects the size to be passed as a i32, so the below // conversion protects a very extreme case of arithmetic conversion error #[allow(irrefutable_let_patterns)] #[allow(clippy::useless_conversion)] let Ok(len) = groups.len().try_into() else { return Err(io::Error::new(io::ErrorKind::Other, "too many groups")); }; // SAFETY: setgroups is passed a valid pointer to a chunk of memory of the correct size // We can cast to gid_t because `GroupId` is marked as transparent cerr(unsafe { libc::setgroups(len, groups.as_ptr().cast::()) })?; Ok(()) } /// set target user and groups (uid, gid, additional groups) for a command pub fn set_target_user( cmd: &mut std::process::Command, mut target_user: User, target_group: Group, ) { use std::os::unix::process::CommandExt; inject_group(target_group.gid, &mut target_user.groups); // we need to do this in a `pre_exec` call since the `groups` method in `process::Command` is unstable // see https://github.com/rust-lang/rust/blob/a01b4cc9f375f1b95fa8195daeea938d3d9c4c34/library/std/src/sys/unix/process/process_unix.rs#L329-L352 // for the std implementation of the libc calls to `setgroups`, `setgid` and `setuid` // SAFETY: Setuid, setgid and setgroups are async-signal-safe. unsafe { cmd.pre_exec(move || { set_supplementary_groups(&target_user.groups)?; // setgid and setuid set the real, effective and saved version of the gid and uid // respectively rather than just the real gid and uid. The original sudo uses setresgid // and setresuid instead with all three arguments equal, but as this does the same as // setgid and setuid using the latter is fine too. cerr(libc::setgid(target_group.gid.inner()))?; cerr(libc::setuid(target_user.uid.inner()))?; Ok(()) }); } } /// Send a signal to a process with the specified ID. pub fn kill(pid: ProcessId, signal: SignalNumber) -> io::Result<()> { // SAFETY: This function cannot cause UB even if `pid` is not a valid process ID or if // `signal` is not a valid signal code. cerr(unsafe { libc::kill(pid.inner(), signal) }).map(|_| ()) } /// Send a signal to a process group with the specified ID. pub fn killpg(pgid: ProcessId, signal: SignalNumber) -> io::Result<()> { // SAFETY: This function cannot cause UB even if `pgid` is not a valid process ID or if // `signal` is not a valid signal code. cerr(unsafe { libc::killpg(pgid.inner(), signal) }).map(|_| ()) } /// Get the process group ID of the current process. pub fn getpgrp() -> ProcessId { // SAFETY: This function is always safe to call ProcessId::new(unsafe { libc::getpgrp() }) } /// Get a process group ID. pub fn getpgid(pid: ProcessId) -> io::Result { // SAFETY: This function cannot cause UB even if `pid` is not a valid process ID Ok(ProcessId::new(cerr(unsafe { libc::getpgid(pid.inner()) })?)) } /// Set a process group ID. pub fn setpgid(pid: ProcessId, pgid: ProcessId) -> io::Result<()> { // SAFETY: This function cannot cause UB even if `pid` or `pgid` are not a valid process IDs: // https://pubs.opengroup.org/onlinepubs/007904975/functions/setpgid.html cerr(unsafe { libc::setpgid(pid.inner(), pgid.inner()) }).map(|_| ()) } pub fn chown>( path: &S, uid: impl Into, gid: impl Into, ) -> io::Result<()> { let path = path.as_ref().as_ptr(); let uid = uid.into(); let gid = gid.into(); // SAFETY: path is a valid pointer to a null-terminated C string; chown cannot cause safety // issues even if uid and/or gid would be invalid identifiers. cerr(unsafe { libc::chown(path, uid.inner(), gid.inner()) }).map(|_| ()) } #[derive(Debug, Clone, PartialEq)] pub struct User { pub uid: UserId, pub gid: GroupId, pub name: SudoString, pub home: SudoPath, pub shell: PathBuf, pub groups: Vec, } impl User { /// # Safety /// This function expects `pwd` to be a result from a successful call to `getpwXXX_r`. /// (It can cause UB if any of `pwd`'s pointed-to strings does not have a null-terminator.) unsafe fn from_libc(pwd: &libc::passwd) -> Result { let mut buf_len: libc::c_int = 32; let mut groups_buffer: Vec; while { groups_buffer = vec![0; buf_len as usize]; // SAFETY: getgrouplist is passed valid pointers // in particular `groups_buffer` is an array of `buf.len()` bytes, as required let result = unsafe { libc::getgrouplist( pwd.pw_name, pwd.pw_gid, groups_buffer.as_mut_ptr(), &mut buf_len, ) }; result == -1 } { if buf_len >= 65536 { panic!("user has too many groups (> 65536), this should not happen"); } buf_len *= 2; } groups_buffer.resize_with(buf_len as usize, || { panic!("invalid groups count returned from getgrouplist, this should not happen") }); // SAFETY: All pointers were initialized by a successful call to `getpwXXX_r` as per the // safety invariant of this function. unsafe { Ok(User { uid: UserId::new(pwd.pw_uid), gid: GroupId::new(pwd.pw_gid), name: SudoString::new(string_from_ptr(pwd.pw_name))?, home: SudoPath::new(os_string_from_ptr(pwd.pw_dir).into())?, shell: os_string_from_ptr(pwd.pw_shell).into(), groups: groups_buffer .iter() .map(|id| GroupId::new(*id)) .collect::>(), }) } } pub fn from_uid(uid: UserId) -> Result, Error> { let max_pw_size = sysconf(libc::_SC_GETPW_R_SIZE_MAX).unwrap_or(16_384); let mut buf = vec![0; max_pw_size as usize]; let mut pwd = MaybeUninit::uninit(); let mut pwd_ptr = std::ptr::null_mut(); // SAFETY: getpwuid_r is passed valid (although partly uninitialized) pointers to memory, // in particular `buf` points to an array of `buf.len()` bytes, as required. // After this call, if `pwd_ptr` is not NULL, `*pwd_ptr` and `pwd` will be aliased; // but we never dereference `pwd_ptr`. cerr(unsafe { libc::getpwuid_r( uid.inner(), pwd.as_mut_ptr(), buf.as_mut_ptr(), buf.len(), &mut pwd_ptr, ) })?; if pwd_ptr.is_null() { Ok(None) } else { // SAFETY: pwd_ptr was not null, and getpwuid_r succeeded, so we have assurances that // the `pwd` structure was written to by getpwuid_r let pwd = unsafe { pwd.assume_init() }; // SAFETY: `pwd` was obtained by a call to getpwXXX_r, as required. unsafe { Self::from_libc(&pwd).map(Some) } } } pub fn effective_uid() -> UserId { // SAFETY: this function cannot cause memory safety issues UserId::new(unsafe { libc::geteuid() }) } pub fn effective_gid() -> GroupId { // SAFETY: this function cannot cause memory safety issues GroupId::new(unsafe { libc::getegid() }) } pub fn real_uid() -> UserId { // SAFETY: this function cannot cause memory safety issues UserId::new(unsafe { libc::getuid() }) } pub fn real_gid() -> GroupId { // SAFETY: this function cannot cause memory safety issues GroupId::new(unsafe { libc::getgid() }) } pub fn real() -> Result, Error> { Self::from_uid(Self::real_uid()) } pub fn primary_group(&self) -> std::io::Result { // Use from_gid_unchecked here to ensure that we can still resolve when // the /etc/group entry for the primary group is missing. Group::from_gid_unchecked(self.gid) } pub fn from_name(name_c: &CStr) -> Result, Error> { let max_pw_size = sysconf(libc::_SC_GETPW_R_SIZE_MAX).unwrap_or(16_384); let mut buf = vec![0; max_pw_size as usize]; let mut pwd = MaybeUninit::uninit(); let mut pwd_ptr = std::ptr::null_mut(); // SAFETY: analogous to getpwuid_r above cerr(unsafe { libc::getpwnam_r( name_c.as_ptr(), pwd.as_mut_ptr(), buf.as_mut_ptr(), buf.len(), &mut pwd_ptr, ) })?; if pwd_ptr.is_null() { Ok(None) } else { // SAFETY: pwd_ptr was not null, and getpwnam_r succeeded, so we have assurances that // the `pwd` structure was written to by getpwnam_r let pwd = unsafe { pwd.assume_init() }; // SAFETY: `pwd` was obtained by a call to getpwXXX_r, as required. unsafe { Self::from_libc(&pwd).map(Some) } } } } #[derive(Debug, Clone)] #[cfg_attr(test, derive(PartialEq))] pub struct Group { pub gid: GroupId, pub name: Option, } impl Group { /// # Safety /// This function expects `grp` to be a result from a successful call to `getgrXXX_r`. /// In particular the grp.gr_mem pointer is assumed to be non-null, and pointing to a /// null-terminated list; the pointed-to strings are expected to be null-terminated. unsafe fn from_libc(grp: &libc::group) -> Group { // SAFETY: The name pointer is initialized by a successful call to `getgrXXX_r` as per the // safety invariant of this function. let name = unsafe { string_from_ptr(grp.gr_name) }; Group { gid: GroupId::new(grp.gr_gid), name: Some(name), } } /// Lookup group for gid without returning an error when a /etc/group entry is missing. fn from_gid_unchecked(gid: GroupId) -> std::io::Result { let max_gr_size = sysconf(libc::_SC_GETGR_R_SIZE_MAX).unwrap_or(16_384); let mut buf = vec![0; max_gr_size as usize]; let mut grp = MaybeUninit::uninit(); let mut grp_ptr = std::ptr::null_mut(); // SAFETY: analogous to getpwuid_r above cerr(unsafe { libc::getgrgid_r( gid.inner(), grp.as_mut_ptr(), buf.as_mut_ptr(), buf.len(), &mut grp_ptr, ) })?; if grp_ptr.is_null() { Ok(Group { gid, name: None }) } else { // SAFETY: grp_ptr was not null, and getgrgid_r succeeded, so we have assurances that // the `grp` structure was written to by getgrgid_r let grp = unsafe { grp.assume_init() }; // SAFETY: `pwd` was obtained by a call to getgrXXX_r, as required. Ok(unsafe { Group::from_libc(&grp) }) } } pub fn from_gid(gid: GroupId) -> std::io::Result> { let group = Self::from_gid_unchecked(gid)?; if group.name.is_none() { // No entry in /etc/group Ok(None) } else { Ok(Some(group)) } } pub fn from_name(name_c: &CStr) -> std::io::Result> { let max_gr_size = sysconf(libc::_SC_GETGR_R_SIZE_MAX).unwrap_or(16_384); let mut buf = vec![0; max_gr_size as usize]; let mut grp = MaybeUninit::uninit(); let mut grp_ptr = std::ptr::null_mut(); // SAFETY: analogous to getpwuid_r above cerr(unsafe { libc::getgrnam_r( name_c.as_ptr(), grp.as_mut_ptr(), buf.as_mut_ptr(), buf.len(), &mut grp_ptr, ) })?; if grp_ptr.is_null() { Ok(None) } else { // SAFETY: grp_ptr was not null, and getgrgid_r succeeded, so we have assurances that // the `grp` structure was written to by getgrgid_r let grp = unsafe { grp.assume_init() }; // SAFETY: `pwd` was obtained by a call to getgrXXX_r, as required. Ok(Some(unsafe { Group::from_libc(&grp) })) } } } pub enum WithProcess { Current, Other(ProcessId), } impl WithProcess { #[cfg(target_os = "linux")] fn to_proc_string(&self) -> String { match self { WithProcess::Current => "self".into(), WithProcess::Other(pid) => pid.to_string(), } } } #[derive(Debug, Clone)] pub struct Process { pub pid: ProcessId, pub parent_pid: Option, pub session_id: ProcessId, } impl Default for Process { fn default() -> Self { Self::new() } } impl Process { pub fn new() -> Process { Process { pid: Self::process_id(), parent_pid: Self::parent_id(), session_id: Self::session_id(), } } /// Return the process identifier for the current process pub fn process_id() -> ProcessId { // NOTE libstd casts the `i32` that `libc::getpid` returns into `u32` // here we cast it back into `i32` (`ProcessId`) ProcessId::new(std::process::id() as i32) } /// Return the parent process identifier for the current process pub fn parent_id() -> Option { // NOTE libstd casts the `i32` that `libc::getppid` returns into `u32` // here we cast it back into `i32` (`ProcessId`) let pid = ProcessId::new(unix::process::parent_id() as i32); if !pid.is_valid() { None } else { Some(pid) } } /// Get the session id for the current process pub fn session_id() -> ProcessId { // SAFETY: this function is explicitly safe to call with argument 0, // and more generally getsid will never cause memory safety issues. ProcessId::new(unsafe { libc::getsid(0) }) } /// Returns the device identifier of the TTY device that is currently /// attached to the given process #[cfg(target_os = "linux")] pub fn tty_device_id(pid: WithProcess) -> std::io::Result> { // device id of tty is displayed as a signed integer of 32 bits let data: i32 = read_proc_stat(pid, 6 /* tty_nr */)?; if data == 0 { Ok(None) } else { // While the integer was displayed as signed in the proc stat file, // we actually need to interpret the bits of that integer as an unsigned // int. We convert via u32 because a direct conversion to DeviceId // would use sign extension, which would result in a different bit // representation Ok(Some(DeviceId::new(data as u64))) } } #[cfg(target_os = "freebsd")] fn get_proc_info(pid: WithProcess) -> std::io::Result { use std::ffi::c_void; use std::ptr; let mut ki_proc: Vec = Vec::with_capacity(1); let pid = match pid { WithProcess::Current => std::process::id() as i32, WithProcess::Other(pid) => pid.inner(), }; loop { let mut size = ki_proc.capacity() * size_of::(); // SAFETY: KERN_PROC_PID only reads data into the ki_proc list. It // does not write more than `size` bytes to the pointer. match cerr(unsafe { libc::sysctl( [ libc::CTL_KERN, libc::KERN_PROC, libc::KERN_PROC_PID, pid, size_of::() as i32, 1, ] .as_ptr(), 4, ki_proc.as_mut_ptr().cast::(), &mut size, ptr::null(), 0, ) }) { Ok(_) => { assert!(size >= size_of::()); // SAFETY: The above sysctl has initialized at least `size` bytes. We have // asserted that this is at least a single element. unsafe { ki_proc.set_len(1); } break; } Err(e) if e.raw_os_error() == Some(libc::ENOMEM) => { // Vector not big enough. Grow it by 10% and try again. ki_proc.reserve(ki_proc.capacity() + (ki_proc.capacity() + 9) / 10); } Err(e) => return Err(e), } } Ok(ki_proc[0]) } /// Returns the device identifier of the TTY device that is currently /// attached to the given process #[cfg(target_os = "freebsd")] pub fn tty_device_id(pid: WithProcess) -> std::io::Result> { let ki_proc = Self::get_proc_info(pid)?; if ki_proc.ki_tdev == !0 { Ok(None) } else { Ok(Some(DeviceId::new(ki_proc.ki_tdev))) } } /// Get the process starting time of a specific process #[cfg(target_os = "linux")] pub fn starting_time(pid: WithProcess) -> io::Result { let process_start: u64 = read_proc_stat(pid, 21 /* start_time */)?; // the startime field is stored in ticks since the system start, so we need to know how many // ticks go into a second let ticks_per_second = crate::cutils::sysconf(libc::_SC_CLK_TCK).ok_or_else(|| { io::Error::new( io::ErrorKind::Other, "Could not retrieve system config variable for ticks per second", ) })? as u64; // finally compute the system time at which the process was started Ok(ProcessCreateTime::new( (process_start / ticks_per_second) as i64, ((process_start % ticks_per_second) * (1_000_000_000 / ticks_per_second)) as i64, )) } /// Get the process starting time of a specific process #[cfg(target_os = "freebsd")] pub fn starting_time(pid: WithProcess) -> io::Result { let ki_proc = Self::get_proc_info(pid)?; let ki_start = ki_proc.ki_start; #[allow(clippy::useless_conversion)] Ok(ProcessCreateTime::new( i64::from(ki_start.tv_sec), i64::from(ki_start.tv_usec) * 1000, )) } } /// Read the n-th field (with 0-based indexing) from `/proc//self`. /// /// See ["Table 1-4: Contents of the stat fields" of "The /proc /// Filesystem"][proc_stat_fields] in the Linux docs for all available fields. /// /// IMPORTANT: the first two fields are not accessible with this routine. /// /// [proc_stat_fields]: https://www.kernel.org/doc/html/latest/filesystems/proc.html#id10 #[cfg(target_os = "linux")] fn read_proc_stat(pid: WithProcess, field_idx: isize) -> io::Result { // the first two fields are skipped by the code below, and we never need them, // so no point in implementing code for it in this private function. debug_assert!(field_idx >= 2); // read from a specific pid file, or use `self` to refer to our own process let pidref = pid.to_proc_string(); // read the data from the stat file for the process with the given pid let path = PathBuf::from_iter(&["/proc", &pidref, "stat"]); let proc_stat = std::fs::read(path)?; // first get the part of the stat file past the second argument, we then reverse // search for a ')' character and start the search for the starttime field from there on let skip_past_second_arg = proc_stat.iter().rposition(|b| *b == b')').ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, "Could not find position of 'comm' field in process stat", ) })?; let mut stat = &proc_stat[skip_past_second_arg..]; // we've now passed the first two fields, so we are at index 1, now we skip over // fields until we arrive at the field we are searching for let mut curr_field = 1; while curr_field < field_idx && !stat.is_empty() { if stat[0] == b' ' { curr_field += 1; } stat = &stat[1..]; } // The expected field cannot be in the file anymore when we are at EOF if stat.is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidData, "Stat file was not of the expected format", )); } // we've now arrived at the field we are looking for, we now check how // long this field is by finding where the next space is let mut idx = 0; while stat[idx] != b' ' && idx < stat.len() { idx += 1; } let field = &stat[0..idx]; // we first convert the data to a string slice, this should not fail with a normal /proc filesystem let fielddata = std::str::from_utf8(field).map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, "Could not interpret byte slice as string", ) })?; // then we convert the string slice to whatever the requested type was fielddata.parse().map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, "Could not interpret string as number", ) }) } pub fn escape_os_str_lossy(s: &std::ffi::OsStr) -> String { s.to_string_lossy().escape_default().collect() } pub fn make_zeroed_sigaction() -> libc::sigaction { // SAFETY: since sigaction is a C struct, all-zeroes is a valid representation // We cannot use a "literal struct" initialization method since the exact representation // of libc::sigaction is not fixed, see e.g. https://github.com/trifectatechfoundation/sudo-rs/issues/829 unsafe { std::mem::zeroed() } } #[cfg(all(test, target_os = "linux"))] pub(crate) const ROOT_GROUP_NAME: &str = "root"; #[cfg(all(test, not(target_os = "linux")))] pub(crate) const ROOT_GROUP_NAME: &str = "wheel"; #[allow(clippy::undocumented_unsafe_blocks)] #[cfg(test)] mod tests { use std::{ io::{self, Read, Write}, os::{ fd::{AsFd, AsRawFd}, unix::net::UnixStream, }, process::exit, }; use libc::SIGKILL; use crate::system::interface::{GroupId, ProcessId, UserId}; use super::{ fork_for_test, getpgrp, setpgid, wait::{Wait, WaitOptions}, Group, User, WithProcess, ROOT_GROUP_NAME, }; pub(super) fn tempfile() -> std::io::Result { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("Failed to get system time") .as_nanos(); let pid = std::process::id(); let filename = format!("sudo_rs_test_{pid}_{timestamp}"); let path = std::path::PathBuf::from("/tmp").join(filename); std::fs::File::options() .read(true) .write(true) .create_new(true) .open(path) } #[test] fn test_get_user_and_group_by_id() { let fixed_users = &[ (UserId::ROOT, "root"), ( User::from_name(cstr!("daemon")).unwrap().unwrap().uid, "daemon", ), ]; for &(id, name) in fixed_users { let root = User::from_uid(id).unwrap().unwrap(); assert_eq!(root.uid, id); assert_eq!(root.name, name); } let fixed_groups = &[ (GroupId::new(0), ROOT_GROUP_NAME), ( Group::from_name(cstr!("daemon")).unwrap().unwrap().gid, "daemon", ), ]; for &(id, name) in fixed_groups { let root = Group::from_gid(id).unwrap().unwrap(); assert_eq!(root.gid, id); assert_eq!(root.name.unwrap(), name); } } #[test] fn miri_test_group_impl() { use super::Group; use std::ffi::CString; fn test(name: &str, passwd: &str, gid: libc::gid_t, mem: &[&str]) { assert_eq!( { let c_mem: Vec = mem.iter().map(|&s| CString::new(s).unwrap()).collect(); let c_name = CString::new(name).unwrap(); let c_passwd = CString::new(passwd).unwrap(); unsafe { Group::from_libc(&libc::group { gr_name: c_name.as_ptr() as *mut _, gr_passwd: c_passwd.as_ptr() as *mut _, gr_gid: gid, gr_mem: c_mem .iter() .map(|cs| cs.as_ptr() as *mut _) .chain(std::iter::once(std::ptr::null_mut())) .collect::>() .as_mut_ptr(), }) } }, Group { name: Some(name.to_string()), gid: GroupId::new(gid), } ) } test("dr. bill", "fidelio", 1999, &["eyes", "wide", "shut"]); test("eris", "fnord", 5, &[]); test("abc", "password123", 42, &[""]); } #[test] fn get_process_tty_device() { assert!(super::Process::tty_device_id(WithProcess::Current).is_ok()); } #[test] fn get_process_start_time() { let time = super::Process::starting_time(WithProcess::Current).unwrap(); let now = super::ProcessCreateTime::now().unwrap(); assert!(time.secs() > now.secs() - 24 * 60 * 60); assert!(time < now); } #[test] fn pgid_test() { use super::{getpgid, setpgid}; let pgrp = getpgrp(); assert_eq!(getpgid(ProcessId::new(0)).unwrap(), pgrp); assert_eq!( getpgid(ProcessId::new(std::process::id() as i32)).unwrap(), pgrp ); let child_pid = unsafe { super::fork_for_test(|| { // wait for the parent. std::thread::sleep(std::time::Duration::from_secs(1)); exit(0); }) }; // The child should be in our process group. assert_eq!( getpgid(child_pid).unwrap(), getpgid(ProcessId::new(0)).unwrap(), ); // Move the child to its own process group setpgid(child_pid, child_pid).unwrap(); // The process group of the child should have changed. assert_eq!(getpgid(child_pid).unwrap(), child_pid); } #[test] fn kill_test() { let mut child = std::process::Command::new("/bin/sleep") .arg("1") .spawn() .unwrap(); super::kill(ProcessId::new(child.id() as i32), SIGKILL).unwrap(); assert!(!child.wait().unwrap().success()); } #[test] fn killpg_test() { // Create a socket so the children write to it if they aren't terminated by `killpg`. let (mut rx, mut tx) = UnixStream::pair().unwrap(); let pid1 = unsafe { fork_for_test(|| { std::thread::sleep(std::time::Duration::from_secs(1)); tx.write_all(&[42]).unwrap(); exit(0); }) }; let pid2 = unsafe { fork_for_test(|| { std::thread::sleep(std::time::Duration::from_secs(1)); tx.write_all(&[42]).unwrap(); exit(0); }) }; drop(tx); let pgid = pid1; // Move the children to their own process group. setpgid(pid1, pgid).unwrap(); setpgid(pid2, pgid).unwrap(); // Send `SIGKILL` to the children process group. super::killpg(pgid, SIGKILL).unwrap(); // Ensure that the child were terminated before writing. assert_eq!( rx.read_exact(&mut [0; 2]).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof ); } fn is_cloexec(fd: &F) -> bool { crate::cutils::cerr(unsafe { libc::fcntl(fd.as_fd().as_raw_fd(), libc::F_GETFD) }).unwrap() & libc::FD_CLOEXEC == libc::FD_CLOEXEC } #[test] fn mark_fds_as_cloexec() { let child_pid = unsafe { fork_for_test(|| { let should_close = std::fs::File::create(std::env::temp_dir().join("should_close.txt")).unwrap(); crate::cutils::cerr(libc::fcntl( should_close.as_fd().as_raw_fd(), libc::F_SETFD, 0, )) .unwrap(); assert!(!is_cloexec(&should_close)); super::mark_fds_as_cloexec().unwrap(); assert!(is_cloexec(&should_close)); assert!(!is_cloexec(&io::stdin())); assert!(!is_cloexec(&io::stdout())); assert!(!is_cloexec(&io::stderr())); exit(0) }) }; let (_, status) = child_pid.wait(WaitOptions::new()).unwrap(); assert_eq!(status.exit_status(), Some(0)); } #[cfg(target_os = "linux")] #[test] fn proc_stat_test() { use super::{read_proc_stat, Process, WithProcess::Current}; // The process can be '(uninterruptible) sleeping' or 'running': it looks like the state // field of /proc/pid/stat will show the state for the main thread of the process rather // than for the process as a whole. let state = read_proc_stat::(Current, 2).unwrap(); assert!("SDR".contains(state), "{state} is not S, D or R"); let parent = Process::parent_id().unwrap(); // field 3 is always the parent process assert_eq!( parent, ProcessId::new(read_proc_stat::(Current, 3).unwrap()) ); // this next field should always be 0 (which precedes an important bit of info for us!) assert_eq!(0, read_proc_stat::(Current, 20).unwrap()); } } sudo-rs-0.2.10/src/system/signal/handler.rs000064400000000000000000000040021046102023000166470ustar 00000000000000use std::io; use crate::log::dev_warn; use super::{consts::*, set::SignalAction, signal_name, SignalNumber}; /// A handler for a signal. /// /// When a value of this type is dropped, it will try to restore the action that was registered for /// the signal prior to calling [`SignalHandler::register`]. pub(crate) struct SignalHandler { signal: SignalNumber, original_action: SignalAction, } impl SignalHandler { const FORBIDDEN: &'static [SignalNumber] = &[SIGKILL, SIGSTOP]; /// Register a new handler for the given signal with the provided behavior. /// /// # Panics /// /// If it is not possible to override the action for the provided signal. pub(crate) fn register( signal: SignalNumber, behavior: SignalHandlerBehavior, ) -> io::Result { if Self::FORBIDDEN.contains(&signal) { panic!( "the {} signal action cannot be overridden", signal_name(signal) ); } let action = SignalAction::new(behavior)?; let original_action = action.register(signal)?; Ok(Self { signal, original_action, }) } /// Forget this signal handler. /// /// This can be used to avoid restoring the original action for the signal. pub(crate) fn forget(self) { std::mem::forget(self) } } impl Drop for SignalHandler { #[track_caller] fn drop(&mut self) { let signal = self.signal; if let Err(err) = self.original_action.register(signal) { dev_warn!( "cannot restore original action for {}: {err}", signal_name(signal), ) } } } /// The possible behaviors for a [`SignalHandler`]. pub(crate) enum SignalHandlerBehavior { /// Execute the default action for the signal. Default, /// Ignore the arrival of the signal. Ignore, /// Stream the signal information into the latest initialized instance of [`super::SignalStream`]. Stream, } sudo-rs-0.2.10/src/system/signal/info.rs000064400000000000000000000026661046102023000162030ustar 00000000000000use std::fmt; use crate::system::interface::ProcessId; use super::SignalNumber; /// Information related to the arrival of a signal. #[repr(transparent)] pub(crate) struct SignalInfo { info: libc::siginfo_t, } impl SignalInfo { pub(super) const SIZE: usize = std::mem::size_of::(); /// Returns whether the signal was sent by the user or not. fn is_user_signaled(&self) -> bool { // This matches the definition of the SI_FROMUSER macro. self.info.si_code <= 0 } /// Gets the PID that sent the signal. pub(crate) fn signaler_pid(&self) -> Option { if self.is_user_signaled() { // SAFETY: si_pid is always initialized if the signal is user signaled. let id = unsafe { self.info.si_pid() }; Some(ProcessId::new(id)) } else { None } } /// Gets the signal number. pub(crate) fn signal(&self) -> SignalNumber { self.info.si_signo } } impl fmt::Display for SignalInfo { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{} {} from ", if self.is_user_signaled() { " user signaled" } else { "" }, self.signal(), )?; if let Some(pid) = self.signaler_pid() { write!(f, "{pid}") } else { write!(f, "") } } } sudo-rs-0.2.10/src/system/signal/mod.rs000064400000000000000000000016661046102023000160260ustar 00000000000000//! Utilities to handle signals. mod handler; mod info; mod set; mod stream; pub(crate) use handler::{SignalHandler, SignalHandlerBehavior}; pub(crate) use set::SignalSet; pub(crate) use stream::{register_handlers, SignalStream}; use std::borrow::Cow; pub(crate) type SignalNumber = libc::c_int; macro_rules! define_consts { ($($signal:ident,)*) => { pub(crate) mod consts { pub(crate) use libc::{$($signal,)*}; } pub(crate) fn signal_name(signal: SignalNumber) -> Cow<'static, str> { match signal { $(consts::$signal => stringify!($signal).into(),)* _ => format!("unknown signal ({signal})").into(), } } }; } define_consts! { SIGINT, SIGQUIT, SIGTSTP, SIGTERM, SIGHUP, SIGALRM, SIGPIPE, SIGUSR1, SIGUSR2, SIGCHLD, SIGCONT, SIGWINCH, SIGTTIN, SIGTTOU, SIGKILL, SIGSTOP, } sudo-rs-0.2.10/src/system/signal/set.rs000064400000000000000000000107041046102023000160330ustar 00000000000000use crate::{cutils::cerr, system::make_zeroed_sigaction}; use super::{handler::SignalHandlerBehavior, SignalNumber}; use std::{io, mem::MaybeUninit}; #[repr(transparent)] pub(super) struct SignalAction { raw: libc::sigaction, } impl SignalAction { pub(super) fn new(behavior: SignalHandlerBehavior) -> io::Result { // This guarantees that functions won't be interrupted by this signal as long as the // handler is alive. let mut sa_flags = libc::SA_RESTART; // We only need a full `sa_mask` if we are going to stream the signal information as we // don't want to be interrupted by any signals while executing `send_siginfo`. let (sa_sigaction, sa_mask) = match behavior { SignalHandlerBehavior::Default => (libc::SIG_DFL, SignalSet::empty()?), SignalHandlerBehavior::Ignore => (libc::SIG_IGN, SignalSet::empty()?), SignalHandlerBehavior::Stream => { // Specify that we want to pass a signal-catching function in `sa_sigaction`. sa_flags |= libc::SA_SIGINFO; ( super::stream::send_siginfo as libc::sighandler_t, SignalSet::full()?, ) } }; let mut raw: libc::sigaction = make_zeroed_sigaction(); raw.sa_sigaction = sa_sigaction; raw.sa_mask = sa_mask.raw; raw.sa_flags = sa_flags; Ok(Self { raw }) } pub(super) fn register(&self, signal: SignalNumber) -> io::Result { let mut original_action = MaybeUninit::::zeroed(); // SAFETY: `sigaction` expects a valid pointer, which we provide; the typecast is valid // since SignalAction is a repr(transparent) newtype struct. cerr(unsafe { libc::sigaction(signal, &self.raw, original_action.as_mut_ptr().cast()) })?; // SAFETY: `sigaction` will have properly initialized `original_action`. Ok(unsafe { original_action.assume_init() }) } } // A signal set that can be used to mask signals. #[repr(transparent)] pub(crate) struct SignalSet { raw: libc::sigset_t, } impl SignalSet { /// Create an empty set. pub(crate) fn empty() -> io::Result { let mut set = MaybeUninit::::zeroed(); // SAFETY: same as above cerr(unsafe { libc::sigemptyset(set.as_mut_ptr().cast()) })?; // SAFETY: `sigemptyset` will have initialized `set` Ok(unsafe { set.assume_init() }) } /// Create a set containing all the signals. pub(crate) fn full() -> io::Result { let mut set = MaybeUninit::::zeroed(); // SAFETY: same as above cerr(unsafe { libc::sigfillset(set.as_mut_ptr().cast()) })?; // SAFETY: `sigfillset` will have initialized `set` Ok(unsafe { set.assume_init() }) } /// Add a signal to this set pub(crate) fn add(&mut self, sig: SignalNumber) -> io::Result<()> { // SAFETY: we pass a valid mutable pointer to `sigaddset` cerr(unsafe { libc::sigaddset(&mut self.raw, sig) })?; Ok(()) } fn sigprocmask(&self, how: libc::c_int) -> io::Result { let mut original_set = MaybeUninit::::zeroed(); // SAFETY: same as above cerr(unsafe { libc::sigprocmask(how, &self.raw, original_set.as_mut_ptr().cast()) })?; // SAFETY: `sigprocmask` will have initialized `set` Ok(unsafe { original_set.assume_init() }) } /// Block all the signals in this set and return the previous set of blocked signals. /// /// After calling this function successfully, the set of blocked signals will be the union of /// the previous set of blocked signals and this set. pub(crate) fn block(&self) -> io::Result { self.sigprocmask(libc::SIG_BLOCK) } /// Unblock all the signals in this set and return the previous set of blocked signals. /// /// After calling this function successfully, the set of blocked signals will be the previous /// set of blocked signals without this set. pub(crate) fn unblock(&self) -> io::Result { self.sigprocmask(libc::SIG_UNBLOCK) } /// Block only the signals that are in this set and return the previous set of blocked signals. /// /// After calling this function successfully, the set of blocked signals will be the exactly /// this set. pub(crate) fn set_mask(&self) -> io::Result { self.sigprocmask(libc::SIG_SETMASK) } } sudo-rs-0.2.10/src/system/signal/stream.rs000064400000000000000000000072151046102023000165360ustar 00000000000000use std::{ io, mem::MaybeUninit, os::{ fd::{AsFd, AsRawFd, BorrowedFd}, unix::net::UnixStream, }, sync::OnceLock, }; use crate::{cutils::cerr, log::dev_error}; use super::{ handler::{SignalHandler, SignalHandlerBehavior}, info::SignalInfo, signal_name, SignalNumber, }; static STREAM: OnceLock = OnceLock::new(); /// # Safety /// /// The `info` parameters has to point to a valid instance of SignalInfo pub(super) unsafe fn send_siginfo( _signal: SignalNumber, info: *const SignalInfo, _context: *const libc::c_void, ) { if let Some(tx) = STREAM.get().map(|stream| stream.tx.as_raw_fd()) { // SAFETY: called ensures that info is a valid pointer; any instance of SignalInfo will // consists of SignalInfo::SIZE bytes unsafe { libc::send(tx, info.cast(), SignalInfo::SIZE, libc::MSG_DONTWAIT) }; } } /// A type able to receive signal information from any [`super::SignalHandler`] with the /// [`super::SignalHandlerBehavior::Stream`] behavior. /// /// This is a singleton type. Meaning that there will be only one value of this type during the /// execution of a program. pub(crate) struct SignalStream { rx: UnixStream, tx: UnixStream, } impl SignalStream { /// Create a new [`SignalStream`]. /// /// # Panics /// /// If this function has been called before. #[track_caller] pub(crate) fn init() -> io::Result<&'static Self> { let (rx, tx) = UnixStream::pair().map_err(|err| { dev_error!("cannot create socket pair for `SignalStream`: {err}"); err })?; if STREAM.set(Self { rx, tx }).is_err() { panic!("`SignalStream` has already been initialized"); }; Ok(STREAM.get().unwrap()) } /// Receives the information related to the arrival of a signal. pub(crate) fn recv(&self) -> io::Result { let mut info = MaybeUninit::::uninit(); let fd = self.rx.as_raw_fd(); // SAFETY: type invariant for `SignalStream` ensures that `fd` is a valid file descriptor; // furthermore, `info` is a valid pointer to `siginfo_t` (by virtue of `SignalInfo` being a // transparent newtype for it), which has room for `SignalInfo::SIZE` bytes. let bytes = cerr(unsafe { libc::recv(fd, info.as_mut_ptr().cast(), SignalInfo::SIZE, 0) })?; if bytes as usize != SignalInfo::SIZE { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, "Not enough bytes when receiving `siginfo_t`", )); } // SAFETY: we can assume `info` is initialized because `recv` wrote enough bytes to fill // the value and `siginfo_t` is POD. Ok(unsafe { info.assume_init() }) } } #[track_caller] pub(crate) fn register_handlers( signals: [SignalNumber; N], ) -> io::Result<[SignalHandler; N]> { let mut handlers = signals.map(|signal| (signal, MaybeUninit::uninit())); for (signal, handler) in &mut handlers { *handler = SignalHandler::register(*signal, SignalHandlerBehavior::Stream) .map(MaybeUninit::new) .map_err(|err| { let name = signal_name(*signal); dev_error!("cannot setup handler for {name}: {err}"); err })?; } // SAFETY: if the above for-loop has terminated, every handler will have // been written to via "MaybeUnit::new", and so is initialized. Ok(handlers.map(|(_, handler)| unsafe { handler.assume_init() })) } impl AsFd for SignalStream { fn as_fd(&self) -> BorrowedFd<'_> { self.rx.as_fd() } } sudo-rs-0.2.10/src/system/term/mod.rs000064400000000000000000000235631046102023000155200ustar 00000000000000mod user_term; use std::{ ffi::{c_uchar, CString, OsString}, fmt, fs::File, io, os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}, ptr::null_mut, }; use libc::{ioctl, winsize, TIOCSWINSZ}; use crate::cutils::{cerr, is_fifo, os_string_from_ptr, safe_isatty}; use super::interface::ProcessId; pub(crate) use user_term::UserTerm; pub(crate) struct Pty { /// The file path of the leader side of the pty. pub(crate) path: CString, /// The leader side of the pty. pub(crate) leader: PtyLeader, /// The follower side of the pty. pub(crate) follower: PtyFollower, } impl Pty { pub(crate) fn open() -> io::Result { const PATH_MAX: usize = libc::PATH_MAX as _; // Allocate a buffer to hold the path to the pty. let mut path = vec![0 as c_uchar; PATH_MAX]; // Create two integers to hold the file descriptors for each side of the pty. let (mut leader, mut follower) = (0, 0); // SAFETY: // - openpty is passed two valid pointers as its first two arguments // - path is a valid array that can hold PATH_MAX characters; and casting `u8` to `i8` is // valid since all values are initialized to zero. // - the last two arguments are allowed to be NULL cerr(unsafe { libc::openpty( &mut leader, &mut follower, path.as_mut_ptr().cast(), null_mut::(), null_mut::(), ) })?; // Get the index of the first null byte and truncate `path` so it doesn't have any null // bytes. If there are no null bytes the path is left as it is. if let Some(index) = path .iter() .enumerate() .find_map(|(index, &byte)| (byte == 0).then_some(index)) { path.truncate(index); } // This will not panic because `path` was truncated to not have any null bytes. let path = CString::new(path).unwrap(); Ok(Self { path, leader: PtyLeader { // SAFETY: `openpty` has set `leader` to an open fd suitable for assuming ownership by `OwnedFd`. file: unsafe { OwnedFd::from_raw_fd(leader) }.into(), }, follower: PtyFollower { // SAFETY: `openpty` has set `follower` to an open fd suitable for assuming ownership by `OwnedFd`. file: unsafe { OwnedFd::from_raw_fd(follower) }.into(), }, }) } } pub(crate) struct PtyLeader { file: File, } impl PtyLeader { pub(crate) fn set_size(&self, term_size: &TermSize) -> io::Result<()> { // SAFETY: the TIOCSWINSZ expects an initialized pointer of type `winsize` // https://www.man7.org/linux/man-pages/man2/TIOCSWINSZ.2const.html // // An object of type TermSize is safe to cast to `winsize` since it is a // repr(transparent) "newtype" struct. cerr(unsafe { ioctl( self.file.as_raw_fd(), TIOCSWINSZ, (term_size as *const TermSize).cast::(), ) })?; Ok(()) } pub(crate) fn set_nonblocking(&self) -> io::Result<()> { let fd = self.file.as_fd(); // SAFETY: these two calls to fcntl are memory safe (and the file descriptor is valid as well) unsafe { let flags = cerr(libc::fcntl(fd.as_raw_fd(), libc::F_GETFL))?; // Set the O_NONBLOCK flag cerr(libc::fcntl( fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK, ))?; } Ok(()) } } impl io::Read for PtyLeader { fn read(&mut self, buf: &mut [u8]) -> io::Result { self.file.read(buf) } } impl io::Write for PtyLeader { fn write(&mut self, buf: &[u8]) -> io::Result { self.file.write(buf) } fn flush(&mut self) -> io::Result<()> { self.file.flush() } } impl AsFd for PtyLeader { fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { self.file.as_fd() } } pub(crate) struct PtyFollower { file: File, } impl PtyFollower { pub(crate) fn try_clone(&self) -> io::Result { self.file.try_clone().map(|file| Self { file }) } } impl AsFd for PtyFollower { fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { self.file.as_fd() } } impl From for std::process::Stdio { fn from(follower: PtyFollower) -> Self { follower.file.into() } } mod sealed { use std::os::fd::AsFd; pub(crate) trait Sealed {} impl Sealed for F {} } pub(crate) trait Terminal: sealed::Sealed { fn tcgetpgrp(&self) -> io::Result; fn tcsetpgrp(&self, pgrp: ProcessId) -> io::Result<()>; fn make_controlling_terminal(&self) -> io::Result<()>; fn ttyname(&self) -> io::Result; fn is_terminal(&self) -> bool; fn is_pipe(&self) -> bool; fn tcgetsid(&self) -> io::Result; } impl Terminal for F { /// Get the foreground process group ID associated with this terminal. fn tcgetpgrp(&self) -> io::Result { // SAFETY: tcgetpgrp cannot cause UB let id = cerr(unsafe { libc::tcgetpgrp(self.as_fd().as_raw_fd()) })?; Ok(ProcessId::new(id)) } /// Set the foreground process group ID associated with this terminal to `pgrp`. fn tcsetpgrp(&self, pgrp: ProcessId) -> io::Result<()> { // SAFETY: tcsetpgrp cannot cause UB cerr(unsafe { libc::tcsetpgrp(self.as_fd().as_raw_fd(), pgrp.inner()) }).map(|_| ()) } /// Make the given terminal the controlling terminal of the calling process. fn make_controlling_terminal(&self) -> io::Result<()> { // SAFETY: this is a correct way to call the TIOCSCTTY ioctl, see: // https://www.man7.org/linux/man-pages/man2/TIOCNOTTY.2const.html cerr(unsafe { libc::ioctl(self.as_fd().as_raw_fd(), libc::TIOCSCTTY, 0) })?; Ok(()) } /// Get the filename of the tty fn ttyname(&self) -> io::Result { let mut buf: [libc::c_char; 1024] = [0; 1024]; if !safe_isatty(self.as_fd()) { return Err(io::ErrorKind::Unsupported.into()); } // SAFETY: `buf` is a valid and initialized pointer, and its correct length is passed cerr(unsafe { libc::ttyname_r(self.as_fd().as_raw_fd(), buf.as_mut_ptr(), buf.len()) })?; // SAFETY: `buf` will have been initialized by the `ttyname_r` call, if it succeeded Ok(unsafe { os_string_from_ptr(buf.as_ptr()) }) } /// Rust standard library "IsTerminal" is not secure for setuid programs (CVE-2023-2002) fn is_terminal(&self) -> bool { safe_isatty(self.as_fd()) } fn is_pipe(&self) -> bool { is_fifo(self.as_fd()) } fn tcgetsid(&self) -> io::Result { // SAFETY: tcgetsid cannot cause UB let id = cerr(unsafe { libc::tcgetsid(self.as_fd().as_raw_fd()) })?; Ok(ProcessId::new(id)) } } /// Try to get the path of the current TTY pub fn current_tty_name() -> io::Result { std::io::stdin().ttyname() } #[repr(transparent)] pub(crate) struct TermSize { raw: winsize, } impl PartialEq for TermSize { fn eq(&self, other: &Self) -> bool { self.raw.ws_col == other.raw.ws_col && self.raw.ws_row == other.raw.ws_row } } impl fmt::Display for TermSize { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} x {}", self.raw.ws_row, self.raw.ws_col) } } #[allow(clippy::undocumented_unsafe_blocks)] #[cfg(test)] mod tests { use std::{ ffi::OsString, io::{Read, Write}, os::unix::{net::UnixStream, prelude::OsStringExt}, path::PathBuf, process::exit, }; use crate::system::{fork_for_test, getpgid, setsid, term::*}; #[test] fn open_pty() { let pty = Pty::open().unwrap(); assert!(pty.leader.file.is_terminal()); assert!(pty.follower.file.is_terminal()); let path = PathBuf::from(OsString::from_vec(pty.path.into_bytes())); assert!(path.try_exists().unwrap()); assert!(path.starts_with("/dev/pts/")); } #[test] fn tcsetpgrp_and_tcgetpgrp_are_consistent() { // Create a socket so the child can send us a byte if successful. let (mut rx, mut tx) = UnixStream::pair().unwrap(); unsafe { fork_for_test(|| { // Open a new pseudoterminal. let leader = Pty::open().unwrap().leader; // On FreeBSD this returns an unspecified PID when there is no foreground process // group, so skip this check on FreeBSD. if cfg!(not(target_os = "freebsd")) { // The pty leader should not have a foreground process group yet. assert_eq!(leader.tcgetpgrp().unwrap().inner(), 0); } // Create a new session so we can change the controlling terminal. setsid().unwrap(); // Set the pty leader as the controlling terminal. leader.make_controlling_terminal().unwrap(); // Set us as the foreground process group of the pty leader. let pgid = getpgid(ProcessId::new(0)).unwrap(); leader.tcsetpgrp(pgid).unwrap(); // Check that we are in fact the foreground process group of the pty leader. assert_eq!(pgid, leader.tcgetpgrp().unwrap()); // If we haven't panicked yet, send a byte to the parent. tx.write_all(&[42]).unwrap(); exit(0); }) }; drop(tx); // Read one byte from the children to confirm that it did not panic. let mut buf = [0]; rx.read_exact(&mut buf).unwrap(); assert_eq!(buf[0], 42); } } sudo-rs-0.2.10/src/system/term/user_term.rs000064400000000000000000000257241046102023000167470ustar 00000000000000//! This module is a port of ogsudo's `lib/util/term.c` with some minor changes to make it //! rust-like. use std::{ ffi::c_int, fs::{File, OpenOptions}, io::{self, Read, Write}, mem::MaybeUninit, os::fd::{AsFd, AsRawFd, BorrowedFd}, sync::atomic::{AtomicBool, Ordering}, }; use libc::{ c_void, cfgetispeed, cfgetospeed, cfmakeraw, cfsetispeed, cfsetospeed, ioctl, sigaction, sigemptyset, sighandler_t, siginfo_t, sigset_t, tcflag_t, tcgetattr, tcsetattr, termios, winsize, CS7, CS8, ECHO, ECHOCTL, ECHOE, ECHOK, ECHOKE, ECHONL, ICANON, ICRNL, IEXTEN, IGNCR, IGNPAR, IMAXBEL, INLCR, INPCK, ISIG, ISTRIP, IXANY, IXOFF, IXON, NOFLSH, OCRNL, ONLCR, ONLRET, ONOCR, OPOST, PARENB, PARMRK, PARODD, PENDIN, SIGTTOU, TCSADRAIN, TCSAFLUSH, TIOCGWINSZ, TIOCSWINSZ, TOSTOP, }; #[cfg(target_os = "linux")] use libc::{IUTF8, OLCUC}; #[cfg(not(target_os = "linux"))] const IUTF8: libc::tcflag_t = 0; #[cfg(not(target_os = "linux"))] const OLCUC: libc::tcflag_t = 0; use super::{TermSize, Terminal}; use crate::{ cutils::cerr, system::{interface::ProcessId, make_zeroed_sigaction}, }; const INPUT_FLAGS: tcflag_t = IGNPAR | PARMRK | INPCK | ISTRIP | INLCR | IGNCR | ICRNL // | IUCLC /* FIXME: not in libc */ | IXON | IXANY | IXOFF | IMAXBEL | IUTF8; const OUTPUT_FLAGS: tcflag_t = OPOST | OLCUC | ONLCR | OCRNL | ONOCR | ONLRET; const CONTROL_FLAGS: tcflag_t = CS7 | CS8 | PARENB | PARODD; const LOCAL_FLAGS: tcflag_t = ISIG | ICANON // | XCASE /* FIXME: not in libc */ | ECHO | ECHOE | ECHOK | ECHONL | NOFLSH | TOSTOP | IEXTEN | ECHOCTL | ECHOKE | PENDIN; static GOT_SIGTTOU: AtomicBool = AtomicBool::new(false); /// This is like `tcsetattr` but it only succeeds if we are in the foreground process group. /// # Safety /// /// The arguments to this function have to be valid arguments to `tcsetattr`. unsafe fn tcsetattr_nobg(fd: c_int, flags: c_int, tp: *const termios) -> io::Result<()> { // This function is based around the fact that we receive `SIGTTOU` if we call `tcsetattr` and // we are not in the foreground process group. // SAFETY: is the responsibility of the caller of `tcsetattr_nobg` let setattr = || cerr(unsafe { tcsetattr(fd, flags, tp) }).map(|_| ()); catching_sigttou(setattr) } fn catching_sigttou(mut function: impl FnMut() -> io::Result<()>) -> io::Result<()> { extern "C" fn on_sigttou(_signal: c_int, _info: *mut siginfo_t, _: *mut c_void) { GOT_SIGTTOU.store(true, Ordering::SeqCst); } let action = { let mut raw: libc::sigaction = make_zeroed_sigaction(); // Call `on_sigttou` if `SIGTTOU` arrives. raw.sa_sigaction = on_sigttou as sighandler_t; // Exclude any other signals from the set raw.sa_mask = { let mut sa_mask = MaybeUninit::::uninit(); // SAFETY: sa_mask is a valid and dereferenceble pointer; it will // become initialized by `sigemptyset` unsafe { sigemptyset(sa_mask.as_mut_ptr()); sa_mask.assume_init() } }; raw.sa_flags = 0; raw }; // Reset `GOT_SIGTTOU`. GOT_SIGTTOU.store(false, Ordering::SeqCst); // Set `action` as the action for `SIGTTOU` and store the original action in `original_action` // to restore it later. // // SAFETY: `original_action` is a valid pointer; second, the `action` installed (on_sigttou): // - is itself a safe function // - only updates an atomic variable, so cannot violate memory unsafety that way // - doesn't call any async-unsafe functions (refer to signal-safety(7)) // Therefore it can safely be installed as a signal handler. // Furthermore, `sigaction` will initialize `original_action`. let original_action = unsafe { let mut original_action = MaybeUninit::::uninit(); sigaction(SIGTTOU, &action, original_action.as_mut_ptr()); original_action.assume_init() }; // Call `tcsetattr` until it suceeds and ignore interruptions if we did not receive `SIGTTOU`. let result = loop { match function() { Ok(_) => break Ok(()), Err(err) => { let got_sigttou = GOT_SIGTTOU.load(Ordering::SeqCst); if got_sigttou || err.kind() != io::ErrorKind::Interrupted { break Err(err); } } } }; // Restore the original action. // // SAFETY: `original_action` is a valid pointer, and was initialized by the preceding // call to `sigaction` (and not subsequently altered, since it is not mut). The third parameter // is allowed to be NULL (this means we ignore the previously-installed handler) unsafe { sigaction(SIGTTOU, &original_action, std::ptr::null_mut()) }; result } /// Type to manipulate the settings of the user's terminal. pub struct UserTerm { tty: File, original_termios: Option, } impl UserTerm { /// Open the user's terminal. pub fn open() -> io::Result { Ok(Self { tty: OpenOptions::new().read(true).write(true).open("/dev/tty")?, original_termios: None, }) } pub(crate) fn get_size(&self) -> io::Result { let mut term_size = MaybeUninit::::uninit(); // SAFETY: This passes a valid file descriptor and valid pointer (of // the correct type) to the TIOCGWINSZ ioctl; see: // https://man7.org/linux/man-pages/man2/TIOCGWINSZ.2const.html cerr(unsafe { ioctl( self.tty.as_raw_fd(), TIOCGWINSZ, term_size.as_mut_ptr().cast::(), ) })?; // SAFETY: if we arrived at this point, `term_size` was initialized. Ok(unsafe { term_size.assume_init() }) } /// Copy the settings of the user's terminal to the `dst` terminal. pub fn copy_to(&self, dst: &D) -> io::Result<()> { let src = self.tty.as_raw_fd(); let dst = dst.as_fd().as_raw_fd(); // SAFETY: tt_src and tt_dst will be initialized by `tcgetattr`. let (tt_src, mut tt_dst) = unsafe { let mut tt_src = MaybeUninit::::uninit(); let mut tt_dst = MaybeUninit::::uninit(); cerr(tcgetattr(src, tt_src.as_mut_ptr()))?; cerr(tcgetattr(dst, tt_dst.as_mut_ptr()))?; (tt_src.assume_init(), tt_dst.assume_init()) }; // Clear select input, output, control and local flags. tt_dst.c_iflag &= !INPUT_FLAGS; tt_dst.c_oflag &= !OUTPUT_FLAGS; tt_dst.c_cflag &= !CONTROL_FLAGS; tt_dst.c_lflag &= !LOCAL_FLAGS; // Copy select input, output, control and local flags. tt_dst.c_iflag |= tt_src.c_iflag & INPUT_FLAGS; tt_dst.c_oflag |= tt_src.c_oflag & OUTPUT_FLAGS; tt_dst.c_cflag |= tt_src.c_cflag & CONTROL_FLAGS; tt_dst.c_lflag |= tt_src.c_lflag & LOCAL_FLAGS; // Copy special chars from src verbatim. tt_dst.c_cc.copy_from_slice(&tt_src.c_cc); // Copy speed from `src`. // // SAFETY: the cfXXXXspeed calls are passed valid pointers and // cannot cause UB even if the speed would be incorrect. unsafe { let mut speed = cfgetospeed(&tt_src); // Zero output speed closes the connection. if speed == libc::B0 { speed = libc::B38400; } cfsetospeed(&mut tt_dst, speed); speed = cfgetispeed(&tt_src); cfsetispeed(&mut tt_dst, speed); } // SAFETY: dst is a valid file descriptor and `tt_dst` is an // initialized struct obtained through tcgetattr; so this is safe to // pass to `tcsetattr`. unsafe { tcsetattr_nobg(dst, TCSAFLUSH, &tt_dst) }?; let mut wsize = MaybeUninit::::uninit(); // SAFETY: TIOCGWINSZ ioctl expects one argument of type *mut winsize cerr(unsafe { ioctl(src, TIOCGWINSZ, wsize.as_mut_ptr()) })?; // SAFETY: wsize has been initialized by the TIOCGWINSZ ioctl cerr(unsafe { ioctl(dst, TIOCSWINSZ, wsize.as_ptr()) })?; Ok(()) } /// Set the user's terminal to raw mode. Enable terminal signals if `with_signals` is set to /// `true`. pub fn set_raw_mode(&mut self, with_signals: bool) -> io::Result<()> { let fd = self.tty.as_raw_fd(); // Retrieve the original terminal (if we haven't done so already) let mut term = if let Some(termios) = self.original_termios { termios } else { // SAFETY: `termios` is a valid pointer to pass to tcgetattr; if that calls succeeds, // it will have initialized the `termios` structure *self.original_termios.insert(unsafe { let mut termios = MaybeUninit::uninit(); cerr(tcgetattr(fd, termios.as_mut_ptr()))?; termios.assume_init() }) }; // Set terminal to raw mode. // SAFETY: `term` is a valid, initialized struct of type `termios`, which // was previously obtained through `tcgetattr`. unsafe { cfmakeraw(&mut term) }; // Enable terminal signals. if with_signals { term.c_cflag |= ISIG; } // SAFETY: `fd` is a valid file descriptor for the tty; for `term`: same as above. unsafe { tcsetattr_nobg(fd, TCSADRAIN, &term) }?; Ok(()) } /// Restore the saved terminal settings if we are in the foreground process group. /// /// This change is done after waiting for all the queued output to be written. To discard the /// queued input `flush` must be set to `true`. pub fn restore(&mut self, flush: bool) -> io::Result<()> { if let Some(termios) = self.original_termios.take() { let fd = self.tty.as_raw_fd(); let flags = if flush { TCSAFLUSH } else { TCSADRAIN }; // SAFETY: `fd` is a valid file descriptor for the tty; and `termios` is a valid pointer // that was obtained through `tcgetattr`. unsafe { tcsetattr_nobg(fd, flags, &termios) }?; } Ok(()) } /// This is like `tcsetpgrp` but it only suceeds if we are in the foreground process group. pub fn tcsetpgrp_nobg(&self, pgrp: ProcessId) -> io::Result<()> { // This function is based around the fact that we receive `SIGTTOU` if we call `tcsetpgrp` and // we are not in the foreground process group. catching_sigttou(|| self.tty.tcsetpgrp(pgrp)) } } impl AsFd for UserTerm { fn as_fd(&self) -> BorrowedFd<'_> { self.tty.as_fd() } } impl Read for UserTerm { fn read(&mut self, buf: &mut [u8]) -> io::Result { self.tty.read(buf) } } impl Write for UserTerm { fn write(&mut self, buf: &[u8]) -> io::Result { self.tty.write(buf) } fn flush(&mut self) -> io::Result<()> { self.tty.flush() } } sudo-rs-0.2.10/src/system/time.rs000064400000000000000000000165461046102023000147330ustar 00000000000000use std::{ io::{Read, Write}, mem::MaybeUninit, ops::{Add, Sub}, }; /// A timestamp relative to `CLOCK_BOOTTIME`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct SystemTime { secs: i64, nsecs: i64, } impl SystemTime { pub(super) fn new(secs: i64, nsecs: i64) -> SystemTime { SystemTime { secs: secs + nsecs.div_euclid(1_000_000_000), nsecs: nsecs.rem_euclid(1_000_000_000), } } pub fn now() -> std::io::Result { let mut spec = MaybeUninit::::uninit(); // SAFETY: valid pointer is passed to clock_gettime crate::cutils::cerr(unsafe { libc::clock_gettime(libc::CLOCK_BOOTTIME, spec.as_mut_ptr()) })?; // SAFETY: The `libc::clock_gettime` will correctly initialize `spec`, // otherwise it will return early with the `?` operator. let spec = unsafe { spec.assume_init() }; Ok(spec.into()) } pub(super) fn encode(&self, target: &mut impl Write) -> std::io::Result<()> { let secs = self.secs.to_ne_bytes(); let nsecs = self.nsecs.to_ne_bytes(); target.write_all(&secs)?; target.write_all(&nsecs)?; Ok(()) } pub(super) fn decode(from: &mut impl Read) -> std::io::Result { let mut sec_bytes = [0; 8]; let mut nsec_bytes = [0; 8]; from.read_exact(&mut sec_bytes)?; from.read_exact(&mut nsec_bytes)?; Ok(SystemTime::new( i64::from_ne_bytes(sec_bytes), i64::from_ne_bytes(nsec_bytes), )) } } impl Sub for SystemTime { type Output = Duration; fn sub(self, rhs: SystemTime) -> Self::Output { Duration::new(self.secs - rhs.secs, self.nsecs - rhs.nsecs) } } impl Add for SystemTime { type Output = SystemTime; fn add(self, rhs: Duration) -> Self::Output { SystemTime::new(self.secs + rhs.secs, self.nsecs + rhs.nsecs) } } impl Sub for SystemTime { type Output = SystemTime; fn sub(self, rhs: Duration) -> Self::Output { SystemTime::new(self.secs - rhs.secs, self.nsecs - rhs.nsecs) } } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] pub struct Duration { secs: i64, nsecs: i64, } impl Duration { pub fn new(secs: i64, nsecs: i64) -> Duration { Duration { secs: secs + nsecs.div_euclid(1_000_000_000), nsecs: nsecs.rem_euclid(1_000_000_000), } } pub fn seconds(secs: i64) -> Duration { Duration::new(secs, 0) } #[cfg(test)] pub fn minutes(minutes: i64) -> Duration { Duration::seconds(minutes * 60) } #[cfg(test)] pub fn milliseconds(ms: i64) -> Duration { let secs = ms / 1000; let ms = ms % 1000; Duration::new(secs, ms * 1_000_000) } } impl Add for Duration { type Output = Duration; fn add(self, rhs: Duration) -> Self::Output { Duration::new(self.secs + rhs.secs, self.nsecs + rhs.nsecs) } } impl Sub for Duration { type Output = Duration; fn sub(self, rhs: Duration) -> Self::Output { Duration::new(self.secs - rhs.secs, self.nsecs - rhs.nsecs) } } impl From for std::time::Duration { fn from(dur: Duration) -> std::time::Duration { std::time::Duration::new( dur.secs.try_into().unwrap_or(0), dur.nsecs.try_into().unwrap_or(0), ) } } impl From for SystemTime { #[allow(clippy::useless_conversion)] fn from(value: libc::timespec) -> Self { SystemTime::new(value.tv_sec.into(), value.tv_nsec.into()) } } /// A timestamp relative to `CLOCK_BOOTTIME` on Linux and relative to `CLOCK_REALTIME` on FreeBSD. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct ProcessCreateTime { secs: i64, nsecs: i64, } impl ProcessCreateTime { pub fn new(secs: i64, nsecs: i64) -> ProcessCreateTime { ProcessCreateTime { secs: secs + nsecs.div_euclid(1_000_000_000), nsecs: nsecs.rem_euclid(1_000_000_000), } } #[cfg(test)] pub(super) fn now() -> std::io::Result { let mut spec = MaybeUninit::::uninit(); // SAFETY: valid pointer is passed to clock_gettime crate::cutils::cerr(unsafe { libc::clock_gettime( if cfg!(target_os = "freebsd") { libc::CLOCK_REALTIME } else { libc::CLOCK_BOOTTIME }, spec.as_mut_ptr(), ) })?; // SAFETY: The `libc::clock_gettime` will correctly initialize `spec`, // otherwise it will return early with the `?` operator. let spec = unsafe { spec.assume_init() }; // the below conversion is not as useless as clippy thinks, on 32bit systems #[allow(clippy::useless_conversion)] Ok(ProcessCreateTime::new( spec.tv_sec.into(), spec.tv_nsec.into(), )) } #[cfg(test)] pub(super) fn secs(&self) -> i64 { self.secs } pub(super) fn encode(&self, target: &mut impl Write) -> std::io::Result<()> { let secs = self.secs.to_ne_bytes(); let nsecs = self.nsecs.to_ne_bytes(); target.write_all(&secs)?; target.write_all(&nsecs)?; Ok(()) } pub(super) fn decode(from: &mut impl Read) -> std::io::Result { let mut sec_bytes = [0; 8]; let mut nsec_bytes = [0; 8]; from.read_exact(&mut sec_bytes)?; from.read_exact(&mut nsec_bytes)?; Ok(ProcessCreateTime::new( i64::from_ne_bytes(sec_bytes), i64::from_ne_bytes(nsec_bytes), )) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_new_durations_and_times() { assert_eq!(Duration::new(1, 1_000_000_000), Duration::seconds(2)); assert_eq!( Duration::new(-2, 500_000_000), Duration::seconds(-1) + Duration::milliseconds(-500) ); assert_eq!(SystemTime::new(-1, 2_000_000_000), SystemTime::new(1, 0)); assert_eq!( SystemTime::new(2, -500_000_000), SystemTime::new(1, 500_000_000) ); } #[test] fn test_time_ops() { assert_eq!( Duration::seconds(2) + Duration::seconds(3), Duration::seconds(5) ); assert_eq!( Duration::seconds(3) - Duration::seconds(1), Duration::seconds(2) ); assert_eq!( Duration::seconds(-10) + Duration::seconds(-5), Duration::seconds(-15) ); assert_eq!( Duration::milliseconds(5555) + Duration::milliseconds(5555), Duration::seconds(11) + Duration::milliseconds(110) ); assert_eq!( Duration::milliseconds(-5555) + Duration::milliseconds(-1111), Duration::milliseconds(-6666) ); assert_eq!( Duration::seconds(10) - Duration::seconds(-5), Duration::seconds(15) ); assert_eq!( SystemTime::new(0, 0) + Duration::seconds(3), SystemTime::new(3, 0) ); assert_eq!( SystemTime::new(10, 0) - Duration::seconds(4), SystemTime::new(6, 0) ); } } sudo-rs-0.2.10/src/system/timestamp.rs000064400000000000000000000661461046102023000160010ustar 00000000000000use std::{ fs::File, io::{self, Cursor, Read, Seek, Write}, path::PathBuf, }; use crate::common::resolve::AuthUser; use crate::{ common::resolve::CurrentUser, log::{auth_info, auth_warn}, }; use super::{ audit::secure_open_cookie_file, file::FileLock, interface::{DeviceId, ProcessId, UserId}, time::{Duration, ProcessCreateTime, SystemTime}, Process, WithProcess, }; type BoolStorage = u8; const SIZE_OF_TS: i64 = std::mem::size_of::() as i64; const SIZE_OF_BOOL: i64 = std::mem::size_of::() as i64; const MOD_OFFSET: i64 = SIZE_OF_TS + SIZE_OF_BOOL; #[derive(Debug)] pub struct SessionRecordFile { file: File, timeout: Duration, for_user: UserId, } impl SessionRecordFile { const BASE_PATH: &'static str = "/var/run/sudo-rs/ts"; pub fn open_for_user(user: &CurrentUser, timeout: Duration) -> io::Result { let uid = user.uid; let mut path = PathBuf::from(Self::BASE_PATH); path.push(uid.to_string()); SessionRecordFile::new(uid, secure_open_cookie_file(&path)?, timeout) } const FILE_VERSION: u16 = 1; const MAGIC_NUM: u16 = 0x50D0; const VERSION_OFFSET: u64 = Self::MAGIC_NUM.to_le_bytes().len() as u64; const FIRST_RECORD_OFFSET: u64 = Self::VERSION_OFFSET + Self::FILE_VERSION.to_le_bytes().len() as u64; /// Create a new SessionRecordFile from the given i/o stream. /// Timestamps in this file are considered valid if they were created or /// updated at most `timeout` time ago. pub fn new(for_user: UserId, io: File, timeout: Duration) -> io::Result { let mut session_records = SessionRecordFile { file: io, timeout, for_user, }; // match the magic number, otherwise reset the file match session_records.read_magic()? { Some(magic) if magic == Self::MAGIC_NUM => (), x => { if let Some(_magic) = x { auth_info!("Session records file for user '{for_user}' is invalid, resetting"); } session_records.init(Self::VERSION_OFFSET)?; } } // match the file version match session_records.read_version()? { Some(v) if v == Self::FILE_VERSION => (), x => { if let Some(v) = x { auth_info!("Session records file for user '{for_user}' has invalid version {v}, only file version {} is supported, resetting", Self::FILE_VERSION); } else { auth_info!( "Session records file did not contain file version information, resetting" ); } session_records.init(Self::FIRST_RECORD_OFFSET)?; } } // we are ready to read records Ok(session_records) } /// Read the magic number from the input stream fn read_magic(&mut self) -> io::Result> { let mut magic_bytes = [0; std::mem::size_of::()]; match self.file.read_exact(&mut magic_bytes) { Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => Ok(None), Err(e) => Err(e), Ok(()) => Ok(Some(u16::from_le_bytes(magic_bytes))), } } /// Read the version number from the input stream fn read_version(&mut self) -> io::Result> { let mut version_bytes = [0; std::mem::size_of::()]; match self.file.read_exact(&mut version_bytes) { Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => Ok(None), Err(e) => Err(e), Ok(()) => Ok(Some(u16::from_le_bytes(version_bytes))), } } /// Initialize a new empty stream. If the stream/file was already filled /// before it will be truncated. fn init(&mut self, offset: u64) -> io::Result<()> { // lock the file to indicate that we are currently writing to it let lock = FileLock::exclusive(&self.file, false)?; self.file.set_len(0)?; self.file.rewind()?; self.file.write_all(&Self::MAGIC_NUM.to_le_bytes())?; self.file.write_all(&Self::FILE_VERSION.to_le_bytes())?; self.file.seek(io::SeekFrom::Start(offset))?; lock.unlock()?; Ok(()) } /// Read the next record and keep note of the start and end positions in the file of that record /// /// This method assumes that the file is already exclusively locked. fn next_record(&mut self) -> io::Result> { // record the position at which this record starts (including size bytes) let mut record_length_bytes = [0; std::mem::size_of::()]; let curr_pos = self.file.stream_position()?; // if eof occurs here we assume we reached the end of the file let record_length = match self.file.read_exact(&mut record_length_bytes) { Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), Err(e) => return Err(e), Ok(()) => u16::from_le_bytes(record_length_bytes), }; // special case when record_length is zero if record_length == 0 { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Found empty record", )); } let mut buf = vec![0; record_length as usize]; match self.file.read_exact(&mut buf) { Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => { // there was half a record here, we clear the rest of the file auth_info!("Found incomplete record in session records file for {}, clearing rest of the file", self.for_user); self.file.set_len(curr_pos)?; return Ok(None); } Err(e) => return Err(e), Ok(()) => (), } // we now try and decode the data read into a session record match SessionRecord::from_bytes(&buf) { Err(_) => { // any error assumes that this file is nonsense from this point // onwards, so we clear the file up to the start of this record auth_info!("Found invalid record in session records file for {}, clearing rest of the file", self.for_user); self.file.set_len(curr_pos)?; Ok(None) } Ok(record) => Ok(Some(record)), } } /// Try and find a record for the given scope and auth user id and update /// that record time to the current time. This will not create a new record /// when one is not found. A record will only be updated if it is still /// valid at this time. pub fn touch(&mut self, scope: RecordScope, auth_user: &AuthUser) -> io::Result { // lock the file to indicate that we are currently in a writing operation let lock = FileLock::exclusive(&self.file, false)?; self.seek_to_first_record()?; while let Some(record) = self.next_record()? { // only touch if record is enabled if record.enabled && record.matches(&scope, auth_user) { let now = SystemTime::now()?; if record.written_between(now - self.timeout, now) { // move back to where the timestamp is and overwrite with the latest time self.file.seek(io::SeekFrom::Current(-MOD_OFFSET))?; let new_time = SystemTime::now()?; new_time.encode(&mut self.file)?; // make sure we can still go to the end of the record self.file.seek(io::SeekFrom::Current(SIZE_OF_BOOL))?; // writing is done, unlock and return lock.unlock()?; return Ok(TouchResult::Updated { old_time: record.timestamp, new_time, }); } else { lock.unlock()?; return Ok(TouchResult::Outdated { time: record.timestamp, }); } } } lock.unlock()?; Ok(TouchResult::NotFound) } /// Disable all records that match the given scope. pub fn disable(&mut self, scope: RecordScope) -> io::Result<()> { let lock = FileLock::exclusive(&self.file, false)?; self.seek_to_first_record()?; while let Some(record) = self.next_record()? { if record.scope == scope { self.file.seek(io::SeekFrom::Current(-SIZE_OF_BOOL))?; write_bool(false, &mut self.file)?; } } lock.unlock()?; Ok(()) } /// Create a new record for the given scope and auth user id. /// If there is an existing record that matches the scope and auth user, /// then that record will be updated. pub fn create(&mut self, scope: RecordScope, auth_user: &AuthUser) -> io::Result { // lock the file to indicate that we are currently writing to it let lock = FileLock::exclusive(&self.file, false)?; self.seek_to_first_record()?; while let Some(record) = self.next_record()? { if record.matches(&scope, auth_user) { self.file.seek(io::SeekFrom::Current(-MOD_OFFSET))?; let new_time = SystemTime::now()?; new_time.encode(&mut self.file)?; write_bool(true, &mut self.file)?; lock.unlock()?; return Ok(CreateResult::Updated { old_time: record.timestamp, new_time, }); } } // record was not found in the list so far, create a new one let record = SessionRecord::new(scope, auth_user.uid)?; // make sure we really are at the end of the file self.file.seek(io::SeekFrom::End(0))?; self.write_record(&record)?; lock.unlock()?; Ok(CreateResult::Created { time: record.timestamp, }) } /// Completely resets the entire file and removes all records. pub fn reset(&mut self) -> io::Result<()> { self.init(0) } /// Write a new record at the current position in the file. fn write_record(&mut self, record: &SessionRecord) -> io::Result<()> { // convert the new record to byte representation and make sure that it fits let bytes = record.as_bytes()?; let record_length = bytes.len(); if record_length > u16::MAX as usize { return Err(io::Error::new( io::ErrorKind::InvalidInput, "A record with an unexpectedly large size was created", )); } let record_length = record_length as u16; // store as u16 // write the record self.file.write_all(&record_length.to_le_bytes())?; self.file.write_all(&bytes)?; Ok(()) } /// Move to where the first record starts. fn seek_to_first_record(&mut self) -> io::Result<()> { self.file .seek(io::SeekFrom::Start(Self::FIRST_RECORD_OFFSET))?; Ok(()) } } #[derive(Debug, PartialEq, Eq, Clone)] pub enum TouchResult { /// The record was found and within the timeout, and it was refreshed Updated { old_time: SystemTime, new_time: SystemTime, }, /// A record was found, but it was no longer valid Outdated { time: SystemTime }, /// A record was not found that matches the input NotFound, } #[cfg_attr(not(test), allow(dead_code))] pub enum CreateResult { /// The record was found and it was refreshed Updated { old_time: SystemTime, new_time: SystemTime, }, /// A new record was created and was set to the time returned Created { time: SystemTime }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RecordScope { Tty { tty_device: DeviceId, session_pid: ProcessId, init_time: ProcessCreateTime, }, Ppid { group_pid: ProcessId, init_time: ProcessCreateTime, }, } impl RecordScope { fn encode(&self, target: &mut impl Write) -> std::io::Result<()> { match self { RecordScope::Tty { tty_device, session_pid, init_time, } => { target.write_all(&[1u8])?; let b = tty_device.inner().to_le_bytes(); target.write_all(&b)?; let b = session_pid.inner().to_le_bytes(); target.write_all(&b)?; init_time.encode(target)?; } RecordScope::Ppid { group_pid, init_time, } => { target.write_all(&[2u8])?; let b = group_pid.inner().to_le_bytes(); target.write_all(&b)?; init_time.encode(target)?; } } Ok(()) } fn decode(from: &mut impl Read) -> std::io::Result { let mut buf = [0; 1]; from.read_exact(&mut buf)?; match buf[0] { 1 => { let mut buf = [0; std::mem::size_of::()]; from.read_exact(&mut buf)?; let tty_device = libc::dev_t::from_le_bytes(buf); let mut buf = [0; std::mem::size_of::()]; from.read_exact(&mut buf)?; let session_pid = libc::pid_t::from_le_bytes(buf); let init_time = ProcessCreateTime::decode(from)?; Ok(RecordScope::Tty { tty_device: DeviceId::new(tty_device), session_pid: ProcessId::new(session_pid), init_time, }) } 2 => { let mut buf = [0; std::mem::size_of::()]; from.read_exact(&mut buf)?; let group_pid = libc::pid_t::from_le_bytes(buf); let init_time = ProcessCreateTime::decode(from)?; Ok(RecordScope::Ppid { group_pid: ProcessId::new(group_pid), init_time, }) } x => Err(io::Error::new( io::ErrorKind::InvalidInput, format!("Unexpected scope variant discriminator: {x}"), )), } } /// Tries to determine a record match scope for the current context. /// This should never produce an error since any actual error should just be /// ignored and no session record file should be used in that case. pub fn for_process(process: &Process) -> Option { let tty = Process::tty_device_id(WithProcess::Current); if let Ok(Some(tty_device)) = tty { if let Ok(init_time) = Process::starting_time(WithProcess::Other(process.session_id)) { Some(RecordScope::Tty { tty_device, session_pid: process.session_id, init_time, }) } else { auth_warn!("Could not get terminal foreground process starting time"); None } } else if let Some(parent_pid) = process.parent_pid { if let Ok(init_time) = Process::starting_time(WithProcess::Other(parent_pid)) { Some(RecordScope::Ppid { group_pid: parent_pid, init_time, }) } else { auth_warn!("Could not get parent process starting time"); None } } else { None } } } fn write_bool(b: bool, target: &mut impl Write) -> io::Result<()> { let s: BoolStorage = if b { 0xFF } else { 0x00 }; let bytes = s.to_le_bytes(); target.write_all(&bytes)?; Ok(()) } /// A record in the session record file #[derive(Debug, PartialEq, Eq)] pub struct SessionRecord { /// The scope for which the current record applies, i.e. what process group /// or which TTY for interactive sessions scope: RecordScope, /// The user that needs to be authenticated against auth_user: UserId, /// The timestamp at which the time was created. This must always be a time /// originating from a monotonic clock that continues counting during system /// sleep. timestamp: SystemTime, /// Disabled records act as if they do not exist, but their storage can /// be re-used when recreating for the same scope and auth user enabled: bool, } impl SessionRecord { /// Create a new record that is scoped to the specified scope and has `auth_user` as /// the target for authentication for the session. fn new(scope: RecordScope, auth_user: UserId) -> io::Result { Ok(Self::init(scope, auth_user, true, SystemTime::now()?)) } /// Initialize a new record with the given parameters fn init( scope: RecordScope, auth_user: UserId, enabled: bool, timestamp: SystemTime, ) -> SessionRecord { SessionRecord { scope, auth_user, timestamp, enabled, } } /// Encode a record into the given stream fn encode(&self, target: &mut impl Write) -> std::io::Result<()> { self.scope.encode(target)?; // write user id let buf = self.auth_user.inner().to_le_bytes(); target.write_all(&buf)?; // write timestamp self.timestamp.encode(target)?; // write enabled boolean write_bool(self.enabled, target)?; Ok(()) } /// Decode a record from the given stream fn decode(from: &mut impl Read) -> std::io::Result { let scope = RecordScope::decode(from)?; // auth user id let mut buf = [0; std::mem::size_of::()]; from.read_exact(&mut buf)?; let auth_user = libc::uid_t::from_le_bytes(buf); let auth_user = UserId::new(auth_user); // timestamp let timestamp = SystemTime::decode(from)?; // enabled boolean let mut buf = [0; std::mem::size_of::()]; from.read_exact(&mut buf)?; let enabled = match BoolStorage::from_le_bytes(buf) { 0xFF => true, 0x00 => false, _ => { return Err(io::Error::new( io::ErrorKind::InvalidData, "Invalid boolean value detected in input stream", )) } }; Ok(SessionRecord::init(scope, auth_user, enabled, timestamp)) } /// Convert the record to a vector of bytes for storage. pub fn as_bytes(&self) -> std::io::Result> { let mut v = vec![]; self.encode(&mut v)?; Ok(v) } /// Convert the given byte slice to a session record, the byte slice must /// be fully consumed for this conversion to be valid. pub fn from_bytes(data: &[u8]) -> std::io::Result { let mut cursor = Cursor::new(data); let record = SessionRecord::decode(&mut cursor)?; if cursor.position() != data.len() as u64 { Err(io::Error::new( io::ErrorKind::InvalidInput, "Record size and record length did not match", )) } else { Ok(record) } } /// Returns true if this record matches the specified scope and is for the /// specified target auth user. pub fn matches(&self, scope: &RecordScope, auth_user: &AuthUser) -> bool { self.scope == *scope && self.auth_user == auth_user.uid } /// Returns true if this record was written somewhere in the time range /// between `early_time` (inclusive) and `later_time` (inclusive), where /// early timestamp may not be later than the later timestamp. pub fn written_between(&self, early_time: SystemTime, later_time: SystemTime) -> bool { early_time <= later_time && self.timestamp >= early_time && self.timestamp <= later_time } } #[cfg(test)] mod tests { use std::path::Path; use super::*; use crate::common::{SudoPath, SudoString}; use crate::system::interface::GroupId; use crate::system::tests::tempfile; use crate::system::User; static TEST_USER_ID: UserId = UserId::ROOT; fn auth_user_from_uid(uid: libc::uid_t) -> AuthUser { AuthUser::from_user_for_targetpw(User { uid: UserId::new(uid), gid: GroupId::new(0), name: SudoString::new("dummy".to_owned()).unwrap(), home: SudoPath::new(Path::new("/nonexistent").to_owned()).unwrap(), shell: Path::new("/bin/sh").to_owned(), groups: vec![], }) } #[test] fn can_encode_and_decode() { let tty_sample = SessionRecord::new( RecordScope::Tty { tty_device: DeviceId::new(10), session_pid: ProcessId::new(42), init_time: ProcessCreateTime::new(1, 0), }, UserId::new(999), ) .unwrap(); let mut bytes = tty_sample.as_bytes().unwrap(); let decoded = SessionRecord::from_bytes(&bytes).unwrap(); assert_eq!(tty_sample, decoded); // we provide some invalid input assert!(SessionRecord::from_bytes(&bytes[1..]).is_err()); // we have remaining input after decoding bytes.push(0); assert!(SessionRecord::from_bytes(&bytes).is_err()); let ppid_sample = SessionRecord::new( RecordScope::Ppid { group_pid: ProcessId::new(42), init_time: ProcessCreateTime::new(151, 0), }, UserId::new(123), ) .unwrap(); let bytes = ppid_sample.as_bytes().unwrap(); let decoded = SessionRecord::from_bytes(&bytes).unwrap(); assert_eq!(ppid_sample, decoded); } #[test] fn timestamp_record_matches_works() { let init_time = ProcessCreateTime::new(1, 0); let scope = RecordScope::Tty { tty_device: DeviceId::new(12), session_pid: ProcessId::new(1234), init_time, }; let tty_sample = SessionRecord::new(scope, UserId::new(675)).unwrap(); assert!(tty_sample.matches(&scope, &auth_user_from_uid(675))); assert!(!tty_sample.matches(&scope, &auth_user_from_uid(789))); assert!(!tty_sample.matches( &RecordScope::Tty { tty_device: DeviceId::new(20), session_pid: ProcessId::new(1234), init_time }, &auth_user_from_uid(675), )); assert!(!tty_sample.matches( &RecordScope::Ppid { group_pid: ProcessId::new(42), init_time }, &auth_user_from_uid(675), )); // make sure time is different std::thread::sleep(std::time::Duration::from_millis(1)); assert!(!tty_sample.matches( &RecordScope::Tty { tty_device: DeviceId::new(12), session_pid: ProcessId::new(1234), init_time: ProcessCreateTime::new(1, 1) }, &auth_user_from_uid(675), )); } #[test] fn timestamp_record_written_between_works() { let some_time = SystemTime::now().unwrap() + Duration::minutes(100); let scope = RecordScope::Tty { tty_device: DeviceId::new(12), session_pid: ProcessId::new(1234), init_time: ProcessCreateTime::new(0, 0), }; let sample = SessionRecord::init(scope, UserId::new(1234), true, some_time); let dur = Duration::seconds(30); assert!(sample.written_between(some_time, some_time)); assert!(sample.written_between(some_time, some_time + dur)); assert!(sample.written_between(some_time - dur, some_time)); assert!(!sample.written_between(some_time + dur, some_time - dur)); assert!(!sample.written_between(some_time + dur, some_time + dur + dur)); assert!(!sample.written_between(some_time - dur - dur, some_time - dur)); } fn tempfile_with_data(data: &[u8]) -> io::Result { let mut file = tempfile()?; file.write_all(data)?; file.rewind()?; Ok(file) } fn data_from_tempfile(mut f: File) -> io::Result> { let mut v = vec![]; f.rewind()?; f.read_to_end(&mut v)?; Ok(v) } #[test] fn session_record_file_header_checks() { // valid header should remain valid let c = tempfile_with_data(&[0xD0, 0x50, 0x01, 0x00]).unwrap(); let timeout = Duration::seconds(30); assert!(SessionRecordFile::new(TEST_USER_ID, c.try_clone().unwrap(), timeout).is_ok()); let v = data_from_tempfile(c).unwrap(); assert_eq!(&v[..], &[0xD0, 0x50, 0x01, 0x00]); // invalid headers should be corrected let c = tempfile_with_data(&[0xAB, 0xBA]).unwrap(); assert!(SessionRecordFile::new(TEST_USER_ID, c.try_clone().unwrap(), timeout).is_ok()); let v = data_from_tempfile(c).unwrap(); assert_eq!(&v[..], &[0xD0, 0x50, 0x01, 0x00]); // empty header should be filled in let c = tempfile_with_data(&[]).unwrap(); assert!(SessionRecordFile::new(TEST_USER_ID, c.try_clone().unwrap(), timeout).is_ok()); let v = data_from_tempfile(c).unwrap(); assert_eq!(&v[..], &[0xD0, 0x50, 0x01, 0x00]); // invalid version should reset file let c = tempfile_with_data(&[0xD0, 0x50, 0xAB, 0xBA, 0x0, 0x0]).unwrap(); assert!(SessionRecordFile::new(TEST_USER_ID, c.try_clone().unwrap(), timeout).is_ok()); let v = data_from_tempfile(c).unwrap(); assert_eq!(&v[..], &[0xD0, 0x50, 0x01, 0x00]); } #[test] fn can_create_and_update_valid_file() { let timeout = Duration::seconds(30); let c = tempfile_with_data(&[]).unwrap(); let mut srf = SessionRecordFile::new(TEST_USER_ID, c.try_clone().unwrap(), timeout).unwrap(); let tty_scope = RecordScope::Tty { tty_device: DeviceId::new(0), session_pid: ProcessId::new(0), init_time: ProcessCreateTime::new(0, 0), }; let auth_user = auth_user_from_uid(2424); let res = srf.create(tty_scope, &auth_user).unwrap(); let CreateResult::Created { time } = res else { panic!("Expected record to be created"); }; std::thread::sleep(std::time::Duration::from_millis(1)); let second = srf.touch(tty_scope, &auth_user).unwrap(); let TouchResult::Updated { old_time, new_time } = second else { panic!("Expected record to be updated"); }; assert_eq!(time, old_time); assert_ne!(old_time, new_time); std::thread::sleep(std::time::Duration::from_millis(1)); let res = srf.create(tty_scope, &auth_user).unwrap(); let CreateResult::Updated { old_time, new_time } = res else { panic!("Expected record to be updated"); }; assert_ne!(old_time, new_time); // reset the file assert!(srf.reset().is_ok()); // after all this the data should be just an empty header let data = data_from_tempfile(c).unwrap(); assert_eq!(&data, &[0xD0, 0x50, 0x01, 0x00]); } } sudo-rs-0.2.10/src/system/wait.rs000064400000000000000000000171061046102023000147320ustar 00000000000000use std::io; #[cfg(target_os = "linux")] use libc::__WALL; use libc::{ c_int, WEXITSTATUS, WIFCONTINUED, WIFEXITED, WIFSIGNALED, WIFSTOPPED, WNOHANG, WSTOPSIG, WTERMSIG, WUNTRACED, }; use crate::cutils::cerr; use crate::system::signal::signal_name; use crate::{system::interface::ProcessId, system::signal::SignalNumber}; #[cfg(not(target_os = "linux"))] const __WALL: c_int = 0; mod sealed { pub(crate) trait Sealed {} impl Sealed for crate::system::interface::ProcessId {} } pub(crate) trait Wait: sealed::Sealed { /// Wait for a process to change state. /// /// Calling this function will block until a child specified by the given process ID has /// changed state. This can be configured further using [`WaitOptions`]. fn wait(self, options: WaitOptions) -> Result<(ProcessId, WaitStatus), WaitError>; } impl Wait for ProcessId { fn wait(self, options: WaitOptions) -> Result<(ProcessId, WaitStatus), WaitError> { let mut status: c_int = 0; // SAFETY: a valid pointer is passed to `waitpid` let pid = cerr(unsafe { libc::waitpid(self.inner(), &mut status, options.flags) }) .map_err(WaitError::Io)?; if pid == 0 && options.flags & WNOHANG != 0 { return Err(WaitError::NotReady); } Ok((ProcessId::new(pid), WaitStatus { status })) } } /// Error values returned when [`Wait::wait`] fails. #[derive(Debug)] pub enum WaitError { // No children were in a waitable state. // // This is only returned if the [`WaitOptions::no_hang`] option is used. NotReady, // Regular I/O error. Io(io::Error), } /// Options to configure how [`Wait::wait`] waits for children. pub struct WaitOptions { flags: c_int, } impl WaitOptions { /// Only wait for terminated children. pub const fn new() -> Self { Self { flags: 0 } } /// Return immediately if no child has exited. pub const fn no_hang(mut self) -> Self { self.flags |= WNOHANG; self } /// Return immediately if a child has stopped. pub const fn untraced(mut self) -> Self { self.flags |= WUNTRACED; self } /// Wait for all children, regardless of being created using `clone` or not. pub const fn all(mut self) -> Self { self.flags |= __WALL; self } } /// The status of the waited child. pub struct WaitStatus { status: c_int, } impl std::fmt::Debug for WaitStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(exit_status) = self.exit_status() { write!(f, "ExitStatus({exit_status})") } else if let Some(signal) = self.term_signal() { write!(f, "TermSignal({})", signal_name(signal)) } else if let Some(signal) = self.stop_signal() { write!(f, "StopSignal({})", signal_name(signal)) } else if self.did_continue() { write!(f, "Continued") } else { write!(f, "Unknown") } } } impl WaitStatus { /// Return `true` if the child terminated normally, i.e., by calling `exit`. pub const fn did_exit(&self) -> bool { WIFEXITED(self.status) } /// Return the exit status of the child if the child terminated normally. pub const fn exit_status(&self) -> Option { if self.did_exit() { Some(WEXITSTATUS(self.status)) } else { None } } /// Return `true` if the child process was terminated by a signal. pub const fn was_signaled(&self) -> bool { WIFSIGNALED(self.status) } /// Return the signal number which caused the child to terminate if the child was terminated by /// a signal. pub const fn term_signal(&self) -> Option { if self.was_signaled() { Some(WTERMSIG(self.status)) } else { None } } /// Return `true` if the child process was stopped by a signal. pub const fn was_stopped(&self) -> bool { WIFSTOPPED(self.status) } /// Return the signal number which caused the child to stop if the child was stopped by a /// signal. pub const fn stop_signal(&self) -> Option { if self.was_stopped() { Some(WSTOPSIG(self.status)) } else { None } } /// Return `true` if the child process was resumed by receiving `SIGCONT`. pub const fn did_continue(&self) -> bool { WIFCONTINUED(self.status) } } #[cfg(test)] mod tests { use libc::{SIGKILL, SIGSTOP}; use crate::system::{ interface::ProcessId, kill, wait::{Wait, WaitError, WaitOptions}, }; #[test] fn exit_status() { #[allow(clippy::zombie_processes)] let command = std::process::Command::new("sh") .args(["-c", "sleep 0.1; exit 42"]) .spawn() .unwrap(); let command_pid = ProcessId::new(command.id() as i32); let (pid, status) = command_pid.wait(WaitOptions::new()).unwrap(); assert_eq!(command_pid, pid); assert!(status.did_exit()); assert_eq!(status.exit_status(), Some(42)); assert!(!status.was_signaled()); assert!(status.term_signal().is_none()); assert!(!status.was_stopped()); assert!(status.stop_signal().is_none()); assert!(!status.did_continue()); // Waiting when there are no children should fail. let WaitError::Io(err) = command_pid.wait(WaitOptions::new()).unwrap_err() else { panic!("`WaitError::NotReady` should not happens if `WaitOptions::no_hang` was not called."); }; assert_eq!(err.raw_os_error(), Some(libc::ECHILD)); } #[test] fn signals() { #[allow(clippy::zombie_processes)] let command = std::process::Command::new("sh") .args(["-c", "sleep 1; exit 42"]) .spawn() .unwrap(); let command_pid = ProcessId::new(command.id() as i32); kill(command_pid, SIGSTOP).unwrap(); let (pid, status) = command_pid.wait(WaitOptions::new().untraced()).unwrap(); assert_eq!(command_pid, pid); assert_eq!(status.stop_signal(), Some(SIGSTOP)); kill(command_pid, SIGKILL).unwrap(); let (pid, status) = command_pid.wait(WaitOptions::new()).unwrap(); assert_eq!(command_pid, pid); assert!(status.was_signaled()); assert_eq!(status.term_signal(), Some(SIGKILL)); assert!(!status.did_exit()); assert!(status.exit_status().is_none()); assert!(!status.was_stopped()); assert!(status.stop_signal().is_none()); assert!(!status.did_continue()); } #[test] fn no_hang() { #[allow(clippy::zombie_processes)] let command = std::process::Command::new("sh") .args(["-c", "sleep 0.1; exit 42"]) .spawn() .unwrap(); let command_pid = ProcessId::new(command.id() as i32); let mut count = 0; let (pid, status) = loop { match command_pid.wait(WaitOptions::new().no_hang()) { Ok(ok) => break ok, Err(WaitError::NotReady) => count += 1, Err(WaitError::Io(err)) => panic!("{err}"), } }; assert_eq!(command_pid, pid); assert!(status.did_exit()); assert_eq!(status.exit_status(), Some(42)); assert!(count > 0); assert!(!status.was_signaled()); assert!(status.term_signal().is_none()); assert!(!status.was_stopped()); assert!(status.stop_signal().is_none()); assert!(!status.did_continue()); } } sudo-rs-0.2.10/src/visudo/cli.rs000064400000000000000000000150721046102023000145220ustar 00000000000000#[derive(Debug, PartialEq)] pub(crate) struct VisudoOptions { pub(crate) file: Option, pub(crate) owner: bool, pub(crate) perms: bool, pub(crate) action: VisudoAction, } impl Default for VisudoOptions { fn default() -> Self { Self { file: None, owner: false, perms: false, action: VisudoAction::Run, } } } #[derive(Debug, PartialEq)] pub(crate) enum VisudoAction { Help, Version, Check, Run, } type OptionSetter = fn(&mut VisudoOptions, Option) -> Result<(), String>; struct VisudoOption { short: char, long: &'static str, takes_argument: bool, set: OptionSetter, } impl VisudoOptions { const VISUDO_OPTIONS: &'static [VisudoOption] = &[ VisudoOption { short: 'c', long: "check", takes_argument: false, set: |options, _| { options.action = VisudoAction::Check; Ok(()) }, }, VisudoOption { short: 'f', long: "file", takes_argument: true, set: |options, argument| { options.file = Some(argument.ok_or("option requires an argument -- 'f'")?); Ok(()) }, }, VisudoOption { short: 'h', long: "help", takes_argument: false, set: |options, _| { options.action = VisudoAction::Help; Ok(()) }, }, VisudoOption { short: 'I', long: "no-includes", takes_argument: false, set: |_, _| Ok(()), /* ignored for compatibility sake */ }, VisudoOption { short: 'q', long: "quiet", takes_argument: false, set: |_, _| Ok(()), /* ignored for compatibility sake */ }, VisudoOption { short: 's', long: "strict", takes_argument: false, set: |_, _| Ok(()), /* ignored for compatibility sake */ }, VisudoOption { short: 'V', long: "version", takes_argument: false, set: |options, _| { options.action = VisudoAction::Version; Ok(()) }, }, VisudoOption { short: 'O', long: "owner", takes_argument: false, set: |options, _| { options.owner = true; Ok(()) }, }, VisudoOption { short: 'P', long: "perms", takes_argument: false, set: |options, _| { options.perms = true; Ok(()) }, }, ]; pub(crate) fn from_env() -> Result { let args = std::env::args().collect(); Self::parse_arguments(args) } /// parse su arguments into VisudoOptions struct pub(crate) fn parse_arguments(arguments: Vec) -> Result { let mut options: VisudoOptions = VisudoOptions::default(); let mut arg_iter = arguments.into_iter().skip(1); while let Some(arg) = arg_iter.next() { // if the argument starts with -- it must be a full length option name if arg.starts_with("--") { // parse assignments like '--file=/etc/sudoers' if arg.contains('=') { // convert assignment to normal tokens let (key, value) = arg.split_once('=').unwrap(); // lookup the option by name if let Some(option) = Self::VISUDO_OPTIONS.iter().find(|o| o.long == &key[2..]) { // the value is already present, when the option does not take any arguments this results in an error if option.takes_argument { (option.set)(&mut options, Some(value.to_string()))?; } else { Err(format!("'--{}' does not take any arguments", option.long))?; } } else { Err(format!("unrecognized option '{arg}'"))?; } // lookup the option } else if let Some(option) = Self::VISUDO_OPTIONS.iter().find(|o| o.long == &arg[2..]) { // try to parse an argument when the option needs an argument if option.takes_argument { let next_arg = arg_iter.next(); (option.set)(&mut options, next_arg)?; } else { (option.set)(&mut options, None)?; } } else { Err(format!("unrecognized option '{arg}'"))?; } } else if arg.starts_with('-') { // flags can be grouped, so we loop over the characters for (n, char) in arg.trim_start_matches('-').chars().enumerate() { // lookup the option if let Some(option) = Self::VISUDO_OPTIONS.iter().find(|o| o.short == char) { // try to parse an argument when one is necessary, either the rest of the current flag group or the next argument if option.takes_argument { let rest = arg[(n + 2)..].trim().to_string(); let next_arg = if rest.is_empty() { arg_iter.next() } else { Some(rest) }; (option.set)(&mut options, next_arg)?; // stop looping over flags if the current flag takes an argument break; } else { // parse flag without argument (option.set)(&mut options, None)?; } } else { Err(format!("unrecognized option '{char}'"))?; } } } else { // If the arg doesn't start with a `-` it must be a file argument. However `-f` // must take precedence if options.file.is_none() { options.file = Some(arg); } } } Ok(options) } } sudo-rs-0.2.10/src/visudo/help.rs000064400000000000000000000007741046102023000147060ustar 00000000000000pub(crate) const USAGE_MSG: &str = "usage: visudo [-chqsV] [[-f] sudoers ]"; const DESCRIPTOR: &str = "visudo - safely edit the sudoers file"; const HELP_MSG: &str = "Options: -c, --check check-only mode -f, --file=sudoers specify sudoers file location -h, --help display help message and exit -V, --version display version information and exit "; pub(crate) fn long_help_message() -> String { format!("{USAGE_MSG}\n\n{DESCRIPTOR}\n\n{HELP_MSG}") } sudo-rs-0.2.10/src/visudo/mod.rs000064400000000000000000000310241046102023000145250ustar 00000000000000#![forbid(unsafe_code)] mod cli; mod help; use std::{ env, ffi, fs::{File, Permissions}, io::{self, Read, Seek, Write}, os::unix::prelude::{MetadataExt, PermissionsExt}, path::{Path, PathBuf}, process::Command, str, }; use crate::{ common::resolve::CurrentUser, sudo::{candidate_sudoers_file, diagnostic}, sudoers::{self, Sudoers}, system::{ file::{create_temporary_dir, Chown, FileLock}, interface::{GroupId, UserId}, signal::{consts::*, register_handlers, SignalStream}, Hostname, User, }, }; use self::cli::{VisudoAction, VisudoOptions}; use self::help::{long_help_message, USAGE_MSG}; const VERSION: &str = env!("CARGO_PKG_VERSION"); macro_rules! io_msg { ($err:expr, $($tt:tt)*) => { io::Error::new($err.kind(), format!("{}: {}", format_args!($($tt)*), $err)) }; } pub fn main() { if User::effective_uid() != User::real_uid() || User::effective_gid() != User::real_gid() { println_ignore_io_error!( "Visudo must not be installed as setuid binary.\n\ Please notify your packager about this misconfiguration.\n\ To prevent privilege escalation visudo will now abort. " ); std::process::exit(1); } let options = match VisudoOptions::from_env() { Ok(options) => options, Err(error) => { println_ignore_io_error!("visudo: {error}\n{USAGE_MSG}"); std::process::exit(1); } }; let cmd = match options.action { VisudoAction::Help => { println_ignore_io_error!("{}", long_help_message()); std::process::exit(0); } VisudoAction::Version => { println_ignore_io_error!("visudo version {VERSION}"); std::process::exit(0); } VisudoAction::Check => check, VisudoAction::Run => run, }; match cmd(options.file.as_deref(), options.perms, options.owner) { Ok(()) => {} Err(error) => { eprintln_ignore_io_error!("visudo: {error}"); std::process::exit(1); } } } fn check(file_arg: Option<&str>, perms: bool, owner: bool) -> io::Result<()> { let sudoers_path = &file_arg .map(PathBuf::from) .unwrap_or_else(candidate_sudoers_file); let sudoers_file = File::open(sudoers_path) .map_err(|err| io_msg!(err, "unable to open {}", sudoers_path.display()))?; let metadata = sudoers_file.metadata()?; if file_arg.is_none() || perms { // For some reason, the MSB of the mode is on so we need to mask it. let mode = metadata.permissions().mode() & 0o777; if mode != 0o440 { return Err(io::Error::new( io::ErrorKind::Other, format!( "{}: bad permissions, should be mode 0440, but found {mode:04o}", sudoers_path.display() ), )); } } if file_arg.is_none() || owner { let owner = (metadata.uid(), metadata.gid()); if owner != (0, 0) { return Err(io::Error::new( io::ErrorKind::Other, format!( "{}: wrong owner (uid, gid) should be (0, 0), but found {owner:?}", sudoers_path.display() ), )); } } let (_sudoers, errors) = Sudoers::read(&sudoers_file, sudoers_path)?; if errors.is_empty() { writeln!(io::stdout(), "{}: parsed OK", sudoers_path.display())?; return Ok(()); } for crate::sudoers::Error { message, source, location, } in errors { let path = source.as_deref().unwrap_or(sudoers_path); diagnostic::diagnostic!("syntax error: {message}", path @ location); } Err(io::Error::new(io::ErrorKind::Other, "invalid sudoers file")) } fn run(file_arg: Option<&str>, perms: bool, owner: bool) -> io::Result<()> { let sudoers_path = &file_arg .map(PathBuf::from) .unwrap_or_else(candidate_sudoers_file); let (sudoers_file, existed) = if sudoers_path.exists() { let file = File::options() .read(true) .write(true) .open(sudoers_path) .map_err(|e| { io::Error::new( e.kind(), format!("Failed to open existing sudoers file at {sudoers_path:?}: {e}"), ) })?; (file, true) } else { // Create a sudoers file if it doesn't exist. let file = File::create(sudoers_path).map_err(|e| { io::Error::new( e.kind(), format!("Failed to create sudoers file at {sudoers_path:?}: {e}"), ) })?; // ogvisudo sets the permissions of the file so it can be read and written by the user and // read by the group if the `-f` argument was passed. if file_arg.is_some() { file.set_permissions(Permissions::from_mode(0o640)) .map_err(|e| { io::Error::new( e.kind(), format!( "Failed to set permissions on new sudoers file at {sudoers_path:?}: {e}" ), ) })?; } (file, false) }; let lock = FileLock::exclusive(&sudoers_file, true).map_err(|err| { if err.kind() == io::ErrorKind::WouldBlock { io_msg!(err, "{} busy, try again later", sudoers_path.display()) } else { err } })?; if perms || file_arg.is_none() { sudoers_file.set_permissions(Permissions::from_mode(0o440))?; } if owner || file_arg.is_none() { sudoers_file.chown(UserId::ROOT, GroupId::new(0))?; } let signal_stream = SignalStream::init()?; let handlers = register_handlers([SIGTERM, SIGHUP, SIGINT, SIGQUIT])?; let tmp_dir = create_temporary_dir()?; let tmp_path = tmp_dir.join("sudoers"); { let tmp_dir = tmp_dir.clone(); std::thread::spawn(|| -> io::Result<()> { signal_stream.recv()?; let _ = std::fs::remove_dir_all(tmp_dir); drop(handlers); std::process::exit(1) }); } let tmp_file = File::options() .read(true) .write(true) .create(true) .truncate(true) .open(&tmp_path)?; tmp_file.set_permissions(Permissions::from_mode(0o600))?; let result = edit_sudoers_file( existed, sudoers_file, sudoers_path, lock, tmp_file, &tmp_path, ); std::fs::remove_dir_all(tmp_dir)?; result } fn edit_sudoers_file( existed: bool, mut sudoers_file: File, sudoers_path: &Path, lock: FileLock, mut tmp_file: File, tmp_path: &Path, ) -> io::Result<()> { let mut stderr = io::stderr(); let mut sudoers_contents = Vec::new(); // Since visudo is meant to run as root, resolve shouldn't fail let current_user: User = match CurrentUser::resolve() { Ok(user) => user.into(), Err(err) => { writeln!(stderr, "visudo: cannot resolve : {err}")?; return Ok(()); } }; let host_name = Hostname::resolve(); let editor_path = if existed { // If the sudoers file existed, read its contents and write them into the temporary file. sudoers_file.read_to_end(&mut sudoers_contents)?; // Rewind the sudoers file so it can be written later. sudoers_file.rewind()?; // Write to the temporary file. tmp_file.write_all(&sudoers_contents)?; let (sudoers, _errors) = Sudoers::read(sudoers_contents.as_slice(), sudoers_path)?; sudoers.visudo_editor_path(&host_name, ¤t_user, ¤t_user) } else { // there is no /etc/sudoers config yet, so use a system default PathBuf::from(crate::defaults::SYSTEM_EDITOR) }; loop { Command::new(&editor_path) .arg("--") .arg(tmp_path) .spawn() .map_err(|_| { io::Error::new( io::ErrorKind::NotFound, format!( "specified editor ({}) could not be used", editor_path.display() ), ) })? .wait_with_output()?; let (sudoers, errors) = File::open(tmp_path) .and_then(|reader| Sudoers::read(reader, tmp_path)) .map_err(|err| { io_msg!( err, "unable to re-open temporary file ({}), {} unchanged", tmp_path.display(), sudoers_path.display() ) })?; if !errors.is_empty() { writeln!(stderr, "The provided sudoers file format is not recognized or contains syntax errors. Please review:\n")?; for crate::sudoers::Error { message, source, location, } in errors { let path = source.as_deref().unwrap_or(sudoers_path); diagnostic::diagnostic!("syntax error: {message}", path @ location); } writeln!(stderr)?; match ask_response(b"What now? e(x)it without saving / (e)dit again: ", b"xe")? { b'x' => return Ok(()), _ => continue, } } else { if sudo_visudo_is_allowed(sudoers, &host_name) == Some(false) { writeln!( stderr, "It looks like you have removed your ability to run 'sudo visudo' again.\n" )?; match ask_response( b"What now? e(x)it without saving / (e)dit again / lock me out and (S)ave: ", b"xeS", )? { b'x' => return Ok(()), b'S' => {} _ => continue, } } break; } } let tmp_contents = std::fs::read(tmp_path)?; // Only write to the sudoers file if the contents changed. if tmp_contents == sudoers_contents { writeln!(stderr, "visudo: {} unchanged", tmp_path.display())?; } else { sudoers_file.write_all(&tmp_contents)?; let new_size = sudoers_file.stream_position()?; sudoers_file.set_len(new_size)?; } lock.unlock()?; Ok(()) } // To detect potential lock-outs if the user called "sudo visudo". // Note that SUDO_USER will normally be set by sudo. // // This returns Some(false) if visudo is forbidden under the given config; // Some(true) if it is allowed; and None if it cannot be determined, which // will be the case if e.g. visudo was simply run as root. fn sudo_visudo_is_allowed(mut sudoers: Sudoers, host_name: &Hostname) -> Option { let sudo_user = User::from_name(&ffi::CString::new(env::var("SUDO_USER").ok()?).ok()?).ok()??; let super_user = User::from_uid(UserId::ROOT).ok()??; let request = sudoers::Request { user: &super_user, group: &super_user.primary_group().ok()?, command: &env::current_exe().ok()?, arguments: &[], }; Some(matches!( sudoers .check(&sudo_user, host_name, request) .authorization(), sudoers::Authorization::Allowed { .. } )) } // Make sure that the first valid response is the "safest" choice pub(crate) fn ask_response(prompt: &[u8], valid_responses: &[u8]) -> io::Result { let stdin = io::stdin(); let stdout = io::stdout(); let mut stderr = io::stderr(); let mut stdin_handle = stdin.lock(); let mut stdout_handle = stdout.lock(); loop { stdout_handle.write_all(prompt)?; stdout_handle.flush()?; let mut input = [0u8]; if let Err(err) = stdin_handle.read_exact(&mut input) { writeln!(stderr, "visudo: cannot read user input: {err}")?; return Ok(valid_responses[0]); } // read the trailing newline loop { let mut skipped = [0u8]; match stdin_handle.read_exact(&mut skipped) { Ok(()) if &skipped != b"\n" => continue, _ => break, } } if valid_responses.contains(&input[0]) { return Ok(input[0]); } else { writeln!( stderr, "Invalid option: '{}'\n", str::from_utf8(&input).unwrap_or("") )?; } } }