├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── FAQ.md ├── LICENSE ├── README.md ├── build.rs ├── cliff.toml ├── clippy.toml ├── release ├── src ├── cl.rs ├── main.rs ├── strace │ ├── mod.rs │ ├── parser │ │ ├── combinator.rs │ │ └── mod.rs │ └── run.rs ├── summarize │ ├── handlers.rs │ └── mod.rs ├── sysctl.rs └── systemd │ ├── mod.rs │ ├── options.rs │ ├── resolver.rs │ ├── service.rs │ └── version.rs ├── systemd_options.md ├── test-all └── tests ├── options.rs └── systemd-run.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions-rs/toolchain@v1 14 | with: 15 | profile: minimal 16 | toolchain: stable 17 | - run: cargo build --verbose 18 | 19 | test: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions-rs/toolchain@v1 24 | with: 25 | profile: minimal 26 | toolchain: stable 27 | - run: ./test-all --verbose -- --test-threads 1 --skip ping 28 | 29 | clippy: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions-rs/toolchain@v1 34 | with: 35 | profile: minimal 36 | toolchain: stable 37 | components: clippy 38 | - run: cargo clippy -- -D warnings 39 | 40 | fmt: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions-rs/toolchain@v1 45 | with: 46 | profile: minimal 47 | toolchain: stable 48 | components: rustfmt 49 | - run: cargo fmt --all -- --check 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*.*.**" 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | changelog-release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | fetch-tags: true 22 | - uses: taiki-e/install-action@v2 23 | with: 24 | tool: git-cliff 25 | - run: git cliff -o CHANGELOG_${{ github.ref_name }}.md $(git tag -l | sort -V | tail -n -2 | head -n -1)..HEAD 26 | - uses: softprops/action-gh-release@v1 27 | with: 28 | body_path: CHANGELOG_${{ github.ref_name }}.md 29 | token: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | deb-release: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: actions-rs/toolchain@v1 36 | with: 37 | profile: minimal 38 | toolchain: stable 39 | target: x86_64-unknown-linux-gnu 40 | - run: | 41 | mkdir -p target/{man,shellcomplete} 42 | cargo run --features generate-extra -- gen-man-pages target/man 43 | cargo run --features generate-extra -- gen-shell-complete target/shellcomplete 44 | gzip -9 target/man/*.1 45 | - uses: taiki-e/install-action@v2 46 | with: 47 | tool: cargo-deb 48 | - run: cargo deb --target x86_64-unknown-linux-gnu 49 | env: 50 | RUSTFLAGS: "-C target-feature=+crt-static" 51 | - uses: softprops/action-gh-release@v1 52 | with: 53 | files: target/x86_64-unknown-linux-gnu/debian/shh_*.deb 54 | token: ${{ secrets.GITHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # https://pre-commit.com 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-case-conflict 8 | - id: check-executables-have-shebangs 9 | - id: check-json 10 | - id: check-merge-conflict 11 | - id: check-symlinks 12 | - id: check-toml 13 | - id: check-xml 14 | - id: check-yaml 15 | - id: end-of-file-fixer 16 | - id: fix-byte-order-marker 17 | - id: mixed-line-ending 18 | args: 19 | - --fix=no 20 | 21 | - repo: https://github.com/doublify/pre-commit-rust 22 | rev: v1.0 23 | hooks: 24 | - id: cargo-check 25 | - id: clippy 26 | - id: fmt 27 | 28 | - repo: https://github.com/shellcheck-py/shellcheck-py 29 | rev: v0.10.0.1 30 | hooks: 31 | - id: shellcheck 32 | 33 | - repo: https://github.com/compilerla/conventional-pre-commit 34 | rev: v4.0.0 35 | hooks: 36 | - id: conventional-pre-commit 37 | stages: [commit-msg] 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2025.4.12 4 | 5 | ### 💡 Features 6 | 7 | - Model disabled mount propagation to host ([70637d4](https://github.com/desbma/shh/commit/70637d4d33b660c73e74cf7304ae69bbcdd916cf) by desbma) 8 | - Support PrivateMounts systemd option ([ca293da](https://github.com/desbma/shh/commit/ca293dac64c6022d802439ad6bfeffb4490fca68) by desbma) 9 | 10 | ### 🐛 Bug fixes 11 | 12 | - Handle namespace pseudo files ([6f75bd9](https://github.com/desbma/shh/commit/6f75bd91c0569b4453a3f9880ca78b67fc483803) by desbma) 13 | 14 | ### 🧪 Testing 15 | 16 | - Add netns systemd-run test ([7162280](https://github.com/desbma/shh/commit/7162280cf0a0d53cd3f4da6e6e863cb0a3212c1f) by desbma) 17 | - options: Remove checks of options that vary too much between environments ([1f18b17](https://github.com/desbma/shh/commit/1f18b171cf45043fae01ff24aa4b6fb79dc577c9) by desbma) 18 | 19 | ### 🏗 Build 20 | 21 | - Generate systemd syscall classes at build time from systemd-analyze output ([c52a860](https://github.com/desbma/shh/commit/c52a860e31bf476c845d1994f65431f3078bc344) by desbma) 22 | 23 | ### 🧰 Miscellaneous tasks 24 | 25 | - Update dependencies ([70d2142](https://github.com/desbma/shh/commit/70d21422b42f4848fe9a4b09bf6e32d43c220816) by desbma) 26 | - Update lints, update to 2024 edition ([a625d11](https://github.com/desbma/shh/commit/a625d11d4edbc14eb81c07a9ab197375ae4e989f) by desbma) 27 | 28 | --- 29 | 30 | ## v2025.3.13 31 | 32 | ### 🧰 Miscellaneous tasks 33 | 34 | - Lint ([5bf6fd2](https://github.com/desbma/shh/commit/5bf6fd20af840846bc8b29bba8e0c8721134f263) by desbma) 35 | 36 | --- 37 | 38 | ## v2025.3.12 39 | 40 | ### 💡 Features 41 | 42 | - ProcSubset systemd option ([365f76d](https://github.com/desbma/shh/commit/365f76d02de48dce433b8ce88f9cc35ec57f7bc2) by desbma) 43 | 44 | ### 🐛 Bug fixes 45 | 46 | - Non leaf symlinks not being canonicalized ([6e90c41](https://github.com/desbma/shh/commit/6e90c418b4a484a31d00193bf4ae682df7642aa9) by desbma) 47 | 48 | ### 📗 Documentation 49 | 50 | - README: Update shh run example output ([7ba62e3](https://github.com/desbma/shh/commit/7ba62e32e73fcc947853bf1d7e3cc7c19e950a32) by desbma) 51 | - README: Split crates.io installation instructions + minor tweaks ([7312ae4](https://github.com/desbma/shh/commit/7312ae4dca2bf7c58354b02cc5a261c90385cf2e) by desbma) 52 | - FAQ: Minor typo fix ([9176a6d](https://github.com/desbma/shh/commit/9176a6d095dd3e73ebdefbf16a7ff66598c84aa0) by desbma) 53 | 54 | ### 🧪 Testing 55 | 56 | - Add ProcSubset integration test ([4ca7a12](https://github.com/desbma/shh/commit/4ca7a129251a632650f6241abe2382df3b8a26d4) by desbma) 57 | 58 | ### 🚜 Refactor 59 | 60 | - Rename 'cl' integration tests to 'options' ([b7e6478](https://github.com/desbma/shh/commit/b7e64789ec90637f6268cbd1ae0d13848fdec980) by desbma) 61 | 62 | --- 63 | 64 | ## v2025.2.7 65 | 66 | ### 💡 Features 67 | 68 | - Track IPv4 addresses ([b4dc2c1](https://github.com/desbma/shh/commit/b4dc2c19178fa649bccac386f732351532c05de3) by desbma) 69 | - IpAddressDeny (WIP) ([8df9a0c](https://github.com/desbma/shh/commit/8df9a0c55c0ee45e0fd7efe9f2c598ebec23c5b8) by desbma) 70 | - Improve network activity coverage ([d8aa8b5](https://github.com/desbma/shh/commit/d8aa8b53947606f4d2666834aeafe066fa02de33) by desbma) 71 | - Dynamic IpAddressAllow ([4928a4c](https://github.com/desbma/shh/commit/4928a4ca81e5a4ed941a6e7ccc91d1dce6c7a6be) by desbma) 72 | - Reorder options ([2f94302](https://github.com/desbma/shh/commit/2f94302149dea5a66fe7489f44cb7decf22fd8cb) by desbma) 73 | - Greatly simplify SocketBindDeny handling ([25c9bf7](https://github.com/desbma/shh/commit/25c9bf730a435ec5156854eefef3a8aecaece65b) by desbma) 74 | - IPv6 support for IPAddressAllow ([9dc0376](https://github.com/desbma/shh/commit/9dc0376ccb246ef511e3cad8987aa9d42646741f) by desbma) 75 | - Make service reset block ([d95f533](https://github.com/desbma/shh/commit/d95f53397fb8cbec48eada4ab658574b923ac77a) by desbma) 76 | - Add option to edit fragment before applying it ([a83c7ab](https://github.com/desbma/shh/commit/a83c7ab29f35a4b9e92a5d7f7e67f9961bc85d55) by desbma) 77 | 78 | ### 📗 Documentation 79 | 80 | - FAQ: Fix typos + mention --merge-paths-threshold option ([9fc6412](https://github.com/desbma/shh/commit/9fc6412b026ca9e957d025e45e6003d043c084d2) by desbma) 81 | 82 | ### 🧪 Testing 83 | 84 | - systemd-run: Add curl test ([8cecf59](https://github.com/desbma/shh/commit/8cecf59084077ac74e1e35695e24c9a5b5d27dbe) by desbma) 85 | - Add ping IPv4 & IPv6 tests ([2c96a3f](https://github.com/desbma/shh/commit/2c96a3f8d78ef6f9063bef15b78ddc82450b2ef1) by desbma) 86 | 87 | ### 🚜 Refactor 88 | 89 | - Mark unreachable code paths as such ([827e88c](https://github.com/desbma/shh/commit/827e88c6bf1480de626b48c15686c76f75f49dab) by desbma) 90 | - Remove now unneeded CountableSetSpecifier ([975a9af](https://github.com/desbma/shh/commit/975a9af6c0a370fd133b678fe5961defd7cd7386) by desbma) 91 | - Update panic macro usage ([4cc7328](https://github.com/desbma/shh/commit/4cc73288d9d25bc387a8ffa6f2143ada209753a8) by desbma) 92 | 93 | --- 94 | 95 | ## v2025.2.6 96 | 97 | ### 💡 Features 98 | 99 | - Mkdir syscall ([f25364d](https://github.com/desbma/shh/commit/f25364d8a5e00f6e62b8efc1734f7b3d7fef5b01) by desbma) 100 | - Track current dir ([1d0080b](https://github.com/desbma/shh/commit/1d0080b8c98ff65224e7f02f7276828e1099ceec) by desbma) 101 | - Use current directory to resolve relative paths ([b486593](https://github.com/desbma/shh/commit/b486593667d9ce3f153469a059358e6230dfa605) by desbma) 102 | - Log whole syscall when handling fails ([f8402d8](https://github.com/desbma/shh/commit/f8402d818d31afe68fe6b3a655162c21eb4a471e) by desbma) 103 | - File system deny all + white list ([502ca9d](https://github.com/desbma/shh/commit/502ca9d451bfa72a40559f6503f0bbafaef8eb50) by desbma) 104 | - Filesystem exception whitelist merging ([2263ab4](https://github.com/desbma/shh/commit/2263ab4e015c82cd6b8286d48626f81e59c72b50) by desbma) 105 | - InaccessiblePaths systemd option (WIP) ([aa76500](https://github.com/desbma/shh/commit/aa765008c7ef121b7a44ae26a66fee112d64542d) by desbma) 106 | - InaccessiblePaths dynamic whitelisting + auto merge options ([53a3c10](https://github.com/desbma/shh/commit/53a3c10757761b23429439dd1e06ea94cb6ad3fa) by desbma) 107 | - Handle exec syscalls ([31814d2](https://github.com/desbma/shh/commit/31814d2eda9caff4352d0790eaba68f947f14380) by desbma) 108 | - Support NoExecPaths systemd option + ExecPath whitelisting ([dbf32a4](https://github.com/desbma/shh/commit/dbf32a499aeabc45f30370e0215f5fe3822c31c1) by desbma) 109 | - Handle PROT_EXEC memory mappings ([16345ae](https://github.com/desbma/shh/commit/16345aedbfb03e9ee9bf4e7bd214d800471dfb33) by desbma) 110 | - Handle intermediate symlinks in all paths ([3015caf](https://github.com/desbma/shh/commit/3015caf90dd98b9abba7607aaa106d6853193193) by desbma) 111 | - Parse ELF header to get dynamic linker interpreter ([6cef0c0](https://github.com/desbma/shh/commit/6cef0c0f0bc774e3fc9a1e10aaf845988afe8959) by desbma) 112 | - Parse shebang to handle exec'd scripts ([1175415](https://github.com/desbma/shh/commit/11754157fb36b2c5c93e3b3820edc71dd3a2cbff) by desbma) 113 | - Disable XxxPaths options if an exception for / makes them useless ([4c97afb](https://github.com/desbma/shh/commit/4c97afb274c31f80796391e3a52225f56939327c) by desbma) 114 | - Auto remove .service suffix ([1355caf](https://github.com/desbma/shh/commit/1355caf3f50bc12ab568aebddc8ca809b2b28f7f) by desbma) 115 | - Check for unsupported unit types ([dd09b00](https://github.com/desbma/shh/commit/dd09b00eb4b55cec1c3151b744c01470c82ef911) by desbma) 116 | - Losslessly simplify paths lists when length is below threshold ([4307ef9](https://github.com/desbma/shh/commit/4307ef92fe8e36c6a3200ae928fde9f025e39398) by desbma) 117 | - Prevent InaccessiblePaths/TemporaryFilesystem to be too easily disabled when / is read (WIP) ([407876f](https://github.com/desbma/shh/commit/407876f558178a2ed45d088f22fd2e2bf337861f) by desbma) 118 | - Improve & re-enable InaccessiblePaths second option ([cdba2f5](https://github.com/desbma/shh/commit/cdba2f5c09742b25557ff42f96dd38390502c6ca) by desbma) 119 | - Improve null effect removal ([f08380d](https://github.com/desbma/shh/commit/f08380d66cff98599c0cfdfd5ccceeecf409a3ef) by desbma) 120 | - Split option effects EmptyPath/RemovePath ([5c6814c](https://github.com/desbma/shh/commit/5c6814cd71fcbc7bd99a065bc3c021dc6ae07e0d) by desbma) 121 | - TemporaryFileSystem=xxx:ro & BindReadOnlyPaths=yyy support ([191fb61](https://github.com/desbma/shh/commit/191fb61c9ef399742b30e8d8abf96e84d42afd25) by desbma) 122 | - Go deeper when whitelisting with TemporaryFileSystem ([d8b6ac5](https://github.com/desbma/shh/commit/d8b6ac51cd796913b56f4be4a6615cd9a6ca12e6) by desbma) 123 | - Add systemd option whitelist for testing ([1bd3d49](https://github.com/desbma/shh/commit/1bd3d4963691dc6d272f6d6d8d551320b393e981) by desbma) 124 | - Prevent duplicate BindPaths/BindReadOnlyPaths exceptions + add tests for InaccessiblePaths ([9c952b1](https://github.com/desbma/shh/commit/9c952b130f9713cc05cafcb18d7a039a58738dce) by desbma) 125 | - Log 'systemd-analyze security' "exposure level" ([60d6309](https://github.com/desbma/shh/commit/60d63096240091f809537589c665cda7fa664120) by desbma) 126 | - More explicit error reporting ([9d79ae3](https://github.com/desbma/shh/commit/9d79ae357c428e521e2996170f3176a5fc0a6dd9) by desbma) 127 | - Improve markdown option list output ([f4f4c88](https://github.com/desbma/shh/commit/f4f4c88af499dbe41ad79464aa37c09e954f84a2) by desbma) 128 | - Detect another case of nullified option effect ([5bd0532](https://github.com/desbma/shh/commit/5bd0532e962fe1246cedfe22df9030556ff8322e) by desbma) 129 | 130 | ### 🐛 Bug fixes 131 | 132 | - Absolute path computation ([702ca50](https://github.com/desbma/shh/commit/702ca50f6274ec5cfbe53721193ed6f9779115ae) by desbma) 133 | - Remove TODO obsolete comment ([0b20d4b](https://github.com/desbma/shh/commit/0b20d4b46a93ddb7594a29458f9bac9907a73824) by desbma) 134 | - Test for char device defensively ([65e8c74](https://github.com/desbma/shh/commit/65e8c749ac87f1f057e519c852686599f61007c8) by desbma) 135 | - Bind on port 0 handling ([d81a660](https://github.com/desbma/shh/commit/d81a660bdce40d04808f322f50864a0478bdc2df) by desbma) 136 | - InaccessiblePaths handling of Create and Exec action whitelisting ([a358de9](https://github.com/desbma/shh/commit/a358de91fe9c2a035ccd40a568ca6125188439e3) by desbma) 137 | - Open with O_RDONLY ([8014c66](https://github.com/desbma/shh/commit/8014c668392026cb9bef3059b9c4e97cdcff837c) by desbma) 138 | - Don't follow symlinks when resolving paths ([de0d459](https://github.com/desbma/shh/commit/de0d459873ab6367df867c6bbf273692fef5baf2) by desbma) 139 | - Open on symlink path ([096fc4f](https://github.com/desbma/shh/commit/096fc4f9436a7261996ba4beee79785fb3be8f88) by desbma) 140 | - Reading /dev/kmsg requires CAP_SYSLOG ([2df9689](https://github.com/desbma/shh/commit/2df96898e74ffc8f96779d934e07f0ad2bd9efb7) by desbma) 141 | - ProtectKernelLogs=true denies syslog ([39e2aa4](https://github.com/desbma/shh/commit/39e2aa44982faff79d3354b50e3445a38d9bb662) by desbma) 142 | - PrivateDevices=true denies mknod and makes /dev noexec ([7f5b3d5](https://github.com/desbma/shh/commit/7f5b3d509dbbcda34b33d28b3f349b349d118707) by desbma) 143 | - Per option element '-' prefix ([cc6fe8a](https://github.com/desbma/shh/commit/cc6fe8a1699e70c2edfd479b7ec947846e18dc3b) by desbma) 144 | - Passing of network firewalling option ([6d1a361](https://github.com/desbma/shh/commit/6d1a3618d8d732f12906eb8761f4eab323d018ac) by desbma) 145 | - Bind port 0 ([153531e](https://github.com/desbma/shh/commit/153531eb14ca400d8b27a06769a5a8014a613dba) by desbma) 146 | - tests: Dmesg tests depending on system logs ([ed7f5cf](https://github.com/desbma/shh/commit/ed7f5cfa9d65380bb7fe073fd21057d1c88de10c) by desbma) 147 | - Remove option negated by exception on / ([023bb61](https://github.com/desbma/shh/commit/023bb61dfdeb5ea33300d15d684244186ccd3f80) by desbma) 148 | - Sort paths ([e2b75d5](https://github.com/desbma/shh/commit/e2b75d596d7a92cd9a83781e6612b5c8fcc99375) by desbma) 149 | - Ensure paths in PATH env var are accessible ([877f62a](https://github.com/desbma/shh/commit/877f62a5b3f08ea9d37559fe482f07d008e8f392) by desbma) 150 | - Don't make /proc or /run inaccessible ([e66e342](https://github.com/desbma/shh/commit/e66e34242622356a96bf836539615d9f2013e525) by desbma) 151 | - Hide effect not incompatible with Create action ([5cce1b1](https://github.com/desbma/shh/commit/5cce1b128a1f03cb31cd199cf2e22706f8013fbb) by desbma) 152 | - Null effect removal inverted test ([4c228df](https://github.com/desbma/shh/commit/4c228df231676ec6c2084f9d8f9df7349d663ab8) by desbma) 153 | - Debian man page names ([4136bed](https://github.com/desbma/shh/commit/4136bed351390f6696ae3ce190ca4158bff96eeb) by desbma) 154 | 155 | ### 🏃 Performance 156 | 157 | - Sort -> sort_unstable ([a3bfba5](https://github.com/desbma/shh/commit/a3bfba592cfd433c81d5cfe0a9effadeeae949ae) by desbma) 158 | - More &'static str conversion ([5265b90](https://github.com/desbma/shh/commit/5265b90e2843124120c958733b0bc24d7b9e8fd0) by desbma) 159 | 160 | ### 📗 Documentation 161 | 162 | - Add crates.io link & install instructions ([8986cfb](https://github.com/desbma/shh/commit/8986cfb8ab8d5444deb12e97d8cd335c4c47c249) by desbma) 163 | - Improve description of --network-firewalling and --filesystem-whitelisting options ([4f5a867](https://github.com/desbma/shh/commit/4f5a86792d61a9862340c9cb54d648423bea96b4) by desbma) 164 | - Add FAQ ([8ab785e](https://github.com/desbma/shh/commit/8ab785e4a979df5fc95211cd20640d1eecb9b0d2) by desbma) 165 | - Comment typo ([71548b6](https://github.com/desbma/shh/commit/71548b6200c21da31149bfe027553b5ebfe85022) by desbma) 166 | - Minor option description improvements ([e39c0bc](https://github.com/desbma/shh/commit/e39c0bc6978a1dfcb40de473fc7b078516618ee4) by desbma) 167 | - README: Add shh run examples ([defe380](https://github.com/desbma/shh/commit/defe380d5ea1fd2b633a22d50ad258007a444521) by desbma) 168 | 169 | ### 🧪 Testing 170 | 171 | - Fix sched_realtime integration test broken with Python 3.13 ([4fa9d25](https://github.com/desbma/shh/commit/4fa9d257bf49272396a90b45fb43c53f0e5771b1) by desbma) 172 | - Add integration tests running systemd-run ([b59c63d](https://github.com/desbma/shh/commit/b59c63dbe862c39d18a353b99ab4da88cc7db0f3) by desbma) 173 | - systemd-run: Log shh run options ([efa12eb](https://github.com/desbma/shh/commit/efa12eb8db7890218b5629dfb3394dff8bd4d94d) by desbma) 174 | - Simplify mmap W+X commands ([2c83c5f](https://github.com/desbma/shh/commit/2c83c5fdc65e17019ad2c91706f30f92257faf59) by desbma) 175 | - Fix passing file via /tmp ([b927803](https://github.com/desbma/shh/commit/b927803e775e4ac227f0003df8dbfd234c116134) by desbma) 176 | 177 | ### 🚜 Refactor 178 | 179 | - Simplify OptionValue::List ([0e9a7fc](https://github.com/desbma/shh/commit/0e9a7fc932822857df5ffb6e41b4bd56160dd193) by desbma) 180 | - Improve error handling for fd type conversions ([db420d3](https://github.com/desbma/shh/commit/db420d3e681bd3c7c5148713fc85db77b0a905cf) by desbma) 181 | - Add convenience constructors for PathDescription ([f74cf59](https://github.com/desbma/shh/commit/f74cf59fd363e9ed7e1818790021add8a653a07e) by desbma) 182 | 183 | ### 🤖 Continuous integration 184 | 185 | - Enable systemd-run integration tests ([c3b4d7f](https://github.com/desbma/shh/commit/c3b4d7fa72611eda4c0fab0b33684dd2e10ff269) by desbma) 186 | 187 | ### 🧰 Miscellaneous tasks 188 | 189 | - Add cargo metadata & rename package to publish on crates.io ([1214fee](https://github.com/desbma/shh/commit/1214feeb6a2ffa5b1b74ea1f2be19e91b21f8fe7) by desbma) 190 | - Lint ([3763bc0](https://github.com/desbma/shh/commit/3763bc072c422e1fc8288f7a3e46d47cba6b6ea0) by desbma) 191 | - Update lints ([418bb2a](https://github.com/desbma/shh/commit/418bb2ac739a03e9ff34ab44eb9f9fd42012a234) by desbma) 192 | 193 | --- 194 | 195 | ## v2025.1.16 196 | 197 | ### 💡 Features 198 | 199 | - Update options for systemd v257 ([2ca1c42](https://github.com/desbma/shh/commit/2ca1c42bc64e15ac0a6a249879a6427142c3be7b) by desbma) 200 | - Add shh version in unit fragment header ([81bf6fd](https://github.com/desbma/shh/commit/81bf6fdbbde46556c18b3d329ad629db8f3d5487) by desbma) 201 | 202 | ### 🐛 Bug fixes 203 | 204 | - strace-parser: Indexed arrays ([f3c0c2f](https://github.com/desbma/shh/commit/f3c0c2ff529fb051051246447063a36700b5885a) by desbma) 205 | 206 | ### 📗 Documentation 207 | 208 | - Add changelog ([01ca7a1](https://github.com/desbma/shh/commit/01ca7a1c246ef9b87c68a5e161744b2dc04046d6) by desbma) 209 | - Add man pages ([53ba284](https://github.com/desbma/shh/commit/53ba28462f53a3d4a785679594b6662bc8185148) by desbma) 210 | - README: Add portability warning ([a9439ae](https://github.com/desbma/shh/commit/a9439ae72af4039987de64e9a1e168598f9df766) by desbma) 211 | - Update changelog template ([e666607](https://github.com/desbma/shh/commit/e6666075082198b23ec15e26468d45a0e196b73c) by desbma) 212 | 213 | ### 🧪 Testing 214 | 215 | - Add mknod integration test ([c6284af](https://github.com/desbma/shh/commit/c6284af106ddfbe891b424ee5ef587ee300a7a30) by desbma-s1n) 216 | - Simplify reference string definitions ([6971f54](https://github.com/desbma/shh/commit/6971f54b281022258a8ea076fa075b1240d304cb) by desbma) 217 | - Fix integration tests for PrivateTmp=disconnected broken by 2ca1c42 ([7a32f7e](https://github.com/desbma/shh/commit/7a32f7e53ff6270dfcc069292def1feaa0933fb0) by desbma) 218 | 219 | ### 🚜 Refactor 220 | 221 | - Drop peg strace parser ([5f1a98c](https://github.com/desbma/shh/commit/5f1a98cd46195a0781a2c81b0c2b6e79deccd787) by desbma) 222 | - summary: Split summary into per syscall group functions ([83fc818](https://github.com/desbma/shh/commit/83fc81824ee9565b9f222e3701e3769dc12ce28c) by desbma) 223 | - Factorize unit fragment header creation ([0687e63](https://github.com/desbma/shh/commit/0687e6313ce98bc865b8624e42fa11b99243bb34) by desbma) 224 | 225 | ### 🏗 Build 226 | 227 | - Release script auto version ([6fbca7e](https://github.com/desbma/shh/commit/6fbca7e9e590064235218b9b97a47c9d43b59a78) by desbma) 228 | - Remove unmaintained prettier pre-commit hook ([9c8a960](https://github.com/desbma/shh/commit/9c8a96027392115a53b0afa45e15403d3acab196) by desbma) 229 | 230 | ### 🧰 Miscellaneous tasks 231 | 232 | - Update lints for rust 1.83 ([ca2d791](https://github.com/desbma/shh/commit/ca2d79142073c0247c9b2d9d9ff3d7074ad761bf) by desbma) 233 | - Add pre-commit hooks ([15df8ba](https://github.com/desbma/shh/commit/15df8ba7564b1ad879e3f33487c59827a786fd84) by desbma) 234 | 235 | --- 236 | 237 | ## v2024.11.23 238 | 239 | ### 💡 Features 240 | 241 | - Support for CapabilityBoundingSet systemd option (WIP) ([8f6a472](https://github.com/desbma/shh/commit/8f6a4725ac322a85b38e417f45c4b6bb2f216b34) by desbma) 242 | - Cl goodies ([57fbeb5](https://github.com/desbma/shh/commit/57fbeb52b4ddc0f41a3b6bd44357135c68367e10) by desbma) 243 | - Support CAP_BLOCK_SUSPEND capability ([8e0530c](https://github.com/desbma/shh/commit/8e0530c558aaed20479c9c2591794446e467831f) by desbma) 244 | - Support CAP_BPF capability ([62bb876](https://github.com/desbma/shh/commit/62bb8762a20688ad0c11d8a6c601363c0422c739) by desbma) 245 | - Support CAP_SYS_CHROOT capability ([ca7ab16](https://github.com/desbma/shh/commit/ca7ab16bbb297d38b16805fe5153e60a6cc57079) by desbma) 246 | - Support CAP_NET_RAW capability ([47f333a](https://github.com/desbma/shh/commit/47f333a9bd4eb9fd724773eb66d827d8cfdd49bd) by desbma) 247 | - Support CAP_SYS_TIME capability ([8f47d34](https://github.com/desbma/shh/commit/8f47d347369fc0fe260c167439921c4b46d97c5c) by desbma) 248 | - Support CAP_PERFMON capability ([e717bdd](https://github.com/desbma/shh/commit/e717bdd137e88efb0e3d48926dd5805829e261aa) by desbma) 249 | - Support CAP_SYS_PTRACE capability ([f46a220](https://github.com/desbma/shh/commit/f46a2206e6811830b64d350ceda130dd1d522cd8) by desbma) 250 | - Support CAP_SYSLOG capability ([9c5f65f](https://github.com/desbma/shh/commit/9c5f65f979d975c300db7c14033d66b93281c59d) by desbma) 251 | - Support CAP_MKNOD capability ([169536e](https://github.com/desbma/shh/commit/169536e42977115afa3f931b1a387fc25069a510) by desbma) 252 | - Support CAP_SYS_TTY_CONFIG capability ([b348788](https://github.com/desbma/shh/commit/b3487883532c2f15d4c5c77ab702360ad436c327) by desbma) 253 | - Support CAP_WAKE_ALARM capability ([94082a0](https://github.com/desbma/shh/commit/94082a0c2bb0f009db0ef1e545a3bbf1d6baac4a) by desbma) 254 | - Support negative sets ([baeea83](https://github.com/desbma/shh/commit/baeea830eae03062be7f664fa6cd42ca25ce37fe) by desbma) 255 | - Changeable effects ([fc69691](https://github.com/desbma/shh/commit/fc6969181148176a3f3876ee66e251ed3d135975) by desbma-s1n) 256 | - Add network firewalling option ([4722239](https://github.com/desbma/shh/commit/4722239c8fb3c68b8a05dcb3006d47e12321dd7a) by desbma) 257 | 258 | ### 🐛 Bug fixes 259 | 260 | - Force StandardOutput=journal when profiling ([852b37c](https://github.com/desbma/shh/commit/852b37cd8ecb70ed792d321b695b9b2c656fae59) by desbma) 261 | - Comment typo ([04b1887](https://github.com/desbma/shh/commit/04b1887084c543df07af8c348bb90e129aaa8341) by desbma) 262 | - Comment typo ([63770db](https://github.com/desbma/shh/commit/63770dbb3817f54cd8e43d75f4a22c9e42f3cd2d) by desbma-s1n) 263 | 264 | ### 📗 Documentation 265 | 266 | - README: Minor clarification ([fb5c6af](https://github.com/desbma/shh/commit/fb5c6af1d145d7bc4f48629567ce6cd6f9133f29) by desbma) 267 | - Add comments ([d91cd42](https://github.com/desbma/shh/commit/d91cd4207551ac4a96e8804da966708b4922ab89) by desbma) 268 | - Add option model comment ([4cc41a9](https://github.com/desbma/shh/commit/4cc41a98fec31244d49abe1968a525be6bd42fe8) by desbma) 269 | - Update capabilities TODOs ([0dc33c0](https://github.com/desbma/shh/commit/0dc33c05c4ee1e7cc98792c6bb62a11572125b63) by desbma) 270 | - Add autogenerated list of supported systemd options ([9ea16cb](https://github.com/desbma/shh/commit/9ea16cba73908a4ead02d7d07f4f55aed7423a92) by desbma) 271 | 272 | ### 🧪 Testing 273 | 274 | - Add CapabilityBoundingSet integration tests ([a98859a](https://github.com/desbma/shh/commit/a98859a2e0be45725627206697ef10d1a50992b5) by desbma-s1n) 275 | 276 | ### 🚜 Refactor 277 | 278 | - peg: Match on rules instead of tags ([cb97a99](https://github.com/desbma/shh/commit/cb97a998e1d70f53c48f89050c8ce2f458f0b839) by desbma-s1n) 279 | - Effect/option types ([26c7f41](https://github.com/desbma/shh/commit/26c7f41977010492f85e74efd623b40a526f53ae) by desbma) 280 | - String -> & 'static str ([af995f0](https://github.com/desbma/shh/commit/af995f05218a44b0ee5abe62964d54ffa4abb2e9) by desbma) 281 | - Replace lazy_static by LazyLock ([192c8ad](https://github.com/desbma/shh/commit/192c8ad8f7d49a1fa75b8c777dd2ca564140be16) by desbma) 282 | - Use Option::transpose ([bc55cb1](https://github.com/desbma/shh/commit/bc55cb1581679628e1fb737a625e12cbca63a917) by desbma) 283 | 284 | ### 🧰 Miscellaneous tasks 285 | 286 | - Update release script ([c1b79db](https://github.com/desbma/shh/commit/c1b79db77758dec8a321b6dd62626766be9d89c1) by desbma) 287 | - Enable more lints ([7620b50](https://github.com/desbma/shh/commit/7620b50e13dbc1d864a3f7ce515d030c49310354) by desbma) 288 | - Update dependencies ([5c4454b](https://github.com/desbma/shh/commit/5c4454bc96eed23e307b774e992b788e143c5243) by desbma) 289 | 290 | --- 291 | 292 | ## v2024.6.4 293 | 294 | ### 💡 Features 295 | 296 | - Add error context if starting strace fails ([eb0bca2](https://github.com/desbma/shh/commit/eb0bca27f774a72a17e313a4b4f25e546ac363c2) by desbma-s1n) 297 | - Add PEG based Pest parser ([d0c570f](https://github.com/desbma/shh/commit/d0c570fecdc5bdb9456ea9a30d7b500c98c91a8b) by desbma-s1n) 298 | - Add optional strace log mirror output ([76f3c14](https://github.com/desbma/shh/commit/76f3c1474645e2e7522c26ede670012a94ec2136) by desbma-s1n) 299 | - Combinator based parser ([40086ae](https://github.com/desbma/shh/commit/40086ae63824351d7b67cedc191b3c9c3724fab0) by desbma-s1n) 300 | 301 | ### 🐛 Bug fixes 302 | 303 | - Handling of '+' prefixed ExecStart directives ([776b146](https://github.com/desbma/shh/commit/776b14624a27fd20f64bd879491f8344c6872887) by desbma) 304 | - Clippy false positive ([0ec360b](https://github.com/desbma/shh/commit/0ec360b36227bcd0570c372cc75b350eed7c3583) by desbma) 305 | 306 | ### 🏃 Performance 307 | 308 | - Add parse_line bench ([c57daee](https://github.com/desbma/shh/commit/c57daeead9e5d31f2ea7f97063102988f39073ae) by desbma-s1n) 309 | 310 | ### 🚜 Refactor 311 | 312 | - Improve incomplete syscall types + move handling out of parser ([ae3ea4f](https://github.com/desbma/shh/commit/ae3ea4fcfee7eca8a67d4e6a307553e01a0f5223) by desbma-s1n) 313 | - Remove legacy regex parser ([d43a9a0](https://github.com/desbma/shh/commit/d43a9a0a2c3eaccaa69cb356eebe7a0902ece4d4) by desbma-s1n) 314 | 315 | ### 🧰 Miscellaneous tasks 316 | 317 | - Merge imports ([bd6b6b5](https://github.com/desbma/shh/commit/bd6b6b57ff270c2ec1b6dc5d86d770d8a353b427) by desbma-s1n) 318 | 319 | --- 320 | 321 | ## v2024.4.5 322 | 323 | ### 💡 Features 324 | 325 | - Build deb with glibc ([09e6f66](https://github.com/desbma/shh/commit/09e6f66d8d35139350b0b442b1e3696a41560381) by desbma-s1n) 326 | 327 | ### 🐛 Bug fixes 328 | 329 | - Strace array parsing (fixes #3) ([be5dd32](https://github.com/desbma/shh/commit/be5dd32d188094822bca2394da0c2d53805d86b8) by desbma) 330 | - Parsing of multiline ExecStartXxx commands ([91d363c](https://github.com/desbma/shh/commit/91d363c1a5d66241daa4297ae46c88cdb138483a) by desbma-s1n) 331 | - Handling of required command line multiple arguments ([79ec626](https://github.com/desbma/shh/commit/79ec6267239e3b29d0576ca62f954c1cabfb60c6) by desbma-s1n) 332 | 333 | ### 📗 Documentation 334 | 335 | - Swap official/mirror repository roles ([a782302](https://github.com/desbma/shh/commit/a782302d18a55e2e549d0ef257ef1c84ebcec956) by desbma) 336 | 337 | ### 🧰 Miscellaneous tasks 338 | 339 | - More clippy lints ([734c090](https://github.com/desbma/shh/commit/734c0901f9156a1d995a6d4b60370e2aeeba6ece) by desbma) 340 | - Modeling -> model ([b0526b5](https://github.com/desbma/shh/commit/b0526b500757496a3829c262c19eb61666396f59) by desbma-s1n) 341 | 342 | --- 343 | 344 | ## v2023.12.16 345 | 346 | ### 🐛 Bug fixes 347 | 348 | - Stopping some services like nginx ([c80f428](https://github.com/desbma/shh/commit/c80f4280843dc5782b852dd620abb965b76a7aac) by desbma) 349 | - Don't wait on systemctl if we don't need to ([b08881d](https://github.com/desbma/shh/commit/b08881d1e8572ebf867aa298d446a51798f92c78) by desbma) 350 | 351 | --- 352 | 353 | ## v2023.12.9 354 | 355 | ### 💡 Features 356 | 357 | - Support services with multiple ExecStartPre/ExecStart/ExecStartPost directives ([30d15b5](https://github.com/desbma/shh/commit/30d15b5221f459f0d2a67041a926d521407e3841) by desbma) 358 | 359 | --- 360 | 361 | ## v2023.12.1 362 | 363 | ### 💡 Features 364 | 365 | - README: Add blogpost backlink ([bcb50af](https://github.com/desbma/shh/commit/bcb50afb3ece1ec6ec37c9fd05df8fee1d54ef7c) by desbma-s1n) 366 | - Parse strace version ([d4064c6](https://github.com/desbma/shh/commit/d4064c632152726a3ab02b7e057e10452b89a8e0) by desbma-s1n) 367 | 368 | ### 🐛 Bug fixes 369 | 370 | - Systemd rc version parsing ([5c8ec20](https://github.com/desbma/shh/commit/5c8ec204dcc726bb36b296e4d81b72d30857fd76) by desbma-s1n) 371 | 372 | ### 📗 Documentation 373 | 374 | - README: Add repo links ([d1d7102](https://github.com/desbma/shh/commit/d1d7102aff930d6d92765efcae4ae24a78514920) by desbma) 375 | - README: Add AUR link ([2881aa2](https://github.com/desbma/shh/commit/2881aa2859d40d84f97972335b8f1eb8b3d99613) by desbma) 376 | - README: Add badges ([e549755](https://github.com/desbma/shh/commit/e549755f2317e7e937c5ca3ee247756422cf8548) by desbma) 377 | 378 | --- 379 | 380 | ## v2023.10.26 381 | 382 | ### 🐛 Bug fixes 383 | 384 | - List of address families missing some chars ([75eba5f](https://github.com/desbma/shh/commit/75eba5f2d34ff165b968cf98d1bfd2f1075c0ffa) by desbma-s1n) 385 | 386 | --- 387 | 388 | ## v2023.10.19 389 | 390 | ### 🐛 Bug fixes 391 | 392 | - Work around inconsistent strace 5.10 output ([86e9d54](https://github.com/desbma/shh/commit/86e9d54953174d38ea63a9270d7248e93b944f74) by desbma-s1n) 393 | 394 | --- 395 | 396 | ## v2023.10.2 397 | 398 | ### 💡 Features 399 | 400 | - Support LockPersonality systemd option ([d46c422](https://github.com/desbma/shh/commit/d46c4221b4a42d575895d4b8c09879b00ec6e8f8) by desbma-s1n) 401 | - Support RestrictRealtime systemd option ([93e9efb](https://github.com/desbma/shh/commit/93e9efbc28fce9a9167d62331e38be7b7d42d0a5) by desbma-s1n) 402 | - Support ProtectClock systemd option ([f995ed2](https://github.com/desbma/shh/commit/f995ed28f67dd26bc8fc187f3d08aaeaa9f99267) by desbma-s1n) 403 | - Support SocketBindDeny systemd option ([4927217](https://github.com/desbma/shh/commit/492721769b44854b3345b4932147c46946dd3551) by desbma-s1n) 404 | 405 | ### 🐛 Bug fixes 406 | 407 | - Track socket protocols per process ([0b67312](https://github.com/desbma/shh/commit/0b6731261f8fedebbbc5beac2c18a61cb38bc546) by desbma-s1n) 408 | 409 | ### 🧪 Testing 410 | 411 | - Script to run integration tests as {user,root} and from /{home,tmp} ([0dfe73f](https://github.com/desbma/shh/commit/0dfe73feb4fa5b2971516f9ec75c6aed883ab085) by desbma-s1n) 412 | - Simplify dmesg test ([92cef27](https://github.com/desbma/shh/commit/92cef27df1ba6d6419e8fbe6fa127fa0339ead39) by desbma-s1n) 413 | 414 | --- 415 | 416 | ## v2023.9.27 417 | 418 | ### 💡 Features 419 | 420 | - Detect unsupported services and throw error ([c3cab7b](https://github.com/desbma/shh/commit/c3cab7b65017abe9f9ddd998dd7cdc046d3bef5c) by desbma-s1n) 421 | - Support RestrictAddressFamilies systemd option ([10d0dad](https://github.com/desbma/shh/commit/10d0dad0cc7327899391f0401fceaaf6dbe7b664) by desbma-s1n) 422 | - Support MemoryDenyWriteExecute systemd option ([3d0daf1](https://github.com/desbma/shh/commit/3d0daf1efa6916abf7d822e380548541cda47f77) by desbma-s1n) 423 | - Improve summary code to do a single hashmap search + support some more syscalls ([8dd0668](https://github.com/desbma/shh/commit/8dd06680ed0525fc12b89d12b3ca2147e78f53a0) by desbma-s1n) 424 | - Add optional aggressive mode + support PrivateNetwork systemd option ([1cdb462](https://github.com/desbma/shh/commit/1cdb4627d07ab1c838e83f4b1351b1678b764351) by desbma-s1n) 425 | - Support SystemCallArchitectures systemd option ([8f66c05](https://github.com/desbma/shh/commit/8f66c0586496849c5cb7a8ded04bf8541dc6c6e0) by desbma-s1n) 426 | - Return EPERM instead of killing with signal when denied syscall is called ([5aefc36](https://github.com/desbma/shh/commit/5aefc36d85d9eb47c360f572fbb68fe8f36853d5) by desbma-s1n) 427 | 428 | ### 🐛 Bug fixes 429 | 430 | - Recvmsg strace parsing ([b393dda](https://github.com/desbma/shh/commit/b393dda3d450bdd5e9bc481fda2317582c293fbd) by desbma-s1n) 431 | - Handling of systemd syscall classes containing classes ([f98d508](https://github.com/desbma/shh/commit/f98d508e0cbd995b91f1ed3342b664c6dd5e3359) by desbma) 432 | 433 | ### 🤖 Continuous integration 434 | 435 | - Initial GitHub actions config ([5695367](https://github.com/desbma/shh/commit/56953676d1464acb4799706c31f48c039ecabbe6) by desbma-s1n) 436 | - GitHub actions release workflow ([12d0212](https://github.com/desbma/shh/commit/12d02127c8ad7d8a87cf385870d87c1d7a8b433a) by desbma-s1n) 437 | 438 | ### 🧰 Miscellaneous tasks 439 | 440 | - Lint ([bc90525](https://github.com/desbma/shh/commit/bc90525d7c2c94bd9adef53de1a8695317f3647f) by desbma-s1n) 441 | - Add release script ([7997c99](https://github.com/desbma/shh/commit/7997c991e6983bd59ccbd2c47af60e1216f481c0) by desbma-s1n) 442 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "systemd-hardening-helper" 3 | authors = ["Maxime Desbrus "] 4 | description = "Automatic systemd service hardening guided by strace profiling" 5 | readme = "README.md" 6 | repository = "https://github.com/desbma/shh" 7 | keywords = ["systemd", "hardening", "strace", "system", "security"] 8 | categories = ["command-line-utilities"] 9 | license = "GPL-3.0-only" 10 | version = "2025.4.12" 11 | rust-version = "1.86" 12 | edition = "2024" 13 | 14 | [[bin]] 15 | name = "shh" 16 | path = "src/main.rs" 17 | 18 | [profile.release] 19 | lto = true 20 | codegen-units = 1 21 | strip = true 22 | 23 | [build-dependencies] 24 | const-gen = { version = "1.6.6", default-features = false, features = ["std", "phf"] } 25 | 26 | [dependencies] 27 | anyhow = { version = "1.0.97", default-features = false, features = ["std", "backtrace"] } 28 | bincode = { version = "2.0.1", default-features = false, features = ["std", "serde"] } 29 | clap = { version = "4.5.36", default-features = false, features = ["std", "color", "help", "usage", "error-context", "suggestions", "derive"] } 30 | clap_complete = { version = "4.5.48", default-features = false, optional = true } 31 | clap_mangen = { version = "0.2.26", default-features = false, optional = true } 32 | function_name = { version = "0.3.0", default-features = false } 33 | goblin = { version = "0.9.3", default-features = false, features = ["std", "elf32", "elf64", "endian_fd"] } 34 | itertools = { version = "0.14.0", default-features = false, features = ["use_std"] } 35 | log = { version = "0.4.27", default-features = false, features = ["max_level_trace", "release_max_level_info"] } 36 | nix = { version = "0.29.0", default-features = false, features = ["fs", "user"] } 37 | nom = { version = "8.0.0", default-features = false, features = ["std"] } 38 | path-clean = { version = "1.0.1", default-features = false } 39 | phf = { version = "0.11.3", default-features = false, features = ["std", "macros"] } 40 | rand = { version = "0.9.0", default-features = false, features = ["std", "thread_rng"] } 41 | regex = { version = "1.11.1", default-features = false, features = ["std", "perf"] } 42 | serde = { version = "1.0.219", default-features = false, features = ["std", "derive"] } 43 | shlex = { version = "1.3.0", default-features = false, features = ["std"] } 44 | signal-hook = { version = "0.3.17", default-features = false, features = ["iterator"] } 45 | simple_logger = { version = "5.0.0", default-features = false, features = ["colors", "stderr"] } 46 | strum = { version = "0.27.1", default-features = false, features = ["std", "derive"] } 47 | tempfile = { version = "3.19.1", default-features = false } 48 | thiserror = { version = "2.0.12", default-features = false, features = ["std"] } 49 | 50 | [dev-dependencies] 51 | assert_cmd = { version = "2.0.16", default-features = false, features = ["color", "color-auto"] } 52 | fastrand = { version = "2.3.0", default-features = false, features = ["std"] } 53 | nix = { version = "0.29.0", default-features = false, features = ["user"] } 54 | predicates = { version = "3.1.3", default-features = false, features = ["color", "regex"] } 55 | pretty_assertions = { version = "1.4.1", default-features = false, features = ["std"] } 56 | 57 | [features] 58 | default = [] 59 | generate-extra = ["dep:clap_mangen", "dep:clap_complete"] 60 | int-tests-as-root = [] # for integration tests only 61 | int-tests-sd-user = [] # for integration tests only 62 | nightly = [] # for benchmarks only 63 | 64 | [lints.rust] 65 | # https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html 66 | explicit_outlives_requirements = "warn" 67 | missing_docs = "warn" 68 | non_ascii_idents = "deny" 69 | redundant-lifetimes = "warn" 70 | single-use-lifetimes = "warn" 71 | unit-bindings = "warn" 72 | unreachable_pub = "warn" 73 | # unused_crate_dependencies = "warn" 74 | unused-lifetimes = "warn" 75 | unused-qualifications = "warn" 76 | 77 | [lints.clippy] 78 | pedantic = { level = "warn", priority = -1 } 79 | unnecessary_debug_formatting = "allow" 80 | # below lints are from clippy::restriction, and assume clippy >= 1.86 81 | # https://rust-lang.github.io/rust-clippy/master/index.html#/?levels=allow&groups=restriction 82 | allow_attributes = "warn" 83 | as_pointer_underscore = "warn" 84 | clone_on_ref_ptr = "warn" 85 | dbg_macro = "warn" 86 | empty_enum_variants_with_brackets = "warn" 87 | expect_used = "warn" 88 | field_scoped_visibility_modifiers = "warn" 89 | fn_to_numeric_cast_any = "warn" 90 | if_then_some_else_none = "warn" 91 | impl_trait_in_params = "warn" 92 | indexing_slicing = "warn" 93 | infinite_loop = "warn" 94 | lossy_float_literal = "warn" 95 | map_with_unused_argument_over_ranges = "warn" 96 | # missing_docs_in_private_items = "warn" 97 | mixed_read_write_in_expression = "warn" 98 | module_name_repetitions = "warn" 99 | # multiple_inherent_impl = "warn" 100 | mutex_integer = "warn" 101 | needless_raw_strings = "warn" 102 | non_zero_suggestions = "warn" 103 | panic = "warn" 104 | pathbuf_init_then_push = "warn" 105 | precedence_bits = "warn" 106 | pub_without_shorthand = "warn" 107 | redundant_type_annotations = "warn" 108 | ref_patterns = "warn" 109 | renamed_function_params = "warn" 110 | rest_pat_in_fully_bound_structs = "warn" 111 | return_and_then = "warn" 112 | same_name_method = "warn" 113 | self_named_module_files = "warn" 114 | semicolon_inside_block = "warn" 115 | shadow_unrelated = "warn" 116 | str_to_string = "warn" 117 | string_slice = "warn" 118 | string_to_string = "warn" 119 | tests_outside_test_module = "warn" 120 | try_err = "warn" 121 | undocumented_unsafe_blocks = "warn" 122 | unnecessary_safety_comment = "warn" 123 | unnecessary_safety_doc = "warn" 124 | unneeded_field_pattern = "warn" 125 | unseparated_literal_suffix = "warn" 126 | unused_result_ok = "warn" 127 | unused_trait_names = "warn" 128 | unwrap_used = "warn" 129 | verbose_file_reads = "warn" 130 | 131 | [package.metadata.deb] 132 | name = "shh" 133 | depends = "$auto, strace" 134 | assets = [ 135 | ["target/release/shh", "usr/bin/", "755"], 136 | ["target/man/*.1.gz", "usr/share/man/man1/", "644"], 137 | ["target/shellcomplete/*.bash", "/usr/share/bash-completion/completions/", "644"], 138 | ["target/shellcomplete/*.fish", "/usr/share/fish/vendor_completions.d/", "644"], 139 | ["target/shellcomplete/_shh", "/usr/share/zsh/vendor-completions/", "644"] 140 | ] 141 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## How does `shh` work? 4 | 5 | See [here](https://www.synacktiv.com/publications/systemd-hardening-made-easy-with-shh) for a high level introduction. 6 | 7 | ## How secure is the hardening produced by SHH? 8 | 9 | It relies on systemd to apply service unit options, and the Linux kernel to enforce them. 10 | For a hardening setting to be circumvented or broken, a vulnerability would have to be exploited in either of those. 11 | 12 | ## Why use this instead of AppArmor/firejail/[other solution]? 13 | 14 | The main advantage of using the systemd based hardening that SHH provides is its ubiquity. 15 | Systemd is everywhere, and you don't have to install or enable anything, or configure complex LSM with new permission model to benefit from per-service hardening. 16 | We believe this kind of hardening is vastly underused on most Linux systems, and that it is an easy way to raise the security level of a Linux system, which SHH makes accessible and convenient. 17 | 18 | Also most other solutions rely on predefined per application profiles. If your application is not well known, you'd have to write a profile yourself, which can be time consuming and error prone. If you happen to be lucky and find a predefined profile, you also need to stay in the "expected" actions set that the program does. If you do, everything is good, however for anything unusual that the profile authors had not forseen, this will break at runtime. SHH relies solely on runtime profiling so this works even if your program is niche or not public, and by construction the hardening options will be generated to be tailored specifically for the program to be hardened. 19 | 20 | ## Can't the over-restrictive hardening break the service for legitimate use cases? 21 | 22 | Breaking legitimate program flow because of too restrictive hardening is a common trap, which discourages many people and pushes them away from this approach. 23 | SHH was designed from personal experience, to eliminate these risks, and save time by eliminating manual guesses of the right level of hardening: 24 | 25 | - The hardening settings are generated only after runtime profiling. 26 | - When building the set of hardening options, SHH sets each setting only if there is no doubt it can be enabled without breaking the program actions. In some cases, it can't be 100% sure, and for those the option is not enabled. This means that sometimes, an option that could have been enabled will me missed, but an option that has any risk of breaking something will never be set (barring a bug in SHH of course). In short, when in doubt, SHH favours _under_ hardening, over _over_ hardening. 27 | 28 | ## I know the service to be hardened very well, can I tell SHH to harden further? 29 | 30 | By default, SHH apply a safe approach, however you can raise the security bar, with _a much more increased risk of breaking the service_, with theses options: 31 | 32 | - `--mode aggressive`: Will set some options which can break the service in very niche cases, however it should be safe for most classic services. 33 | - `--filesystem-whitelisting`: This option will basically build lists of paths accessed, and only allow access to those, by mounting read only or empty filesystems where it can. This is very powerful, but can only be done if the files accessed during profiling are representative of all future executions. For example for a file server, it will only allow access to files downloaded during profiling. You can also tweak the length of the path lists with the `--merge-paths-threshold` option. 34 | - `--network-firewalling`: Like the previous option, this will only allow network traffic to peers/addresses/ports observed during profiling. You most likely don't want to use this unless for a local-only service: for example any use of DNS with changing IP will be denied if the IP was not seen during profiling. 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SHH (Systemd Hardening Helper) 2 | 3 | [![CI status](https://github.com/desbma/shh/actions/workflows/ci.yml/badge.svg)](https://github.com/desbma/shh/actions) 4 | [![crates.io version](https://img.shields.io/crates/v/systemd-hardening-helper)](https://crates.io/crates/systemd-hardening-helper) 5 | [![AUR version](https://img.shields.io/aur/version/shh.svg?style=flat)](https://aur.archlinux.org/packages/shh/) 6 | [![License](https://img.shields.io/github/license/desbma/shh.svg?style=flat)](https://github.com/desbma/shh/blob/master/LICENSE) 7 | 8 | Automatic [systemd](https://systemd.io/) service hardening guided by [strace](https://strace.io/) profiling. 9 | 10 | [Official repository](https://github.com/desbma/shh) - [Mirror repository](https://github.com/synacktiv/shh) 11 | 12 | ## Documentation 13 | 14 | - High level introduction: [Systemd hardening made easy with SHH](https://www.synacktiv.com/publications/systemd-hardening-made-easy-with-shh) 15 | - [FAQ](FAQ.md) 16 | - [Changelog](CHANGELOG.md) 17 | - [Currently supported systemd options](systemd_options.md) 18 | 19 | ## Installation 20 | 21 | ### Dependencies 22 | 23 | Strace needs to be installed and its executable available in the path. A recent Strace version is strongly recommended. 24 | 25 | ### From source 26 | 27 | You need a Rust build environment for example from [rustup](https://rustup.rs/). 28 | 29 | Run in the current repository: 30 | 31 | ``` 32 | cargo build --release 33 | install -Dm 755 -t /usr/local/bin target/release/shh 34 | ``` 35 | 36 | ### From [`crates.io`](https://crates.io/) 37 | 38 | ``` 39 | sudo cargo install --root /usr/local 40 | ``` 41 | 42 | ### Debian (or Debian based distribution) 43 | 44 | See [GitHub releases](https://github.com/desbma/shh/releases) for Debian packages built for each tagged version. 45 | 46 | ### Arch Linux 47 | 48 | Arch Linux users can install the [shh AUR package](https://aur.archlinux.org/packages/shh). 49 | 50 | ## Usage 51 | 52 | ### Hardening a service 53 | 54 | To harden a system unit named `SERVICE.service`: 55 | 56 | 1. Start service profiling: `shh service start-profile SERVICE`. The service will be restarted with strace profiling. 57 | 2. Use the service normally for a while, trying to cover as much features and use cases as possible. 58 | 3. Run `shh service finish-profile SERVICE -a`. The service will be restarted with a hardened configuration built from previous runtime profiling, to allow it to run safely as was observed during the profiling period, and to deny other dangerous system actions. 59 | 60 | Run `shh -h` for full command line reference, or append `-h` to a subcommand to get help. 61 | 62 | > [!WARNING] 63 | > The hardening options generated by `shh` are by construction **not** portable across different systems. 64 | > They depend on many factors, and may break the service if any of those change: 65 | > 66 | > - the code path covered during profiling 67 | > - the Linux kernel version 68 | > - the libc used 69 | > - the systemd version 70 | > 71 | > Reusing options generated by `shh` on a system with a different environment (ie. different Linux distribution) is very likely to break the service. 72 | 73 | ### Testing locally 74 | 75 | If you want to run a quick test to see what options would be generated, you can use `shh run -- COMMAND`. 76 | 77 | Current directory and `PATH` environment variable both influence the program execution, reset those first: 78 | 79 | ``` 80 | $ cd / 81 | export PATH=/usr/local/bin:/usr/bin:/bin 82 | ``` 83 | 84 | Then to see what options would be generated for a `curl https://www.example.com` invocation: 85 | 86 | ``` 87 | $ shh run -- curl https://www.example.com 88 | ... 89 | -------- Start of suggested service options -------- 90 | ProtectSystem=strict 91 | ProtectHome=true 92 | PrivateTmp=disconnected 93 | PrivateDevices=true 94 | ProtectKernelTunables=true 95 | ProtectKernelModules=true 96 | ProtectKernelLogs=true 97 | ProtectControlGroups=true 98 | ProtectProc=ptraceable 99 | LockPersonality=true 100 | RestrictRealtime=true 101 | ProtectClock=true 102 | MemoryDenyWriteExecute=true 103 | RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_UNIX 104 | SocketBindDeny=ipv4:tcp 105 | SocketBindDeny=ipv4:udp 106 | SocketBindDeny=ipv6:tcp 107 | SocketBindDeny=ipv6:udp 108 | CapabilityBoundingSet=~CAP_BLOCK_SUSPEND CAP_BPF CAP_CHOWN CAP_MKNOD CAP_NET_RAW CAP_PERFMON CAP_SYS_BOOT CAP_SYS_CHROOT CAP_SYS_MODULE CAP_SYS_NICE CAP_SYS_PACCT CAP_SYS_PTRACE CAP_SYS_TIME CAP_SYSLOG CAP_WAKE_ALARM 109 | SystemCallFilter=~@aio:EPERM @chown:EPERM @clock:EPERM @cpu-emulation:EPERM @debug:EPERM @ipc:EPERM @keyring:EPERM @memlock:EPERM @module:EPERM @mount:EPERM @obsolete:EPERM @pkey:EPERM @privileged:EPERM @raw-io:EPERM @reboot:EPERM @resources:EPERM @sandbox:EPERM @setuid:EPERM @swap:EPERM @sync:EPERM @timer:EPERM 110 | -------- End of suggested service options -------- 111 | ``` 112 | 113 | Or to sandbox as much as possible: 114 | 115 | ``` 116 | $ shh run --mode aggressive --filesystem-whitelisting --network-firewalling -- curl https://www.example.com -o /dev/null 117 | ... 118 | -------- Start of suggested service options -------- 119 | ProtectSystem=strict 120 | ProtectHome=true 121 | PrivateTmp=disconnected 122 | PrivateDevices=true 123 | ProtectKernelTunables=true 124 | ProtectKernelModules=true 125 | ProtectKernelLogs=true 126 | ProtectControlGroups=true 127 | ProtectProc=ptraceable 128 | LockPersonality=true 129 | RestrictRealtime=true 130 | ProtectClock=true 131 | MemoryDenyWriteExecute=true 132 | SystemCallArchitectures=native 133 | ReadOnlyPaths=-/ 134 | ReadWritePaths=-/dev 135 | InaccessiblePaths=-/boot -/home -/lost+found -/media -/mnt -/opt -/root -/srv -/sys -/tmp -/var 136 | TemporaryFileSystem=/usr:ro 137 | BindReadOnlyPaths=-/usr/bin -/usr/lib -/usr/lib64 -/usr/local -/usr/share 138 | NoExecPaths=-/ 139 | ExecPaths=-/usr/bin/curl -/usr/lib/x86_64-linux-gnu 140 | RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_UNIX 141 | SocketBindDeny=ipv4:tcp 142 | SocketBindDeny=ipv4:udp 143 | SocketBindDeny=ipv6:tcp 144 | SocketBindDeny=ipv6:udp 145 | IPAddressDeny=any 146 | IPAddressAllow=[redacted] 147 | CapabilityBoundingSet=~CAP_BLOCK_SUSPEND CAP_BPF CAP_CHOWN CAP_MKNOD CAP_NET_RAW CAP_PERFMON CAP_SYS_BOOT CAP_SYS_CHROOT CAP_SYS_MODULE CAP_SYS_NICE CAP_SYS_PACCT CAP_SYS_PTRACE CAP_SYS_TIME CAP_SYSLOG CAP_WAKE_ALARM 148 | SystemCallFilter=~@aio:EPERM @chown:EPERM @clock:EPERM @cpu-emulation:EPERM @debug:EPERM @ipc:EPERM @keyring:EPERM @memlock:EPERM @module:EPERM @mount:EPERM @obsolete:EPERM @pkey:EPERM @privileged:EPERM @raw-io:EPERM @reboot:EPERM @resources:EPERM @sandbox:EPERM @setuid:EPERM @swap:EPERM @sync:EPERM @timer:EPERM 149 | -------- End of suggested service options -------- 150 | ``` 151 | 152 | ## License 153 | 154 | [GPLv3](https://www.gnu.org/licenses/gpl-3.0-standalone.html) 155 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | //! Build script to generate syscall map 2 | 3 | #![expect(clippy::unwrap_used)] 4 | 5 | use std::{ 6 | collections::{HashMap, HashSet}, 7 | env, fs, 8 | io::BufRead as _, 9 | path::Path, 10 | process::{Command, Stdio}, 11 | }; 12 | 13 | use const_gen::{CompileConst as _, const_declaration}; 14 | 15 | fn is_syscall_line(l: &str) -> bool { 16 | l.starts_with(" ") && !l.starts_with(" # ") 17 | } 18 | 19 | /// Ignored classes it would make no sense to backlist 20 | const IGNORED_CLASSES: [&str; 3] = ["default", "known", "system-service"]; 21 | 22 | fn main() { 23 | // Run systemd-analyze to get syscall list & groups 24 | let output = Command::new("systemd-analyze") 25 | .arg("syscall-filter") 26 | .env("LANG", "C") 27 | .stdin(Stdio::null()) 28 | .stdout(Stdio::piped()) 29 | .stderr(Stdio::null()) 30 | .output() 31 | .unwrap(); 32 | assert!(output.status.success()); 33 | 34 | // Parse output 35 | let mut classes: HashMap> = HashMap::new(); 36 | let mut lines: Box> = 37 | Box::new(output.stdout.lines().map(Result::unwrap)); 38 | loop { 39 | // Get class name 40 | lines = Box::new(lines.skip_while(|l| !l.starts_with('@'))); 41 | let Some(class_name) = lines 42 | .next() 43 | .and_then(|g| g.strip_prefix('@').map(ToOwned::to_owned)) 44 | else { 45 | break; 46 | }; 47 | if IGNORED_CLASSES.contains(&class_name.as_str()) { 48 | continue; 49 | } 50 | 51 | // Get syscalls names 52 | lines = Box::new(lines.skip_while(|l| !is_syscall_line(l))); 53 | let mut group_syscalls = HashSet::new(); 54 | for line in lines.by_ref() { 55 | if is_syscall_line(&line) { 56 | group_syscalls.insert(line.trim_start().to_owned()); 57 | } else { 58 | break; 59 | } 60 | } 61 | classes.insert(class_name, group_syscalls); 62 | } 63 | 64 | // Write generated code 65 | let out_dir = env::var_os("OUT_DIR").unwrap(); 66 | let dest_path = Path::new(&out_dir).join("systemd_syscall_groups.rs"); 67 | let const_declarations = const_declaration!(SYSCALL_CLASSES = classes); 68 | fs::write(&dest_path, const_declarations).unwrap(); 69 | } 70 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # https://git-cliff.org/docs/configuration 2 | 3 | [changelog] 4 | # template for the changelog header 5 | header = "# Changelog" 6 | # template for the changelog body 7 | # https://keats.github.io/tera/docs/#introduction 8 | body = """ 9 | 10 | {% if version %}\ 11 | ## {{ version }} 12 | {% else %}\ 13 | ## Unreleased 14 | {% endif %}\ 15 | {% for group, commits in commits | group_by(attribute="group") %} 16 | ### {{ group | upper_first }} 17 | {% for commit in commits %} 18 | - {% if commit.scope %}{{ commit.scope }}: {% endif %}\ 19 | {{ commit.message | split(pat="\n") | first | upper_first | trim }} \ 20 | ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/desbma/shh/commit/{{ commit.id }}) by {{ commit.author.name }})\ 21 | {% endfor %} 22 | {% endfor %} 23 | --- 24 | 25 | """ 26 | # template for the changelog footer 27 | footer = "" 28 | # remove the leading and trailing whitespace from the templates 29 | trim = true 30 | 31 | [git] 32 | # parse the commits based on https://www.conventionalcommits.org 33 | conventional_commits = true 34 | # filter out the commits that are not conventional 35 | filter_unconventional = false 36 | # regex for parsing and grouping commits 37 | commit_parsers = [ 38 | { message = "^feat", group = "💡 Features" }, 39 | { message = "^fix", group = "🐛 Bug fixes" }, 40 | { message = "^perf", group = "🏃 Performance" }, 41 | { message = "^doc", group = "📗 Documentation" }, 42 | { message = "^test", group = "🧪 Testing" }, 43 | { message = "^refactor", group = "🚜 Refactor" }, 44 | { message = "^style", group = "🎨 Styling" }, 45 | { message = "^build", group = "🏗 Build" }, 46 | { message = "^ci", group = "🤖 Continuous integration" }, 47 | { message = "^chore: version ", skip = true }, 48 | { message = "^chore", group = "🧰 Miscellaneous tasks" }, 49 | { message = "^revert", group = "🧰 Miscellaneous tasks", default_scope = "revert" }, 50 | { body = ".*security", group = "🛡️ Security" }, 51 | ] 52 | # filter out the commits that are not matched by commit parsers 53 | filter_commits = false 54 | # sort the tags topologically 55 | topo_order = false 56 | # sort the commits inside sections by oldest/newest order 57 | sort_commits = "oldest" 58 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | allow-expect-in-tests = true 2 | allow-indexing-slicing-in-tests = true 3 | allow-panic-in-tests = true 4 | allow-unwrap-in-tests = true 5 | avoid-breaking-exported-api = false 6 | enum-variant-size-threshold = 64 7 | too-many-lines-threshold = 250 8 | -------------------------------------------------------------------------------- /release: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | 3 | set -o pipefail 4 | 5 | readonly VERSION="${1:-$(date +%Y.%-m.%-d)}" 6 | readonly IS_JUJUTSU="$(if jj root &> /dev/null; then echo 1; else echo 0; fi)" 7 | 8 | cd "$(git rev-parse --show-toplevel)" 9 | 10 | if [ "${IS_JUJUTSU}" -eq 1 ] 11 | then 12 | jj new -m "chore: version ${VERSION}" 13 | fi 14 | 15 | cargo set-version "${VERSION}" 16 | 17 | cargo upgrade 18 | cargo update 19 | 20 | cargo check 21 | cargo test --bins 22 | 23 | RUST_LOG=warn cargo run -- list-systemd-options | head -n -1 > systemd_options.md 24 | 25 | if [ "${IS_JUJUTSU}" -eq 0 ] 26 | then 27 | git add Cargo.{toml,lock} systemd_options.md 28 | 29 | git commit -m "chore: version ${VERSION}" 30 | fi 31 | 32 | if [ "${IS_JUJUTSU}" -eq 1 ] 33 | then 34 | jj new 35 | fi 36 | 37 | git tag -f -m "Version ${VERSION}" "v${VERSION}" 38 | git cliff | head -n -3 > CHANGELOG.md 39 | git tag -d "v${VERSION}" 40 | 41 | if [ "${IS_JUJUTSU}" -eq 1 ] 42 | then 43 | jj squash -u 44 | else 45 | git add CHANGELOG.md 46 | git commit --amend --no-edit 47 | fi 48 | 49 | git tag -f -m "Version ${VERSION}" "v${VERSION}" 50 | -------------------------------------------------------------------------------- /src/cl.rs: -------------------------------------------------------------------------------- 1 | //! Command line interface 2 | 3 | use std::{num::NonZeroUsize, path::PathBuf}; 4 | 5 | use clap::Parser; 6 | 7 | use crate::systemd; 8 | 9 | /// Command line arguments 10 | #[derive(Parser, Debug)] 11 | #[command(version, about)] 12 | pub(crate) struct Args { 13 | #[command(subcommand)] 14 | pub action: Action, 15 | } 16 | 17 | /// How hard we should harden 18 | #[derive(Debug, Clone, Default, clap::ValueEnum, strum::Display)] 19 | #[strum(serialize_all = "snake_case")] 20 | pub(crate) enum HardeningMode { 21 | /// Only generate hardening options if they have a very low risk of breaking things 22 | #[default] 23 | Safe, 24 | /// Will harden further and prevent circumventing restrictions of some options, but may increase the risk of 25 | /// breaking services 26 | Aggressive, 27 | } 28 | 29 | #[derive(Debug, clap::Parser)] 30 | pub(crate) struct HardeningOptions { 31 | /// How hard we should harden 32 | #[arg(short, long, default_value_t, value_enum)] 33 | pub mode: HardeningMode, 34 | /// Enable advanced network firewalling. 35 | /// Only use this if you know that the network addresses and ports of 36 | /// local system and remote peers will not change 37 | #[arg(short = 'f', long, default_value_t)] 38 | pub network_firewalling: bool, 39 | /// Enable whitelist-based filesystem hardening. 40 | /// Only use this if you know that the paths accessed by the service will not 41 | /// change 42 | #[arg(short = 'w', long, default_value_t)] 43 | pub filesystem_whitelisting: bool, 44 | /// When using whitelist-based filesystem hardening, if path whitelist is longer than this value, 45 | /// try to merge paths with the same parent 46 | #[arg(long, default_value = "5")] 47 | pub merge_paths_threshold: NonZeroUsize, 48 | /// Disable all systemd options except these (case sensitive). 49 | /// Other options may be generated when mutating these options to make them compatible with profiling data. 50 | /// For testing only 51 | #[arg(long, num_args=1..)] 52 | pub systemd_options: Option>, 53 | } 54 | 55 | impl HardeningOptions { 56 | /// Build the most safe options 57 | #[cfg_attr(not(test), expect(dead_code))] 58 | pub(crate) fn safe() -> Self { 59 | Self { 60 | mode: HardeningMode::Safe, 61 | network_firewalling: false, 62 | filesystem_whitelisting: false, 63 | #[expect(clippy::unwrap_used)] 64 | merge_paths_threshold: NonZeroUsize::new(1).unwrap(), 65 | systemd_options: None, 66 | } 67 | } 68 | 69 | /// Build the most strict options 70 | pub(crate) fn strict() -> Self { 71 | Self { 72 | mode: HardeningMode::Aggressive, 73 | network_firewalling: true, 74 | filesystem_whitelisting: true, 75 | #[expect(clippy::unwrap_used)] 76 | merge_paths_threshold: NonZeroUsize::new(usize::MAX).unwrap(), 77 | systemd_options: None, 78 | } 79 | } 80 | 81 | pub(crate) fn to_cmd_args(&self) -> Vec { 82 | let mut args = vec!["-m".to_owned(), self.mode.to_string()]; 83 | if self.network_firewalling { 84 | args.push("-f".to_owned()); 85 | } 86 | if self.filesystem_whitelisting { 87 | args.push("-w".to_owned()); 88 | } 89 | args.extend([ 90 | "--merge-paths-threshold".to_owned(), 91 | self.merge_paths_threshold.to_string(), 92 | ]); 93 | args 94 | } 95 | } 96 | 97 | #[derive(Debug, clap::Subcommand)] 98 | pub(crate) enum Action { 99 | /// Run a program to profile its behavior 100 | Run { 101 | /// The command line to run 102 | #[arg(num_args = 1.., required = true)] 103 | command: Vec, 104 | #[command(flatten)] 105 | instance: ServiceInstance, 106 | #[command(flatten)] 107 | hardening_opts: HardeningOptions, 108 | /// Generate profile data file to be merged with others instead of generating systemd options directly 109 | #[arg(short, long, default_value = None)] 110 | profile_data_path: Option, 111 | /// Log strace output to this file. 112 | /// Only use for debugging: this will slow down processing, and may generate a huge file. 113 | #[arg(short = 'l', long, default_value = None)] 114 | strace_log_path: Option, 115 | }, 116 | /// Merge profile data from previous runs to generate systemd options 117 | MergeProfileData { 118 | #[command(flatten)] 119 | instance: ServiceInstance, 120 | #[command(flatten)] 121 | hardening_opts: HardeningOptions, 122 | /// Profile data paths 123 | #[arg(num_args = 1.., required = true)] 124 | paths: Vec, 125 | }, 126 | /// Act on a systemd service unit 127 | #[clap(subcommand)] 128 | Service(ServiceAction), 129 | /// Dump markdown formatted list of supported systemd options 130 | ListSystemdOptions, 131 | /// Generate man pages 132 | #[cfg(feature = "generate-extra")] 133 | GenManPages { 134 | /// Target directory (must exist) 135 | dir: PathBuf, 136 | }, 137 | /// Generate shell completion 138 | #[cfg(feature = "generate-extra")] 139 | #[group(required = true, multiple = true)] 140 | GenShellComplete { 141 | /// Shell to generate for, leave empty for all 142 | #[arg(short = 's', long, default_value = None)] 143 | shell: Option, 144 | /// Target directory, leave empty to write to standard output 145 | dir: Option, 146 | }, 147 | } 148 | 149 | #[derive(Debug, clap::Parser)] 150 | pub(crate) struct Service { 151 | /// Service unit name 152 | pub name: String, 153 | #[command(flatten)] 154 | pub instance: ServiceInstance, 155 | } 156 | 157 | #[derive(Debug, clap::Parser)] 158 | pub(crate) struct ServiceInstance { 159 | /// Systemd instance of the service ("system" or "user" for per-user instances of the service manager) 160 | #[arg(short, long, default_value_t, value_enum)] 161 | pub instance: systemd::InstanceKind, 162 | } 163 | 164 | #[derive(Debug, clap::Subcommand)] 165 | pub(crate) enum ServiceAction { 166 | /// Add fragment config to service to profile its behavior 167 | StartProfile { 168 | #[command(flatten)] 169 | service: Service, 170 | #[command(flatten)] 171 | hardening_opts: HardeningOptions, 172 | /// Disable immediate service restart 173 | #[arg(short, long, default_value_t = false)] 174 | no_restart: bool, 175 | }, 176 | /// Get profiling result and remove fragment config from service 177 | FinishProfile { 178 | #[command(flatten)] 179 | service: Service, 180 | /// Automatically apply hardening config 181 | #[arg(short, long, default_value_t = false)] 182 | apply: bool, 183 | /// Edit generated options before applying them 184 | #[arg(short, long, default_value_t = false)] 185 | edit: bool, 186 | /// Disable immediate service restart 187 | #[arg(short, long, default_value_t = false)] 188 | no_restart: bool, 189 | }, 190 | /// Remove profiling and/or hardening config fragments, and restart service to restore its initial state 191 | Reset { 192 | #[command(flatten)] 193 | service: Service, 194 | }, 195 | } 196 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! Systemd Hardening Helper 2 | 3 | #![cfg_attr(all(feature = "nightly", test), feature(test))] 4 | 5 | use std::{ 6 | env, 7 | fs::{self, File}, 8 | io, 9 | path::Path, 10 | process::Command, 11 | thread, 12 | }; 13 | 14 | use anyhow::Context as _; 15 | use clap::Parser as _; 16 | 17 | mod cl; 18 | mod strace; 19 | mod summarize; 20 | mod sysctl; 21 | mod systemd; 22 | 23 | fn sd_options( 24 | sd_version: &systemd::SystemdVersion, 25 | kernel_version: &systemd::KernelVersion, 26 | sysctl_state: &sysctl::State, 27 | instance_kind: &systemd::InstanceKind, 28 | hardening_opts: &cl::HardeningOptions, 29 | ) -> Vec { 30 | let sd_opts = systemd::build_options( 31 | sd_version, 32 | kernel_version, 33 | sysctl_state, 34 | instance_kind, 35 | hardening_opts, 36 | ); 37 | log::info!( 38 | "Enabled support for systemd options: {}", 39 | sd_opts 40 | .iter() 41 | .map(ToString::to_string) 42 | .collect::>() 43 | .join(", ") 44 | ); 45 | sd_opts 46 | } 47 | 48 | fn edit_file(path: &Path) -> anyhow::Result<()> { 49 | let editor = env::var("VISUAL") 50 | .or_else(|_| env::var("EDITOR")) 51 | .unwrap_or_else(|_| { 52 | log::warn!("Neither VISUAL or EDITOR environment variable is set, defaulting to nano"); 53 | "nano".into() 54 | }); 55 | let editor_args = shlex::split(&editor) 56 | .ok_or_else(|| anyhow::anyhow!("Unable to parse environment variable value {editor:?}"))?; 57 | let (first_arg, other_args) = editor_args 58 | .split_first() 59 | .ok_or_else(|| anyhow::anyhow!("Empty editor environment variable value"))?; 60 | let status = Command::new(first_arg) 61 | .args(other_args) 62 | .arg(path) 63 | .status()?; 64 | if !status.success() { 65 | anyhow::bail!("Editor failed with status {}", status); 66 | } 67 | Ok(()) 68 | } 69 | 70 | fn main() -> anyhow::Result<()> { 71 | // Init logger 72 | simple_logger::SimpleLogger::new() 73 | .with_level(if cfg!(debug_assertions) { 74 | log::LevelFilter::Debug 75 | } else { 76 | log::LevelFilter::Info 77 | }) 78 | .env() 79 | .init() 80 | .context("Failed to setup logger")?; 81 | 82 | // Get versions 83 | let sd_version = 84 | systemd::SystemdVersion::local_system().context("Failed to get systemd version")?; 85 | let kernel_version = 86 | systemd::KernelVersion::local_system().context("Failed to get Linux kernel version")?; 87 | let strace_version = 88 | strace::StraceVersion::local_system().context("Failed to get strace version")?; 89 | log::info!( 90 | "Detected versions: Systemd {sd_version}, Linux kernel {kernel_version}, strace {strace_version}" 91 | ); 92 | if strace_version < strace::StraceVersion::new(6, 4) { 93 | log::warn!( 94 | "Strace version >=6.4 is strongly recommended, if you experience strace output parsing errors, please consider upgrading" 95 | ); 96 | } 97 | 98 | // Parse cl args 99 | let args = cl::Args::parse(); 100 | 101 | // Handle CL args 102 | match args.action { 103 | cl::Action::Run { 104 | command, 105 | instance, 106 | hardening_opts, 107 | profile_data_path, 108 | strace_log_path, 109 | } => { 110 | // Build supported systemd options 111 | let sysctl_state = sysctl::State::fetch()?; 112 | let sd_opts = sd_options( 113 | &sd_version, 114 | &kernel_version, 115 | &sysctl_state, 116 | &instance.instance, 117 | &hardening_opts, 118 | ); 119 | 120 | // Run strace 121 | let cmd = command.iter().map(|a| &**a).collect::>(); 122 | let st = strace::Strace::run(&cmd, strace_log_path) 123 | .context("Failed to setup strace profiling")?; 124 | 125 | // Start signal handling thread 126 | let mut signals = signal_hook::iterator::Signals::new([ 127 | signal_hook::consts::signal::SIGINT, 128 | signal_hook::consts::signal::SIGQUIT, 129 | signal_hook::consts::signal::SIGTERM, 130 | ]) 131 | .context("Failed to setup signal handlers")?; 132 | thread::spawn(move || { 133 | for sig in signals.forever() { 134 | // The strace, and its watched child processes already get the signal, so the iterator will stop naturally 135 | log::info!("Got signal {sig:?}, ignoring"); 136 | } 137 | }); 138 | 139 | // Get & parse PATH env var 140 | let env_paths: Vec<_> = env::var_os("PATH") 141 | .map(|ev| env::split_paths(&ev).collect()) 142 | .unwrap_or_default(); 143 | 144 | // Summarize actions 145 | let logs = st 146 | .log_lines() 147 | .context("Failed to setup strace output reader")?; 148 | let actions = 149 | summarize::summarize(logs, &env_paths).context("Failed to summarize syscalls")?; 150 | log::debug!("{actions:?}"); 151 | 152 | if let Some(profile_data_path) = profile_data_path { 153 | // Dump profile data 154 | log::info!("Writing profile data into {profile_data_path:?}..."); 155 | let mut file = File::create(&profile_data_path) 156 | .with_context(|| format!("Failed to create {profile_data_path:?}"))?; 157 | bincode::serde::encode_into_std_write( 158 | &actions, 159 | &mut file, 160 | bincode::config::standard(), 161 | ) 162 | .context("Failed to serialize profile")?; 163 | } else { 164 | // Resolve 165 | let resolved_opts = systemd::resolve(&sd_opts, &actions, &hardening_opts); 166 | 167 | // Report 168 | systemd::report_options(resolved_opts); 169 | } 170 | } 171 | cl::Action::MergeProfileData { 172 | instance, 173 | hardening_opts, 174 | paths, 175 | } => { 176 | // Build supported systemd options 177 | let sysctl_state = sysctl::State::fetch()?; 178 | let sd_opts = sd_options( 179 | &sd_version, 180 | &kernel_version, 181 | &sysctl_state, 182 | &instance.instance, 183 | &hardening_opts, 184 | ); 185 | 186 | // Load and merge profile data 187 | let mut actions: Vec = Vec::new(); 188 | for path in &paths { 189 | let mut file = 190 | File::open(path).with_context(|| format!("Failed to open {path:?}"))?; 191 | let mut profile_actions: Vec = 192 | bincode::serde::decode_from_std_read(&mut file, bincode::config::standard()) 193 | .with_context(|| format!("Failed to deserialize profile from {path:?}"))?; 194 | actions.append(&mut profile_actions); 195 | } 196 | log::debug!("{actions:?}"); 197 | 198 | // Resolve 199 | let resolved_opts = systemd::resolve(&sd_opts, &actions, &hardening_opts); 200 | 201 | // Report 202 | systemd::report_options(resolved_opts); 203 | 204 | // Remove profile data files 205 | for path in paths { 206 | fs::remove_file(&path) 207 | .with_context(|| format!("Failed to remove profile file {path:?}"))?; 208 | } 209 | } 210 | cl::Action::Service(cl::ServiceAction::StartProfile { 211 | service, 212 | hardening_opts, 213 | no_restart, 214 | }) => { 215 | let service = systemd::Service::new(&service.name, service.instance.instance) 216 | .context("Invalid service name")?; 217 | log::info!( 218 | "Current service exposure level: {}", 219 | service 220 | .get_exposure_level() 221 | .context("Failed to get exposure level")? 222 | ); 223 | service 224 | .add_profile_fragment(&hardening_opts) 225 | .context("Failed to write systemd unit profiling fragment")?; 226 | if no_restart { 227 | log::warn!( 228 | "Profiling config will only be applied when systemd config is reloaded, and service restarted" 229 | ); 230 | } else { 231 | service 232 | .reload_unit_config() 233 | .context("Failed to reload systemd config")?; 234 | service 235 | .action("restart", false) 236 | .context("Failed to restart service")?; 237 | } 238 | } 239 | cl::Action::Service(cl::ServiceAction::FinishProfile { 240 | service, 241 | apply, 242 | edit, 243 | no_restart, 244 | }) => { 245 | let service = systemd::Service::new(&service.name, service.instance.instance) 246 | .context("Invalid service name")?; 247 | let cursor = systemd::JournalCursor::current()?; 248 | service 249 | .action("stop", true) 250 | .context("Failed to stop service")?; 251 | service 252 | .remove_profile_fragment() 253 | .context("Failed to remove systemd unit profiling fragment")?; 254 | let resolved_opts = service.profiling_result_retry(&cursor)?; 255 | log::info!( 256 | "Resolved systemd options:\n{}", 257 | resolved_opts 258 | .iter() 259 | .map(|o| format!("{o}")) 260 | .collect::>() 261 | .join("\n") 262 | ); 263 | if apply && !resolved_opts.is_empty() { 264 | let fragment_path = service 265 | .add_hardening_fragment(resolved_opts) 266 | .context("Failed to write systemd unit hardening fragment")?; 267 | if edit { 268 | edit_file(&fragment_path).with_context(|| { 269 | format!("Failed to edit geneted frament {fragment_path:?}") 270 | })?; 271 | } 272 | } 273 | service 274 | .reload_unit_config() 275 | .context("Failed to reload systemd config")?; 276 | if apply { 277 | log::info!( 278 | "New service exposure level: {}", 279 | service 280 | .get_exposure_level() 281 | .context("Failed to get exposure level")? 282 | ); 283 | } 284 | if !no_restart { 285 | service 286 | .action("start", false) 287 | .context("Failed to start service")?; 288 | } 289 | } 290 | cl::Action::Service(cl::ServiceAction::Reset { service }) => { 291 | let service = systemd::Service::new(&service.name, service.instance.instance)?; 292 | let _ = service.remove_profile_fragment(); 293 | let _ = service.remove_hardening_fragment(); 294 | service 295 | .reload_unit_config() 296 | .context("Failed to reload systemd config")?; 297 | let _ = service.action("try-restart", true); 298 | } 299 | cl::Action::ListSystemdOptions => { 300 | println!("# Supported systemd options\n"); 301 | let sysctl_state = sysctl::State::all(); 302 | let mut sd_opts = sd_options( 303 | &sd_version, 304 | &kernel_version, 305 | &sysctl_state, 306 | &systemd::InstanceKind::System, 307 | &cl::HardeningOptions::strict(), 308 | ); 309 | sd_opts.sort_unstable_by_key(|o| o.name); 310 | { 311 | let mut stdout = io::stdout().lock(); 312 | for sd_opt in sd_opts { 313 | sd_opt 314 | .write_markdown(&mut stdout) 315 | .context("Failed to write markdown output")?; 316 | } 317 | } 318 | } 319 | #[cfg(feature = "generate-extra")] 320 | cl::Action::GenManPages { dir } => { 321 | use clap::CommandFactory as _; 322 | 323 | // Use the binary name instead of the default of the package name 324 | let cmd = cl::Args::command().name(env!("CARGO_BIN_NAME")); 325 | clap_mangen::generate_to(cmd, &dir)?; 326 | } 327 | #[cfg(feature = "generate-extra")] 328 | cl::Action::GenShellComplete { shell, dir } => { 329 | use clap::CommandFactory as _; 330 | use clap::ValueEnum as _; 331 | use clap_complete::{Shell, generate, generate_to}; 332 | 333 | // Use the binary name instead of the default of the package name 334 | let name = env!("CARGO_BIN_NAME"); 335 | let mut cmd = cl::Args::command().name(name); 336 | 337 | if let Some(shell) = shell { 338 | if let Some(dir) = dir { 339 | generate_to(shell, &mut cmd, name, dir)?; 340 | } else { 341 | generate(shell, &mut cmd, name, &mut io::stdout()); 342 | } 343 | } else if let Some(dir) = dir { 344 | let shells = Shell::value_variants(); 345 | for shell_i in shells { 346 | generate_to(*shell_i, &mut cmd, name, &dir)?; 347 | } 348 | } 349 | } 350 | } 351 | 352 | Ok(()) 353 | } 354 | -------------------------------------------------------------------------------- /src/strace/mod.rs: -------------------------------------------------------------------------------- 1 | //! Strace related code 2 | 3 | use std::{collections::HashMap, fmt, io::BufRead as _, process::Command, str}; 4 | 5 | mod parser; 6 | mod run; 7 | 8 | pub(crate) use run::Strace; 9 | 10 | const STRACE_BIN: &str = if let Some(p) = option_env!("SHH_STRACE_BIN_PATH") { 11 | p 12 | } else { 13 | "strace" 14 | }; 15 | 16 | #[derive(Debug, Clone, PartialEq)] 17 | pub(crate) struct Syscall { 18 | pub pid: u32, 19 | pub rel_ts: f64, 20 | pub name: String, 21 | pub args: Vec, 22 | pub ret_val: IntegerExpression, 23 | } 24 | 25 | #[derive(Debug, Clone, PartialEq)] 26 | pub(crate) enum BufferType { 27 | AbstractPath, 28 | Unknown, 29 | } 30 | 31 | #[derive(Debug, Clone, PartialEq)] 32 | pub(crate) struct IntegerExpression { 33 | pub value: IntegerExpressionValue, 34 | pub metadata: Option>, 35 | } 36 | 37 | impl IntegerExpression { 38 | pub(crate) fn value(&self) -> Option { 39 | self.value.value() 40 | } 41 | } 42 | 43 | #[derive(Debug, Clone, PartialEq)] 44 | pub(crate) struct BufferExpression { 45 | pub value: Vec, 46 | pub type_: BufferType, 47 | } 48 | 49 | #[derive(Debug, Clone, PartialEq)] 50 | pub(crate) enum Expression { 51 | Buffer(BufferExpression), 52 | MacAddress([u8; 6]), 53 | Integer(IntegerExpression), 54 | Struct(HashMap), 55 | // The strace syntax can be ambiguous between array and set (ie sigset_t in sigprocmask), 56 | // so store both in this, and let the summary interpret 57 | Collection { 58 | complement: bool, 59 | // First element of tuple is index if explicitly set 60 | values: Vec<(Option, Expression)>, 61 | }, 62 | // Only used for strace pseudo macro invocations, see `test_macro_addr_arg` for an example 63 | DestinationAddress(String), 64 | } 65 | 66 | impl Expression { 67 | pub(crate) fn metadata(&self) -> Option<&[u8]> { 68 | match self { 69 | Self::Integer(IntegerExpression { metadata, .. }) => metadata.as_deref(), 70 | _ => None, 71 | } 72 | } 73 | } 74 | 75 | #[derive(Debug, Clone, PartialEq)] 76 | pub(crate) enum IntegerExpressionValue { 77 | BinaryOr(Vec), 78 | BooleanAnd(Vec), 79 | Equality(Vec), 80 | LeftBitShift { 81 | bits: Box, 82 | shift: Box, 83 | }, 84 | Literal(i128), // allows holding both signed and unsigned 64 bit integers 85 | Macro { 86 | name: String, 87 | args: Vec, 88 | }, 89 | Multiplication(Vec), 90 | NamedSymbol(String), 91 | Substraction(Vec), 92 | } 93 | 94 | impl IntegerExpressionValue { 95 | pub(crate) fn is_flag_set(&self, flag: &str) -> bool { 96 | match self { 97 | IntegerExpressionValue::NamedSymbol(v) => flag == v, 98 | IntegerExpressionValue::BinaryOr(ces) => ces.iter().any(|ce| ce.is_flag_set(flag)), 99 | _ => false, // if it was a flag field, strace would have decoded it with named consts 100 | } 101 | } 102 | 103 | pub(crate) fn flags(&self) -> Vec { 104 | match self { 105 | IntegerExpressionValue::NamedSymbol(v) => vec![v.clone()], 106 | IntegerExpressionValue::BinaryOr(vs) => { 107 | vs.iter().flat_map(IntegerExpressionValue::flags).collect() 108 | } 109 | _ => vec![], 110 | } 111 | } 112 | 113 | pub(crate) fn value(&self) -> Option { 114 | match self { 115 | IntegerExpressionValue::BinaryOr(values) => values 116 | .iter() 117 | .map(Self::value) 118 | .collect::>>()? 119 | .into_iter() 120 | .reduce(|a, b| a | b), 121 | IntegerExpressionValue::BooleanAnd(values) => values 122 | .iter() 123 | .map(Self::value) 124 | .collect::>>()? 125 | .into_iter() 126 | .reduce(|a, b| i128::from((a != 0) && (b != 0))), 127 | IntegerExpressionValue::Equality(values) => values 128 | .iter() 129 | .map(Self::value) 130 | .collect::>>()? 131 | .into_iter() 132 | .reduce(|a, b| i128::from(a == b)), 133 | IntegerExpressionValue::LeftBitShift { bits, shift } => { 134 | Some(bits.value()? << shift.value()?) 135 | } 136 | IntegerExpressionValue::Literal(v) => Some(*v), 137 | IntegerExpressionValue::NamedSymbol(_) | IntegerExpressionValue::Macro { .. } => None, 138 | IntegerExpressionValue::Multiplication(values) => values 139 | .iter() 140 | .map(Self::value) 141 | .collect::>>()? 142 | .into_iter() 143 | .reduce(|a, b| a * b), 144 | IntegerExpressionValue::Substraction(values) => values 145 | .iter() 146 | .map(Self::value) 147 | .collect::>>()? 148 | .into_iter() 149 | .reduce(|a, b| a - b), 150 | } 151 | } 152 | } 153 | 154 | #[derive(Ord, PartialOrd, Eq, PartialEq)] 155 | pub(crate) struct StraceVersion { 156 | pub major: u16, 157 | pub minor: u16, 158 | } 159 | 160 | impl StraceVersion { 161 | pub(crate) fn new(major: u16, minor: u16) -> Self { 162 | Self { major, minor } 163 | } 164 | 165 | pub(crate) fn local_system() -> anyhow::Result { 166 | let output = Command::new(STRACE_BIN).arg("--version").output()?; 167 | if !output.status.success() { 168 | anyhow::bail!("strace invocation failed with code {:?}", output.status); 169 | } 170 | let version_line = output 171 | .stdout 172 | .lines() 173 | .next() 174 | .ok_or_else(|| anyhow::anyhow!("Unable to get strace version"))??; 175 | let (major, minor) = version_line 176 | .rsplit_once(' ') 177 | .ok_or_else(|| anyhow::anyhow!("Unable to get strace version"))? 178 | .1 179 | .split_once('.') 180 | .ok_or_else(|| anyhow::anyhow!("Unable to get strace version"))?; 181 | Ok(Self { 182 | major: major.parse()?, 183 | minor: minor.parse()?, 184 | }) 185 | } 186 | } 187 | 188 | impl fmt::Display for StraceVersion { 189 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 190 | write!(f, "{}.{}", self.major, self.minor) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/strace/parser/combinator.rs: -------------------------------------------------------------------------------- 1 | //! Combinator based strace output parser 2 | 3 | use std::iter; 4 | 5 | use nom::{ 6 | IResult, Parser as _, 7 | branch::alt, 8 | bytes::complete::{tag, take, take_until}, 9 | character::complete::{ 10 | self, alpha1, alphanumeric1, char, digit1, hex_digit1, oct_digit1, space1, 11 | }, 12 | combinator::{map, map_opt, map_res, opt, recognize}, 13 | multi::{count, many_till, many0_count, separated_list0, separated_list1}, 14 | number::complete::double, 15 | sequence::{delimited, pair, preceded, separated_pair, terminated}, 16 | }; 17 | 18 | use super::ParseResult; 19 | use crate::strace::{ 20 | BufferExpression, BufferType, Expression, IntegerExpression, IntegerExpressionValue, Syscall, 21 | parser::{SyscallEnd, SyscallStart}, 22 | }; 23 | 24 | macro_rules! dbg_parser_entry { 25 | ($input:expr) => { 26 | log::trace!("{}:{}\ninput: {:?}", function_name!(), line!(), $input) 27 | }; 28 | } 29 | 30 | macro_rules! dbg_parser_success { 31 | ($output:expr) => { 32 | log::trace!("{}:{}\nparsed: {:?}", function_name!(), line!(), $output) 33 | }; 34 | } 35 | 36 | pub(crate) fn parse_line(line: &str) -> anyhow::Result { 37 | match parse_syscall_line(line).map(|s| s.1) { 38 | Err(nom::Err::Incomplete(_) | nom::Err::Error(_)) => Ok(ParseResult::IgnoredLine), 39 | Err(nom::Err::Failure(e)) => Err(anyhow::anyhow!("{e}")), 40 | Ok(res) => Ok(res), 41 | } 42 | } 43 | 44 | // Main line token parsers 45 | 46 | #[function_name::named] 47 | fn parse_syscall_line(i: &str) -> IResult<&str, ParseResult> { 48 | dbg_parser_entry!(i); 49 | alt(( 50 | // Complete syscall 51 | map( 52 | ( 53 | parse_pid, 54 | parse_rel_ts, 55 | parse_name, 56 | parse_args_complete, 57 | parse_ret_val, 58 | ), 59 | |(pid, rel_ts, name, args, ret_val)| { 60 | ParseResult::Syscall(Syscall { 61 | pid, 62 | rel_ts, 63 | name: name.to_owned(), 64 | args, 65 | ret_val, 66 | }) 67 | }, 68 | ), 69 | // Syscall start 70 | map( 71 | (parse_pid, parse_rel_ts, parse_name, parse_args_incomplete), 72 | |(pid, rel_ts, name, args)| { 73 | ParseResult::SyscallStart(SyscallStart { 74 | pid, 75 | rel_ts, 76 | name: name.to_owned(), 77 | args, 78 | }) 79 | }, 80 | ), 81 | // Syscall end 82 | map( 83 | ( 84 | parse_pid, 85 | parse_rel_ts, 86 | delimited(tag("<... "), parse_name, (tag(" resumed> )"), space1)), 87 | parse_ret_val, 88 | ), 89 | |(pid, rel_ts, name, ret_val)| { 90 | ParseResult::SyscallEnd(SyscallEnd { 91 | pid, 92 | rel_ts, 93 | name: name.to_owned(), 94 | ret_val, 95 | }) 96 | }, 97 | ), 98 | )) 99 | .parse(i) 100 | .inspect(|r| dbg_parser_success!(r)) 101 | } 102 | 103 | #[function_name::named] 104 | fn parse_pid(i: &str) -> IResult<&str, u32> { 105 | dbg_parser_entry!(i); 106 | terminated(map_res(digit1, str::parse), space1) 107 | .parse(i) 108 | .inspect(|r| dbg_parser_success!(r)) 109 | } 110 | 111 | #[function_name::named] 112 | fn parse_rel_ts(i: &str) -> IResult<&str, f64> { 113 | dbg_parser_entry!(i); 114 | terminated(double, space1) 115 | .parse(i) 116 | .inspect(|r| dbg_parser_success!(r)) 117 | } 118 | 119 | #[function_name::named] 120 | fn parse_name(i: &str) -> IResult<&str, &str> { 121 | dbg_parser_entry!(i); 122 | parse_symbol(i) 123 | } 124 | 125 | #[function_name::named] 126 | fn parse_args_complete(i: &str) -> IResult<&str, Vec> { 127 | dbg_parser_entry!(i); 128 | delimited(char('('), parse_args_inner, terminated(char(')'), space1)) 129 | .parse(i) 130 | .inspect(|r| dbg_parser_success!(r)) 131 | } 132 | 133 | #[function_name::named] 134 | fn parse_args_incomplete(i: &str) -> IResult<&str, Vec> { 135 | dbg_parser_entry!(i); 136 | delimited(char('('), parse_args_inner, tag(" ")) 137 | .parse(i) 138 | .inspect(|r| dbg_parser_success!(r)) 139 | } 140 | 141 | #[function_name::named] 142 | fn parse_args_inner(i: &str) -> IResult<&str, Vec> { 143 | dbg_parser_entry!(i); 144 | alt(( 145 | map(separated_list1(tag(", "), parse_struct_member), |ne| { 146 | // Named arguments are stuffed in a single struct 147 | vec![Expression::Struct( 148 | ne.into_iter().map(|(n, e)| (n.to_owned(), e)).collect(), 149 | )] 150 | }), 151 | separated_list0( 152 | tag(", "), 153 | alt(( 154 | map(parse_in_out_argument, |(ia, oa)| ia.unwrap_or(oa)), 155 | parse_expression, 156 | )), 157 | ), 158 | )) 159 | .parse(i) 160 | .inspect(|r| dbg_parser_success!(r)) 161 | } 162 | 163 | #[function_name::named] 164 | fn parse_in_out_argument(i: &str) -> IResult<&str, (Option, Expression)> { 165 | dbg_parser_entry!(i); 166 | alt(( 167 | map( 168 | alt(( 169 | separated_pair(parse_expression, tag(" => "), parse_expression), 170 | delimited( 171 | char('['), 172 | separated_pair(parse_expression, tag(" => "), parse_expression), 173 | char(']'), 174 | ), 175 | )), 176 | |(ia, oa)| (Some(ia), oa), 177 | ), 178 | map(delimited(tag("[{"), parse_expression, tag("}]")), |oa| { 179 | (None, oa) 180 | }), 181 | )) 182 | .parse(i) 183 | .inspect(|r| dbg_parser_success!(r)) 184 | } 185 | 186 | #[function_name::named] 187 | fn parse_ret_val(i: &str) -> IResult<&str, IntegerExpression> { 188 | dbg_parser_entry!(i); 189 | preceded(terminated(char('='), space1), parse_int_literal) 190 | .parse(i) 191 | .inspect(|r| dbg_parser_success!(r)) 192 | } 193 | 194 | // Shared parsers 195 | 196 | #[function_name::named] 197 | fn parse_symbol(i: &str) -> IResult<&str, &str> { 198 | dbg_parser_entry!(i); 199 | recognize(pair( 200 | alt((alpha1, tag("_"))), 201 | many0_count(alt((alphanumeric1, tag("_")))), 202 | )) 203 | .parse(i) 204 | .inspect(|r| dbg_parser_success!(r)) 205 | } 206 | 207 | #[function_name::named] 208 | fn parse_comment(i: &str) -> IResult<&str, Option<&str>> { 209 | dbg_parser_entry!(i); 210 | opt(delimited(tag(" /* "), take_until(" */"), tag(" */"))) 211 | .parse(i) 212 | .inspect(|r| dbg_parser_success!(r)) 213 | } 214 | 215 | // Expression 216 | 217 | #[function_name::named] 218 | fn parse_expression(i: &str) -> IResult<&str, Expression> { 219 | dbg_parser_entry!(i); 220 | map( 221 | pair( 222 | alt(( 223 | parse_expression_mac_addr, 224 | parse_expression_int, 225 | parse_expression_struct, 226 | parse_expression_buf, 227 | parse_expression_set, 228 | parse_expression_array, 229 | )), 230 | parse_comment, 231 | ), 232 | |(u, _)| u, 233 | ) 234 | .parse(i) 235 | .inspect(|r| dbg_parser_success!(r)) 236 | } 237 | 238 | #[function_name::named] 239 | fn parse_expression_mac_addr(i: &str) -> IResult<&str, Expression> { 240 | dbg_parser_entry!(i); 241 | map( 242 | ( 243 | map_res(take(2_usize), |s| u8::from_str_radix(s, 16)), 244 | count( 245 | map_res(preceded(char(':'), take(2_usize)), |s| { 246 | u8::from_str_radix(s, 16) 247 | }), 248 | 5, 249 | ), 250 | ), 251 | |(f, o)| { 252 | let mut mac = [0; 6]; 253 | mac[0] = f; 254 | mac[1..].copy_from_slice(&o); 255 | Expression::MacAddress(mac) 256 | }, 257 | ) 258 | .parse(i) 259 | .inspect(|r| dbg_parser_success!(r)) 260 | } 261 | 262 | #[function_name::named] 263 | fn parse_expression_macro_pseudo_address(i: &str) -> IResult<&str, Expression> { 264 | dbg_parser_entry!(i); 265 | map(preceded(char('&'), parse_symbol), |s| { 266 | Expression::DestinationAddress(s.to_owned()) 267 | }) 268 | .parse(i) 269 | .inspect(|r| dbg_parser_success!(r)) 270 | } 271 | 272 | #[function_name::named] 273 | fn parse_expression_int(i: &str) -> IResult<&str, Expression> { 274 | dbg_parser_entry!(i); 275 | map(parse_int, Expression::Integer) 276 | .parse(i) 277 | .inspect(|r| dbg_parser_success!(r)) 278 | } 279 | 280 | #[function_name::named] 281 | fn parse_expression_struct(i: &str) -> IResult<&str, Expression> { 282 | dbg_parser_entry!(i); 283 | map( 284 | delimited( 285 | char('{'), 286 | separated_list0( 287 | tag(", "), 288 | alt(( 289 | map(parse_struct_member, |(n, e)| (n.to_owned(), e)), 290 | map_opt(parse_int_macro, |e| -> Option<(String, Expression)> { 291 | if let IntegerExpression { 292 | value: IntegerExpressionValue::Macro { args, .. }, 293 | .. 294 | } = &e 295 | { 296 | args.iter().find_map(|a| { 297 | if let Expression::DestinationAddress(n) = a { 298 | Some((n.to_owned(), Expression::Integer(e.clone()))) 299 | } else { 300 | None 301 | } 302 | }) 303 | } else { 304 | None 305 | } 306 | }), 307 | )), 308 | ), 309 | (opt(tag(", ...")), char('}')), 310 | ), 311 | |m| Expression::Struct(m.into_iter().collect()), 312 | ) 313 | .parse(i) 314 | .inspect(|r| dbg_parser_success!(r)) 315 | } 316 | 317 | #[function_name::named] 318 | fn parse_expression_buf(i: &str) -> IResult<&str, Expression> { 319 | dbg_parser_entry!(i); 320 | map(parse_buffer, Expression::Buffer) 321 | .parse(i) 322 | .inspect(|r| dbg_parser_success!(r)) 323 | } 324 | 325 | #[function_name::named] 326 | fn parse_expression_set(i: &str) -> IResult<&str, Expression> { 327 | dbg_parser_entry!(i); 328 | map( 329 | pair( 330 | opt(char('~')), 331 | delimited( 332 | char('['), 333 | separated_list0(char(' '), parse_int), 334 | (opt(tag(" ...")), char(']')), 335 | ), 336 | ), 337 | |(neg, values)| Expression::Collection { 338 | complement: neg.is_some(), 339 | values: values 340 | .into_iter() 341 | .map(|ie| (None, Expression::Integer(ie))) 342 | .collect(), 343 | }, 344 | ) 345 | .parse(i) 346 | .inspect(|r| dbg_parser_success!(r)) 347 | } 348 | 349 | #[function_name::named] 350 | fn parse_expression_array(i: &str) -> IResult<&str, Expression> { 351 | dbg_parser_entry!(i); 352 | map( 353 | delimited( 354 | char('['), 355 | separated_list0( 356 | tag(", "), 357 | ( 358 | opt(terminated( 359 | delimited(char('['), terminated(parse_int, parse_comment), char(']')), 360 | char('='), 361 | )), 362 | parse_expression, 363 | ), 364 | ), 365 | char(']'), 366 | ), 367 | |values| Expression::Collection { 368 | complement: false, 369 | values, 370 | }, 371 | ) 372 | .parse(i) 373 | .inspect(|r| dbg_parser_success!(r)) 374 | } 375 | 376 | // Int expression 377 | 378 | #[function_name::named] 379 | fn parse_int(i: &str) -> IResult<&str, IntegerExpression> { 380 | dbg_parser_entry!(i); 381 | alt(( 382 | parse_int_bit_or, 383 | parse_int_bool_and, 384 | parse_int_equals, 385 | parse_int_macro, 386 | parse_int_multiplication, 387 | parse_int_substraction, 388 | parse_int_left_shift, 389 | parse_int_literal, 390 | parse_int_named, 391 | )) 392 | .parse(i) 393 | .inspect(|r| dbg_parser_success!(r)) 394 | } 395 | 396 | #[function_name::named] 397 | fn parse_int_bit_or(i: &str) -> IResult<&str, IntegerExpression> { 398 | dbg_parser_entry!(i); 399 | map( 400 | separated_pair( 401 | parse_int_named, 402 | char('|'), 403 | separated_list1(char('|'), parse_int), 404 | ), 405 | |(f, rs)| IntegerExpression { 406 | value: IntegerExpressionValue::BinaryOr( 407 | iter::once(f.value) 408 | .chain(rs.into_iter().map(|r| r.value).flat_map(|e| { 409 | // Flatten child expressions 410 | if let IntegerExpressionValue::BinaryOr(es) = e { 411 | es.into_iter() 412 | } else { 413 | vec![e].into_iter() 414 | } 415 | })) 416 | .collect(), 417 | ), 418 | metadata: None, 419 | }, 420 | ) 421 | .parse(i) 422 | .inspect(|r| dbg_parser_success!(r)) 423 | } 424 | 425 | #[function_name::named] 426 | fn parse_int_bool_and(i: &str) -> IResult<&str, IntegerExpression> { 427 | dbg_parser_entry!(i); 428 | map( 429 | separated_pair( 430 | parse_int_macro, 431 | tag(" && "), 432 | separated_list1(tag(" && "), parse_int), 433 | ), 434 | |(f, rs)| IntegerExpression { 435 | value: IntegerExpressionValue::BooleanAnd( 436 | iter::once(f.value) 437 | .chain(rs.into_iter().map(|r| r.value).flat_map(|e| { 438 | // Flatten child expressions 439 | if let IntegerExpressionValue::BooleanAnd(es) = e { 440 | es.into_iter() 441 | } else { 442 | vec![e].into_iter() 443 | } 444 | })) 445 | .collect(), 446 | ), 447 | metadata: None, 448 | }, 449 | ) 450 | .parse(i) 451 | .inspect(|r| dbg_parser_success!(r)) 452 | } 453 | 454 | #[function_name::named] 455 | fn parse_int_equals(i: &str) -> IResult<&str, IntegerExpression> { 456 | dbg_parser_entry!(i); 457 | map( 458 | separated_pair( 459 | parse_int_macro, 460 | tag(" == "), 461 | separated_list1(tag(" == "), parse_int), 462 | ), 463 | |(f, rs)| IntegerExpression { 464 | value: IntegerExpressionValue::Equality( 465 | iter::once(f.value) 466 | .chain(rs.into_iter().map(|r| r.value).flat_map(|e| { 467 | // Flatten child expressions 468 | if let IntegerExpressionValue::Equality(es) = e { 469 | es.into_iter() 470 | } else { 471 | vec![e].into_iter() 472 | } 473 | })) 474 | .collect(), 475 | ), 476 | metadata: None, 477 | }, 478 | ) 479 | .parse(i) 480 | .inspect(|r| dbg_parser_success!(r)) 481 | } 482 | 483 | #[function_name::named] 484 | fn parse_int_macro(i: &str) -> IResult<&str, IntegerExpression> { 485 | dbg_parser_entry!(i); 486 | map( 487 | pair( 488 | parse_symbol, 489 | delimited( 490 | char('('), 491 | separated_list0( 492 | tag(", "), 493 | alt((parse_expression_macro_pseudo_address, parse_expression)), 494 | ), 495 | char(')'), 496 | ), 497 | ), 498 | |(n, args)| IntegerExpression { 499 | value: IntegerExpressionValue::Macro { 500 | name: n.to_owned(), 501 | args, 502 | }, 503 | metadata: None, 504 | }, 505 | ) 506 | .parse(i) 507 | .inspect(|r| dbg_parser_success!(r)) 508 | } 509 | 510 | #[function_name::named] 511 | fn parse_int_multiplication(i: &str) -> IResult<&str, IntegerExpression> { 512 | dbg_parser_entry!(i); 513 | map( 514 | separated_pair( 515 | parse_int_literal, 516 | char('*'), 517 | separated_list1(char('*'), parse_int), 518 | ), 519 | |(f, rs)| IntegerExpression { 520 | value: IntegerExpressionValue::Multiplication( 521 | iter::once(f.value) 522 | .chain(rs.into_iter().map(|r| r.value).flat_map(|e| { 523 | // Flatten child expressions 524 | if let IntegerExpressionValue::Multiplication(es) = e { 525 | es.into_iter() 526 | } else { 527 | vec![e].into_iter() 528 | } 529 | })) 530 | .collect(), 531 | ), 532 | metadata: None, 533 | }, 534 | ) 535 | .parse(i) 536 | .inspect(|r| dbg_parser_success!(r)) 537 | } 538 | 539 | #[function_name::named] 540 | fn parse_int_substraction(i: &str) -> IResult<&str, IntegerExpression> { 541 | dbg_parser_entry!(i); 542 | map( 543 | separated_pair( 544 | parse_int_named, 545 | char('-'), 546 | separated_list1(char('-'), parse_int), 547 | ), 548 | |(f, rs)| IntegerExpression { 549 | value: IntegerExpressionValue::Substraction( 550 | iter::once(f.value) 551 | .chain(rs.into_iter().map(|r| r.value).flat_map(|e| { 552 | // Flatten child expressions 553 | if let IntegerExpressionValue::Substraction(es) = e { 554 | es.into_iter() 555 | } else { 556 | vec![e].into_iter() 557 | } 558 | })) 559 | .collect(), 560 | ), 561 | metadata: None, 562 | }, 563 | ) 564 | .parse(i) 565 | .inspect(|r| dbg_parser_success!(r)) 566 | } 567 | 568 | #[function_name::named] 569 | fn parse_int_literal(i: &str) -> IResult<&str, IntegerExpression> { 570 | dbg_parser_entry!(i); 571 | map( 572 | ( 573 | alt(( 574 | parse_int_literal_hexa, 575 | parse_int_literal_oct, 576 | parse_int_literal_dec, 577 | )), 578 | parse_int_metadata, 579 | ), 580 | |(v, m)| IntegerExpression { 581 | value: IntegerExpressionValue::Literal(v), 582 | metadata: m, 583 | }, 584 | ) 585 | .parse(i) 586 | .inspect(|r| dbg_parser_success!(r)) 587 | } 588 | 589 | #[function_name::named] 590 | fn parse_int_left_shift(i: &str) -> IResult<&str, IntegerExpression> { 591 | dbg_parser_entry!(i); 592 | map( 593 | separated_pair(parse_int_literal, tag("<<"), parse_int), 594 | |(b, s)| IntegerExpression { 595 | value: IntegerExpressionValue::LeftBitShift { 596 | bits: Box::new(b.value), 597 | shift: Box::new(s.value), 598 | }, 599 | metadata: None, 600 | }, 601 | ) 602 | .parse(i) 603 | .inspect(|r| dbg_parser_success!(r)) 604 | } 605 | 606 | #[function_name::named] 607 | fn parse_int_named(i: &str) -> IResult<&str, IntegerExpression> { 608 | dbg_parser_entry!(i); 609 | map((parse_symbol, parse_int_metadata), |(e, metadata)| { 610 | IntegerExpression { 611 | value: IntegerExpressionValue::NamedSymbol(e.to_owned()), 612 | metadata, 613 | } 614 | }) 615 | .parse(i) 616 | .inspect(|r| dbg_parser_success!(r)) 617 | } 618 | 619 | #[function_name::named] 620 | fn parse_int_metadata(i: &str) -> IResult<&str, Option>> { 621 | dbg_parser_entry!(i); 622 | opt(delimited( 623 | char('<'), 624 | map(many_till(parse_buffer_byte, char('>')), |r| r.0), 625 | opt(tag("(deleted)")), 626 | )) 627 | .parse(i) 628 | .inspect(|r| dbg_parser_success!(r)) 629 | } 630 | 631 | // Int literal 632 | 633 | #[function_name::named] 634 | fn parse_int_literal_hexa(i: &str) -> IResult<&str, i128> { 635 | dbg_parser_entry!(i); 636 | preceded( 637 | tag("0x"), 638 | map_res(hex_digit1, |s| i128::from_str_radix(s, 16)), 639 | ) 640 | .parse(i) 641 | .inspect(|r| dbg_parser_success!(r)) 642 | } 643 | 644 | #[function_name::named] 645 | fn parse_int_literal_oct(i: &str) -> IResult<&str, i128> { 646 | dbg_parser_entry!(i); 647 | preceded( 648 | char('0'), 649 | map_res(oct_digit1, |s| i128::from_str_radix(s, 8)), 650 | ) 651 | .parse(i) 652 | .inspect(|r| dbg_parser_success!(r)) 653 | } 654 | 655 | #[function_name::named] 656 | fn parse_int_literal_dec(i: &str) -> IResult<&str, i128> { 657 | dbg_parser_entry!(i); 658 | complete::i128(i) 659 | } 660 | 661 | // Buffer 662 | 663 | #[function_name::named] 664 | fn parse_buffer(i: &str) -> IResult<&str, BufferExpression> { 665 | dbg_parser_entry!(i); 666 | map( 667 | terminated( 668 | pair( 669 | opt(char('@')), 670 | preceded( 671 | char('"'), 672 | map(many_till(parse_buffer_byte, char('"')), |r| r.0), 673 | ), 674 | ), 675 | opt(tag("...")), 676 | ), 677 | |(a, r)| BufferExpression { 678 | value: r, 679 | type_: if a.is_some() { 680 | BufferType::AbstractPath 681 | } else { 682 | BufferType::Unknown 683 | }, 684 | }, 685 | ) 686 | .parse(i) 687 | .inspect(|r| dbg_parser_success!(r)) 688 | } 689 | 690 | #[function_name::named] 691 | fn parse_buffer_byte(i: &str) -> IResult<&str, u8> { 692 | dbg_parser_entry!(i); 693 | map_res(preceded(tag("\\x"), take(2_usize)), |s| { 694 | u8::from_str_radix(s, 16) 695 | }) 696 | .parse(i) 697 | .inspect(|r| dbg_parser_success!(r)) 698 | } 699 | 700 | // Struct 701 | 702 | #[function_name::named] 703 | fn parse_struct_member(i: &str) -> IResult<&str, (&str, Expression)> { 704 | dbg_parser_entry!(i); 705 | separated_pair( 706 | parse_symbol, 707 | char('='), 708 | alt(( 709 | map(parse_in_out_argument, |(ia, oa)| ia.unwrap_or(oa)), 710 | parse_expression, 711 | )), 712 | ) 713 | .parse(i) 714 | .inspect(|r| dbg_parser_success!(r)) 715 | } 716 | 717 | #[cfg(test)] 718 | mod tests { 719 | use super::*; 720 | 721 | #[test] 722 | fn test_parse_expression_array() { 723 | assert_eq!( 724 | parse_expression_array( 725 | "[[IPV4_DEVCONF_BC_FORWARDING-1]=0, [IPV4_DEVCONF_ARP_EVICT_NOCARRIER-1]=1, [37 /* IPSTATS_MIB_??? */]=22]" 726 | ) 727 | .unwrap(), 728 | ( 729 | "", 730 | Expression::Collection { 731 | complement: false, 732 | values: vec![ 733 | ( 734 | Some(IntegerExpression { 735 | value: IntegerExpressionValue::Substraction(vec![ 736 | IntegerExpressionValue::NamedSymbol( 737 | "IPV4_DEVCONF_BC_FORWARDING".to_owned() 738 | ), 739 | IntegerExpressionValue::Literal(1) 740 | ]), 741 | metadata: None 742 | }), 743 | Expression::Integer(IntegerExpression { 744 | value: IntegerExpressionValue::Literal(0), 745 | metadata: None 746 | }) 747 | ), 748 | ( 749 | Some(IntegerExpression { 750 | value: IntegerExpressionValue::Substraction(vec![ 751 | IntegerExpressionValue::NamedSymbol( 752 | "IPV4_DEVCONF_ARP_EVICT_NOCARRIER".to_owned() 753 | ), 754 | IntegerExpressionValue::Literal(1) 755 | ]), 756 | metadata: None 757 | }), 758 | Expression::Integer(IntegerExpression { 759 | value: IntegerExpressionValue::Literal(1), 760 | metadata: None 761 | }) 762 | ), 763 | ( 764 | Some(IntegerExpression { 765 | value: IntegerExpressionValue::Literal(37), 766 | metadata: None 767 | }), 768 | Expression::Integer(IntegerExpression { 769 | value: IntegerExpressionValue::Literal(22), 770 | metadata: None 771 | }) 772 | ) 773 | ] 774 | } 775 | ) 776 | ); 777 | } 778 | } 779 | -------------------------------------------------------------------------------- /src/strace/run.rs: -------------------------------------------------------------------------------- 1 | //! Strace invocation code 2 | 3 | use std::{ 4 | fs::File, 5 | io::BufReader, 6 | path::PathBuf, 7 | process::{Child, Command, Stdio}, 8 | }; 9 | 10 | use anyhow::Context as _; 11 | 12 | use crate::strace::{STRACE_BIN, parser::LogParser}; 13 | 14 | pub(crate) struct Strace { 15 | /// Strace process 16 | process: Child, 17 | /// Temp dir for pipe location 18 | pipe_dir: tempfile::TempDir, 19 | /// Strace log mirror path 20 | log_path: Option, 21 | } 22 | 23 | impl Strace { 24 | pub(crate) fn run(command: &[&str], log_path: Option) -> anyhow::Result { 25 | // Create named pipe 26 | let pipe_dir = tempfile::tempdir().context("Failed to create temporary directory")?; 27 | let pipe_path = Self::pipe_path(&pipe_dir); 28 | #[expect(clippy::unwrap_used)] 29 | nix::unistd::mkfifo(&pipe_path, nix::sys::stat::Mode::from_bits(0o600).unwrap()) 30 | .context("Failed to create named pipe")?; 31 | 32 | // Start process 33 | // TODO setuid/setgid execution will be broken unless strace runs as root 34 | let child = Command::new(STRACE_BIN) 35 | .args([ 36 | "--daemonize=grandchild", 37 | "--relative-timestamps", 38 | "--follow-forks", 39 | // TODO APPROXIMATION this can make us miss interesting stuff like open with O_EXCL|O_CREAT which 40 | // returns -1 because file exists 41 | "--successful-only", 42 | "--strings-in-hex=all", 43 | // Despite this, some structs are still truncated 44 | "-e", 45 | "abbrev=none", 46 | // "-e", 47 | // "read=all", 48 | // "-e", 49 | // "write=all", 50 | "-e", 51 | "decode-fds=path", 52 | "--output-append-mode", 53 | "-o", 54 | #[expect(clippy::unwrap_used)] 55 | pipe_path.to_str().unwrap(), 56 | "--", 57 | ]) 58 | .args(command) 59 | .env("LANG", "C") // avoids locale side effects 60 | .stdin(Stdio::null()) 61 | .spawn() 62 | .context("Failed to start strace")?; 63 | 64 | Ok(Self { 65 | process: child, 66 | pipe_dir, 67 | log_path, 68 | }) 69 | } 70 | 71 | fn pipe_path(dir: &tempfile::TempDir) -> PathBuf { 72 | dir.path().join("strace.pipe") 73 | } 74 | 75 | pub(crate) fn log_lines(&self) -> anyhow::Result { 76 | let pipe_path = Self::pipe_path(&self.pipe_dir); 77 | let reader = BufReader::new(File::open(pipe_path)?); 78 | LogParser::new(Box::new(reader), self.log_path.as_deref()) 79 | } 80 | } 81 | 82 | impl Drop for Strace { 83 | fn drop(&mut self) { 84 | let _ = self.process.kill(); 85 | let _ = self.process.wait(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/summarize/mod.rs: -------------------------------------------------------------------------------- 1 | //! Summarize program syscalls into higher level action 2 | 3 | use std::{ 4 | collections::{HashMap, HashSet}, 5 | fmt::{self, Display}, 6 | net::IpAddr, 7 | num::NonZeroU16, 8 | os::fd::RawFd, 9 | path::PathBuf, 10 | sync::LazyLock, 11 | }; 12 | 13 | use anyhow::Context as _; 14 | 15 | use crate::{ 16 | strace::{ 17 | BufferExpression, BufferType, Expression, IntegerExpression, IntegerExpressionValue, 18 | Syscall, 19 | }, 20 | systemd::{SocketFamily, SocketProtocol}, 21 | }; 22 | 23 | mod handlers; 24 | 25 | /// A high level program runtime action 26 | /// This does *not* map 1-1 with a syscall, and does *not* necessarily respect chronology 27 | #[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)] 28 | pub(crate) enum ProgramAction { 29 | /// Path was accessed (open, stat'ed, read...) 30 | Read(PathBuf), 31 | /// Path was written to (data, metadata, path removal...) 32 | Write(PathBuf), 33 | /// Path was created 34 | Create(PathBuf), 35 | /// Path was exec'd 36 | Exec(PathBuf), 37 | /// Mount propagated to host 38 | MountToHost, 39 | /// Network (socket) activity 40 | NetworkActivity(Box), 41 | /// Memory mapping with write and execute bits 42 | WriteExecuteMemoryMapping, 43 | /// Set scheduler to a real time one 44 | SetRealtimeScheduler, 45 | /// Inhibit suspend 46 | Wakeup, 47 | /// Create special files 48 | MknodSpecial, 49 | /// Set privileged timer alarm 50 | SetAlarm, 51 | /// Names of the syscalls made by the program 52 | Syscalls(HashSet), 53 | } 54 | 55 | /// Network (socket) activity 56 | #[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)] 57 | pub(crate) struct NetworkActivity { 58 | pub af: SetSpecifier, 59 | pub proto: SetSpecifier, 60 | pub kind: SetSpecifier, 61 | pub local_port: SetSpecifier, 62 | // Note: this account for source and destination addresses 63 | pub address: SetSpecifier, 64 | } 65 | 66 | /// Quantify something that is done or denied 67 | #[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)] 68 | pub(crate) enum SetSpecifier { 69 | None, 70 | One(T), 71 | Some(Vec), 72 | AllExcept(Vec), 73 | All, 74 | } 75 | 76 | impl SetSpecifier { 77 | fn contains_one(&self, needle: &T) -> bool { 78 | match self { 79 | Self::None => false, 80 | Self::One(e) => e == needle, 81 | Self::Some(es) => es.contains(needle), 82 | Self::AllExcept(es) => !es.contains(needle), 83 | Self::All => true, 84 | } 85 | } 86 | 87 | pub(crate) fn intersects(&self, other: &Self) -> bool { 88 | match self { 89 | Self::None => false, 90 | Self::One(e) => other.contains_one(e), 91 | Self::Some(es) => es.iter().any(|e| other.contains_one(e)), 92 | Self::AllExcept(excs) => match other { 93 | Self::None => false, 94 | Self::One(e) => !excs.contains(e), 95 | Self::Some(es) => es.iter().any(|e| !excs.contains(e)), 96 | Self::AllExcept(other_excs) => excs != other_excs, 97 | Self::All => true, // this is incorrect, but unless excs has the whole address/port space, we should be good 98 | }, 99 | Self::All => !matches!(other, Self::None), 100 | } 101 | } 102 | 103 | pub(crate) fn excluded_elements(&self) -> Vec { 104 | match self { 105 | Self::AllExcept(vec) => vec.to_owned(), 106 | _ => unimplemented!(), 107 | } 108 | } 109 | 110 | /// Remove a single element from the set 111 | /// The element to remove **must** be in the set, otherwise may panic 112 | #[expect(clippy::unwrap_used, clippy::panic)] 113 | pub(crate) fn remove(&mut self, to_rm: &T) { 114 | debug_assert!(self.contains_one(to_rm)); 115 | match self { 116 | Self::None => panic!(), 117 | Self::One(_) => { 118 | *self = Self::None; 119 | } 120 | Self::Some(es) => { 121 | let idx = es.iter().position(|e| e == to_rm).unwrap(); 122 | es.remove(idx); 123 | } 124 | Self::AllExcept(excs) => { 125 | debug_assert!(!excs.contains(to_rm)); 126 | excs.push(to_rm.to_owned()); 127 | } 128 | Self::All => { 129 | *self = Self::AllExcept(vec![to_rm.to_owned()]); 130 | } 131 | } 132 | } 133 | } 134 | 135 | /// Socket activity 136 | #[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)] 137 | pub(crate) enum NetworkActivityKind { 138 | SocketCreation, 139 | Bind, 140 | Connect, 141 | Accept, 142 | SendRecv, 143 | } 144 | 145 | impl NetworkActivityKind { 146 | /// All kinds that are linked with one or more addresses 147 | pub(crate) const ADDRESSED: [Self; 4] = [ 148 | NetworkActivityKind::Bind, 149 | NetworkActivityKind::Connect, 150 | NetworkActivityKind::Accept, 151 | NetworkActivityKind::SendRecv, 152 | ]; 153 | 154 | /// Get kind from syscall name, panic if it fails 155 | fn from_sc_name(sc: &str) -> Self { 156 | match sc { 157 | "socket" => NetworkActivityKind::SocketCreation, 158 | "bind" => NetworkActivityKind::Bind, 159 | "connect" => NetworkActivityKind::Connect, 160 | "accept" | "accept4" => NetworkActivityKind::Accept, 161 | "sendto" | "recvfrom" => NetworkActivityKind::SendRecv, 162 | _ => unreachable!("{:?}", sc), 163 | } 164 | } 165 | } 166 | 167 | // TODO review Derive 168 | #[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq, serde::Serialize, serde::Deserialize)] 169 | pub(crate) struct NetworkPort(NonZeroU16); 170 | 171 | impl Display for NetworkPort { 172 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 173 | self.0.fmt(f) 174 | } 175 | } 176 | 177 | #[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq, serde::Serialize, serde::Deserialize)] 178 | pub(crate) struct NetworkAddress(IpAddr); 179 | 180 | impl From for NetworkAddress { 181 | fn from(value: IpAddr) -> Self { 182 | Self(value) 183 | } 184 | } 185 | 186 | impl Display for NetworkAddress { 187 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 188 | write!(f, "{}", self.0) 189 | } 190 | } 191 | 192 | #[derive(Debug)] 193 | enum FdOrPath { 194 | Fd(T), 195 | Path(T), 196 | } 197 | 198 | /// Meta structure to group syscalls that have similar summary handling 199 | /// and store arguments 200 | enum SyscallArgsInfo { 201 | Chdir(FdOrPath), 202 | EpollCtl { 203 | op: T, 204 | event: T, 205 | }, 206 | Exec { 207 | relfd: Option, 208 | path: T, 209 | }, 210 | Mkdir { 211 | relfd: Option, 212 | path: T, 213 | }, 214 | Mknod { 215 | mode: T, 216 | }, 217 | Mmap { 218 | prot: T, 219 | fd: Option, 220 | }, 221 | Mount { 222 | flags: T, 223 | }, 224 | Network { 225 | fd: T, 226 | sockaddr: T, 227 | }, 228 | Open { 229 | relfd: Option, 230 | path: T, 231 | flags: T, 232 | }, 233 | Rename { 234 | relfd_src: Option, 235 | path_src: T, 236 | relfd_dst: Option, 237 | path_dst: T, 238 | flags: Option, 239 | }, 240 | SetScheduler { 241 | policy: T, 242 | }, 243 | Socket { 244 | af: T, 245 | flags: T, 246 | }, 247 | StatFd { 248 | fd: T, 249 | }, 250 | StatPath { 251 | relfd: Option, 252 | path: T, 253 | }, 254 | TimerCreate { 255 | clockid: T, 256 | }, 257 | } 258 | 259 | /// Syscall argument indexes 260 | type SyscallArgsIndex = SyscallArgsInfo; 261 | /// Syscall arguments 262 | type SyscallArgs<'a> = SyscallArgsInfo<&'a Expression>; 263 | 264 | impl SyscallArgsIndex { 265 | /// Extract arguments from indexes 266 | fn extract_args<'a>(&self, sc: &'a Syscall) -> anyhow::Result> { 267 | let args = match self { 268 | Self::Chdir(p) => SyscallArgsInfo::Chdir(match p { 269 | FdOrPath::Fd(i) => FdOrPath::Fd(Self::extract_arg(sc, *i)?), 270 | FdOrPath::Path(i) => FdOrPath::Path(Self::extract_arg(sc, *i)?), 271 | }), 272 | Self::EpollCtl { op, event } => SyscallArgs::EpollCtl { 273 | op: Self::extract_arg(sc, *op)?, 274 | event: Self::extract_arg(sc, *event)?, 275 | }, 276 | Self::Exec { relfd, path } => SyscallArgs::Exec { 277 | relfd: relfd 278 | .map(|relfd| Self::extract_arg(sc, relfd)) 279 | .transpose()?, 280 | path: Self::extract_arg(sc, *path)?, 281 | }, 282 | Self::Mkdir { relfd, path } => SyscallArgs::Mkdir { 283 | relfd: relfd 284 | .map(|relfd| Self::extract_arg(sc, relfd)) 285 | .transpose()?, 286 | path: Self::extract_arg(sc, *path)?, 287 | }, 288 | Self::Mknod { mode } => SyscallArgs::Mknod { 289 | mode: Self::extract_arg(sc, *mode)?, 290 | }, 291 | Self::Mmap { prot, fd } => SyscallArgs::Mmap { 292 | prot: Self::extract_arg(sc, *prot)?, 293 | fd: fd.map(|fd| Self::extract_arg(sc, fd)).transpose()?, 294 | }, 295 | Self::Mount { flags } => SyscallArgs::Mount { 296 | flags: Self::extract_arg(sc, *flags)?, 297 | }, 298 | Self::Network { fd, sockaddr } => SyscallArgs::Network { 299 | fd: Self::extract_arg(sc, *fd)?, 300 | sockaddr: Self::extract_arg(sc, *sockaddr)?, 301 | }, 302 | Self::Open { relfd, path, flags } => SyscallArgs::Open { 303 | relfd: relfd 304 | .map(|relfd| Self::extract_arg(sc, relfd)) 305 | .transpose()?, 306 | path: Self::extract_arg(sc, *path)?, 307 | flags: Self::extract_arg(sc, *flags)?, 308 | }, 309 | Self::Rename { 310 | relfd_src, 311 | path_src, 312 | relfd_dst, 313 | path_dst, 314 | flags, 315 | } => SyscallArgs::Rename { 316 | relfd_src: relfd_src 317 | .map(|relfd_src| Self::extract_arg(sc, relfd_src)) 318 | .transpose()?, 319 | path_src: Self::extract_arg(sc, *path_src)?, 320 | relfd_dst: relfd_dst 321 | .map(|relfd_dst| Self::extract_arg(sc, relfd_dst)) 322 | .transpose()?, 323 | path_dst: Self::extract_arg(sc, *path_dst)?, 324 | flags: flags 325 | .map(|flags| Self::extract_arg(sc, flags)) 326 | .transpose()?, 327 | }, 328 | Self::SetScheduler { policy } => SyscallArgs::SetScheduler { 329 | policy: Self::extract_arg(sc, *policy)?, 330 | }, 331 | Self::Socket { af, flags } => SyscallArgs::Socket { 332 | af: Self::extract_arg(sc, *af)?, 333 | flags: Self::extract_arg(sc, *flags)?, 334 | }, 335 | Self::StatFd { fd } => SyscallArgs::StatFd { 336 | fd: Self::extract_arg(sc, *fd)?, 337 | }, 338 | Self::StatPath { relfd, path } => SyscallArgs::StatPath { 339 | relfd: relfd 340 | .map(|relfd| Self::extract_arg(sc, relfd)) 341 | .transpose()?, 342 | path: Self::extract_arg(sc, *path)?, 343 | }, 344 | Self::TimerCreate { clockid } => SyscallArgsInfo::TimerCreate { 345 | clockid: Self::extract_arg(sc, *clockid)?, 346 | }, 347 | }; 348 | Ok(args) 349 | } 350 | 351 | fn extract_arg(sc: &Syscall, index: usize) -> anyhow::Result<&Expression> { 352 | sc.args.get(index).ok_or_else(|| { 353 | anyhow::anyhow!( 354 | "Unable to extract syscall argument {} for {:?}", 355 | index, 356 | sc.name 357 | ) 358 | }) 359 | } 360 | } 361 | 362 | // 363 | // For some reference on syscalls, see: 364 | // - https://man7.org/linux/man-pages/man2/syscalls.2.html 365 | // - https://filippo.io/linux-syscall-table/ 366 | // - https://linasm.sourceforge.net/docs/syscalls/filesystem.php 367 | // 368 | static SYSCALL_MAP: LazyLock> = LazyLock::new(|| { 369 | HashMap::from([ 370 | // chdir 371 | ("chdir", SyscallArgsIndex::Chdir(FdOrPath::Path(0))), 372 | ("fchdir", SyscallArgsIndex::Chdir(FdOrPath::Fd(0))), 373 | // epoll_ctl 374 | ("epoll_ctl", SyscallArgsIndex::EpollCtl { op: 1, event: 3 }), 375 | // execve 376 | ( 377 | "execve", 378 | SyscallArgsIndex::Exec { 379 | relfd: None, 380 | path: 0, 381 | }, 382 | ), 383 | ( 384 | "execveat", 385 | SyscallArgsIndex::Exec { 386 | relfd: Some(0), 387 | path: 1, 388 | }, 389 | ), 390 | // mkdir 391 | ( 392 | "mkdir", 393 | SyscallArgsIndex::Mkdir { 394 | path: 0, 395 | relfd: None, 396 | }, 397 | ), 398 | ( 399 | "mkdirat", 400 | SyscallArgsIndex::Mkdir { 401 | path: 1, 402 | relfd: Some(0), 403 | }, 404 | ), 405 | // mknod 406 | ("mknod", SyscallArgsIndex::Mknod { mode: 1 }), 407 | ("mknodat", SyscallArgsIndex::Mknod { mode: 2 }), 408 | // mmap 409 | ( 410 | "mmap", 411 | SyscallArgsIndex::Mmap { 412 | prot: 2, 413 | fd: Some(4), 414 | }, 415 | ), 416 | ( 417 | "mmap2", 418 | SyscallArgsIndex::Mmap { 419 | prot: 2, 420 | fd: Some(4), 421 | }, 422 | ), 423 | ("mprotect", SyscallArgsIndex::Mmap { prot: 2, fd: None }), 424 | ( 425 | "pkey_mprotect", 426 | SyscallArgsIndex::Mmap { prot: 2, fd: None }, 427 | ), 428 | // mount 429 | ("mount", SyscallArgsIndex::Mount { flags: 3 }), 430 | // network 431 | // We don't track other send/recv variants because, we can track activity we need 432 | // from other syscalls 433 | ("accept", SyscallArgsIndex::Network { fd: 0, sockaddr: 1 }), 434 | ("accept4", SyscallArgsIndex::Network { fd: 0, sockaddr: 1 }), 435 | ("bind", SyscallArgsIndex::Network { fd: 0, sockaddr: 1 }), 436 | ("connect", SyscallArgsIndex::Network { fd: 0, sockaddr: 1 }), 437 | ("recvfrom", SyscallArgsIndex::Network { fd: 0, sockaddr: 4 }), 438 | ("sendto", SyscallArgsIndex::Network { fd: 0, sockaddr: 4 }), 439 | // open 440 | ( 441 | "open", 442 | SyscallArgsIndex::Open { 443 | relfd: None, 444 | path: 0, 445 | flags: 1, 446 | }, 447 | ), 448 | ( 449 | "openat", 450 | SyscallArgsIndex::Open { 451 | relfd: Some(0), 452 | path: 1, 453 | flags: 2, 454 | }, 455 | ), 456 | // rename 457 | ( 458 | "rename", 459 | SyscallArgsIndex::Rename { 460 | relfd_src: None, 461 | path_src: 0, 462 | relfd_dst: None, 463 | path_dst: 1, 464 | flags: None, 465 | }, 466 | ), 467 | ( 468 | "renameat", 469 | SyscallArgsIndex::Rename { 470 | relfd_src: Some(0), 471 | path_src: 1, 472 | relfd_dst: Some(2), 473 | path_dst: 3, 474 | flags: None, 475 | }, 476 | ), 477 | ( 478 | "renameat2", 479 | SyscallArgsIndex::Rename { 480 | relfd_src: Some(0), 481 | path_src: 1, 482 | relfd_dst: Some(2), 483 | path_dst: 3, 484 | flags: Some(4), 485 | }, 486 | ), 487 | // set scheduler 488 | ( 489 | "sched_setscheduler", 490 | SyscallArgsIndex::SetScheduler { policy: 1 }, 491 | ), 492 | // socket 493 | ("socket", SyscallArgsIndex::Socket { af: 0, flags: 1 }), 494 | // stat fd 495 | ("fstat", SyscallArgsIndex::StatFd { fd: 0 }), 496 | ("getdents", SyscallArgsIndex::StatFd { fd: 0 }), 497 | // stat path 498 | ( 499 | "stat", 500 | SyscallArgsIndex::StatPath { 501 | relfd: None, 502 | path: 0, 503 | }, 504 | ), 505 | ( 506 | "lstat", 507 | SyscallArgsIndex::StatPath { 508 | relfd: None, 509 | path: 0, 510 | }, 511 | ), 512 | ( 513 | "newfstatat", 514 | SyscallArgsIndex::StatPath { 515 | relfd: Some(0), 516 | path: 1, 517 | }, 518 | ), 519 | // timer_create 520 | ("timer_create", SyscallArgsIndex::TimerCreate { clockid: 0 }), 521 | ]) 522 | }); 523 | 524 | /// Information that persists between syscalls and that we need to handle 525 | /// Obviously, keeping this to a minimum is a goal 526 | #[derive(Debug, Default)] 527 | struct ProgramState { 528 | /// Keep known socket protocols (per process) for bind handling, we don't care for the socket closings 529 | /// because the fd will be reused or never bound again 530 | known_sockets_proto: HashMap<(u32, RawFd), SocketProtocol>, 531 | /// Current working directory 532 | // TODO initialize with startup current dir? 533 | cur_dir: Option, 534 | } 535 | 536 | pub(crate) fn summarize(syscalls: I, env_paths: &[PathBuf]) -> anyhow::Result> 537 | where 538 | I: IntoIterator>, 539 | { 540 | let mut actions = Vec::new(); 541 | let mut stats: HashMap = HashMap::new(); 542 | let mut program_state = ProgramState::default(); 543 | for syscall in syscalls { 544 | let syscall = syscall?; 545 | log::trace!("{syscall:?}"); 546 | stats 547 | .entry(syscall.name.clone()) 548 | .and_modify(|c| *c += 1) 549 | .or_insert(1); 550 | let name = syscall.name.as_str(); 551 | 552 | if let Some(arg_indexes) = SYSCALL_MAP.get(name) { 553 | let args = arg_indexes.extract_args(&syscall)?; 554 | handlers::summarize_syscall(&syscall, args, &mut actions, &mut program_state) 555 | .with_context(|| format!("Failed to summarize syscall {syscall:?}"))?; 556 | } 557 | } 558 | 559 | // Almost free optimization 560 | actions.dedup(); 561 | 562 | // Create single action with all syscalls for efficient handling of seccomp filters 563 | actions.push(ProgramAction::Syscalls(stats.keys().cloned().collect())); 564 | 565 | // Directories in PATH env var need to be accessible, otherwise systemd errors 566 | actions.extend(env_paths.iter().cloned().map(ProgramAction::Read)); 567 | 568 | // Report stats 569 | let mut syscall_names = stats.keys().collect::>(); 570 | syscall_names.sort_unstable(); 571 | for syscall_name in syscall_names { 572 | #[expect(clippy::unwrap_used)] 573 | let count = stats.get(syscall_name).unwrap(); 574 | log::debug!("{:24} {: >12}", format!("{syscall_name}:"), count); 575 | } 576 | 577 | Ok(actions) 578 | } 579 | 580 | #[expect(clippy::unreadable_literal)] 581 | #[cfg(test)] 582 | mod tests { 583 | use std::os::unix::ffi::OsStrExt as _; 584 | 585 | use super::*; 586 | use crate::strace::*; 587 | 588 | #[test] 589 | fn test_relative_rename() { 590 | let _ = simple_logger::SimpleLogger::new().init(); 591 | 592 | let env_paths = [ 593 | PathBuf::from("/path/from/env/1"), 594 | PathBuf::from("/path/from/env/2"), 595 | ]; 596 | 597 | let temp_dir_src = tempfile::tempdir().unwrap(); 598 | let temp_dir_dst = tempfile::tempdir().unwrap(); 599 | let syscalls = [Ok(Syscall { 600 | pid: 1068781, 601 | rel_ts: 0.000083, 602 | name: "renameat".to_owned(), 603 | args: vec![ 604 | Expression::Integer(IntegerExpression { 605 | value: IntegerExpressionValue::NamedSymbol("AT_FDCWD".to_owned()), 606 | metadata: Some(temp_dir_src.path().as_os_str().as_bytes().to_vec()), 607 | }), 608 | Expression::Buffer(BufferExpression { 609 | value: "a".as_bytes().to_vec(), 610 | type_: BufferType::Unknown, 611 | }), 612 | Expression::Integer(IntegerExpression { 613 | value: IntegerExpressionValue::NamedSymbol("AT_FDCWD".to_owned()), 614 | metadata: Some(temp_dir_dst.path().as_os_str().as_bytes().to_vec()), 615 | }), 616 | Expression::Buffer(BufferExpression { 617 | value: "b".as_bytes().to_vec(), 618 | type_: BufferType::Unknown, 619 | }), 620 | Expression::Integer(IntegerExpression { 621 | value: IntegerExpressionValue::NamedSymbol("RENAME_NOREPLACE".to_owned()), 622 | metadata: None, 623 | }), 624 | ], 625 | ret_val: IntegerExpression { 626 | value: IntegerExpressionValue::Literal(0), 627 | metadata: None, 628 | }, 629 | })]; 630 | assert_eq!( 631 | summarize(syscalls, &env_paths).unwrap(), 632 | vec![ 633 | ProgramAction::Read(temp_dir_src.path().join("a")), 634 | ProgramAction::Write(temp_dir_src.path().join("a")), 635 | ProgramAction::Create(temp_dir_dst.path().join("b")), 636 | ProgramAction::Write(temp_dir_dst.path().join("b")), 637 | ProgramAction::Syscalls(["renameat".to_owned()].into()), 638 | ProgramAction::Read("/path/from/env/1".into()), 639 | ProgramAction::Read("/path/from/env/2".into()), 640 | ] 641 | ); 642 | } 643 | 644 | #[test] 645 | fn test_connect_uds() { 646 | let _ = simple_logger::SimpleLogger::new().init(); 647 | 648 | let env_paths = [ 649 | PathBuf::from("/path/from/env/1"), 650 | PathBuf::from("/path/from/env/2"), 651 | ]; 652 | 653 | let syscalls = [Ok(Syscall { 654 | pid: 598056, 655 | rel_ts: 0.000036, 656 | name: "connect".to_owned(), 657 | args: vec![ 658 | Expression::Integer(IntegerExpression { 659 | value: IntegerExpressionValue::Literal(4), 660 | metadata: Some("/run/user/1000/systemd/private".as_bytes().to_vec()), 661 | }), 662 | Expression::Struct(HashMap::from([ 663 | ( 664 | "sa_family".to_owned(), 665 | Expression::Integer(IntegerExpression { 666 | value: IntegerExpressionValue::NamedSymbol("AF_UNIX".to_owned()), 667 | metadata: None, 668 | }), 669 | ), 670 | ( 671 | "sun_path".to_owned(), 672 | Expression::Buffer(BufferExpression { 673 | value: "/run/user/1000/systemd/private".as_bytes().to_vec(), 674 | type_: BufferType::Unknown, 675 | }), 676 | ), 677 | ])), 678 | Expression::Integer(IntegerExpression { 679 | value: IntegerExpressionValue::Literal(33), 680 | metadata: None, 681 | }), 682 | ], 683 | ret_val: IntegerExpression { 684 | value: IntegerExpressionValue::Literal(0), 685 | metadata: None, 686 | }, 687 | })]; 688 | assert_eq!( 689 | summarize(syscalls, &env_paths).unwrap(), 690 | vec![ 691 | ProgramAction::Read("/run/user/1000/systemd/private".into()), 692 | ProgramAction::Syscalls(["connect".to_owned()].into()), 693 | ProgramAction::Read("/path/from/env/1".into()), 694 | ProgramAction::Read("/path/from/env/2".into()), 695 | ] 696 | ); 697 | } 698 | } 699 | -------------------------------------------------------------------------------- /src/sysctl.rs: -------------------------------------------------------------------------------- 1 | //! Sysctl handling 2 | 3 | use std::{any::type_name, fs, path::PathBuf, str::FromStr}; 4 | 5 | use anyhow::Context as _; 6 | 7 | /// State of system sysctl knobs 8 | pub(crate) struct State { 9 | pub kernel_unprivileged_userns_clone: bool, 10 | } 11 | 12 | impl State { 13 | /// Fetch current state 14 | pub(crate) fn fetch() -> anyhow::Result { 15 | Ok(Self { 16 | kernel_unprivileged_userns_clone: Self::read_bool("kernel/unprivileged_userns_clone")?, 17 | }) 18 | } 19 | 20 | /// Generate synthetic "all is supported" state 21 | pub(crate) fn all() -> Self { 22 | Self { 23 | kernel_unprivileged_userns_clone: true, 24 | } 25 | } 26 | 27 | /// Generate synthetic "none is supported" state 28 | #[cfg(test)] 29 | pub(crate) fn none() -> Self { 30 | Self { 31 | kernel_unprivileged_userns_clone: false, 32 | } 33 | } 34 | 35 | fn read(key: &str) -> anyhow::Result 36 | where 37 | T: FromStr, 38 | { 39 | let path: PathBuf = ["/proc/sys", key].iter().collect(); 40 | let val_str = fs::read_to_string(&path) 41 | .map(|s| s.trim_end().to_owned()) 42 | .with_context(|| format!("Failed to read {path:?}"))?; 43 | val_str 44 | .parse() 45 | .map_err(|_| anyhow::anyhow!("Failed to parse {:?} into {}", val_str, type_name::())) 46 | } 47 | 48 | fn read_bool(key: &str) -> anyhow::Result { 49 | Ok(Self::read::(key)? != 0) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/systemd/mod.rs: -------------------------------------------------------------------------------- 1 | //! Systemd code 2 | 3 | mod options; 4 | mod resolver; 5 | mod service; 6 | mod version; 7 | 8 | pub(crate) use options::{ 9 | ListOptionValue, OptionDescription, SocketFamily, SocketProtocol, build_options, 10 | }; 11 | pub(crate) use resolver::resolve; 12 | pub(crate) use service::{JournalCursor, Service}; 13 | pub(crate) use version::{KernelVersion, SystemdVersion}; 14 | 15 | const START_OPTION_OUTPUT_SNIPPET: &str = "-------- Start of suggested service options --------"; 16 | const END_OPTION_OUTPUT_SNIPPET: &str = "-------- End of suggested service options --------"; 17 | 18 | #[derive(Debug, Clone, Default, Eq, PartialEq, clap::ValueEnum, strum::Display)] 19 | #[strum(serialize_all = "snake_case")] 20 | pub(crate) enum InstanceKind { 21 | #[default] 22 | System, 23 | User, 24 | } 25 | 26 | impl InstanceKind { 27 | pub(crate) fn to_cmd_args(&self) -> Vec { 28 | vec!["-i".to_owned(), self.to_string()] 29 | } 30 | } 31 | 32 | pub(crate) fn report_options(opts: Vec>) { 33 | // Report (not through logging facility because we may need to parse it back from service logs) 34 | println!("{START_OPTION_OUTPUT_SNIPPET}"); 35 | for opt in opts { 36 | println!("{opt}"); 37 | } 38 | println!("{END_OPTION_OUTPUT_SNIPPET}"); 39 | } 40 | -------------------------------------------------------------------------------- /src/systemd/service.rs: -------------------------------------------------------------------------------- 1 | //! Systemd service actions 2 | 3 | use std::{ 4 | env, fmt, 5 | fs::{self, File}, 6 | io::{self, BufRead as _, BufReader, BufWriter, Write}, 7 | ops::RangeInclusive, 8 | path::{Path, PathBuf}, 9 | process::{Command, Stdio}, 10 | thread::sleep, 11 | time::{Duration, Instant}, 12 | }; 13 | 14 | use anyhow::Context as _; 15 | use itertools::Itertools as _; 16 | use rand::Rng as _; 17 | 18 | use crate::{ 19 | cl::HardeningOptions, 20 | systemd::{END_OPTION_OUTPUT_SNIPPET, START_OPTION_OUTPUT_SNIPPET, options::OptionWithValue}, 21 | }; 22 | 23 | use super::InstanceKind; 24 | 25 | pub(crate) struct Service { 26 | name: String, 27 | arg: Option, 28 | instance: InstanceKind, 29 | } 30 | 31 | const PROFILING_FRAGMENT_NAME: &str = "profile"; 32 | const HARDENING_FRAGMENT_NAME: &str = "harden"; 33 | /// Command line prefix for `ExecStartXxx`= that bypasses all hardening options 34 | /// See 35 | const PRIVILEGED_PREFIX: &str = "+"; 36 | 37 | /// Systemd "exposure level", to rate service security. 38 | /// The lower, the better 39 | pub(crate) struct ExposureLevel(u8); 40 | 41 | impl TryFrom for ExposureLevel { 42 | type Error = anyhow::Error; 43 | 44 | fn try_from(value: f64) -> Result { 45 | const RANGE: RangeInclusive = 0.0..=10.0; 46 | anyhow::ensure!( 47 | RANGE.contains(&value), 48 | "Value not in range [{:.1}; {:.1}]", 49 | RANGE.start(), 50 | RANGE.end() 51 | ); 52 | #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] 53 | Ok(Self((value * 10.0) as u8)) 54 | } 55 | } 56 | 57 | impl fmt::Display for ExposureLevel { 58 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 59 | write!(f, "{:.1}", f64::from(self.0) / 10.0) 60 | } 61 | } 62 | 63 | pub(crate) struct JournalCursor(String); 64 | 65 | impl JournalCursor { 66 | pub(crate) fn current() -> anyhow::Result { 67 | let tmp_file = tempfile::NamedTempFile::new()?; 68 | // Note: user instances use the same cursor 69 | let status = Command::new("journalctl") 70 | .args([ 71 | "-n", 72 | "0", 73 | "--cursor-file", 74 | tmp_file 75 | .path() 76 | .to_str() 77 | .ok_or_else(|| anyhow::anyhow!("Invalid temporary filepath"))?, 78 | ]) 79 | .status()?; 80 | if !status.success() { 81 | anyhow::bail!("journalctl failed: {status}"); 82 | } 83 | let val = fs::read_to_string(tmp_file.path())?; 84 | Ok(Self(val)) 85 | } 86 | } 87 | 88 | impl AsRef for JournalCursor { 89 | fn as_ref(&self) -> &str { 90 | self.0.as_str() 91 | } 92 | } 93 | 94 | impl Service { 95 | pub(crate) fn new(unit: &str, instance: InstanceKind) -> anyhow::Result { 96 | const UNSUPPORTED_UNIT_SUFFIXS: [&str; 10] = [ 97 | ".socket", 98 | ".device", 99 | ".mount", 100 | ".automount", 101 | ".swap", 102 | ".target", 103 | ".path", 104 | ".timer", 105 | ".slice", 106 | ".scope", 107 | ]; 108 | if let Some(suffix) = UNSUPPORTED_UNIT_SUFFIXS.iter().find(|s| unit.ends_with(*s)) { 109 | let type_ = suffix.split_at(1).1; 110 | anyhow::bail!("Unit type {type_:?} is not supported"); 111 | } 112 | let unit = unit.strip_suffix(".service").unwrap_or(unit); 113 | if let Some((name, arg)) = unit.split_once('@') { 114 | Ok(Self { 115 | name: name.to_owned(), 116 | arg: Some(arg.to_owned()), 117 | instance, 118 | }) 119 | } else { 120 | Ok(Self { 121 | name: unit.to_owned(), 122 | arg: None, 123 | instance, 124 | }) 125 | } 126 | } 127 | 128 | fn unit_name(&self) -> String { 129 | format!( 130 | "{}{}.service", 131 | &self.name, 132 | if let Some(arg) = self.arg.as_ref() { 133 | format!("@{arg}") 134 | } else { 135 | String::new() 136 | } 137 | ) 138 | } 139 | 140 | /// Get systemd "exposure level" for the service (0-100). 141 | /// 100 means extremely exposed (no hardening), 0 means so sandboxed it can't do much. 142 | /// Although this is a very crude heuristic, below 40-50 is generally good. 143 | pub(crate) fn get_exposure_level(&self) -> anyhow::Result { 144 | let mut cmd = Command::new("systemd-analyze"); 145 | cmd.arg("security"); 146 | if matches!(self.instance, InstanceKind::User) { 147 | cmd.arg("--user"); 148 | } 149 | let output = cmd 150 | .arg(self.unit_name()) 151 | .env("LANG", "C") 152 | .stdin(Stdio::null()) 153 | .stdout(Stdio::piped()) 154 | .stderr(Stdio::null()) 155 | .output()?; 156 | if !output.status.success() { 157 | anyhow::bail!("systemd-analyze failed: {}", output.status); 158 | } 159 | let last_line = output 160 | .stdout 161 | .lines() 162 | .map_while(Result::ok) 163 | .last() 164 | .context("Failed to read systemd-analyze output")?; 165 | let val_f = last_line 166 | .rsplit(' ') 167 | .nth(2) 168 | .and_then(|v| v.parse::().ok()) 169 | .ok_or_else(|| anyhow::anyhow!("Failed to parse exposure level"))?; 170 | val_f.try_into() 171 | } 172 | 173 | pub(crate) fn add_profile_fragment( 174 | &self, 175 | hardening_opts: &HardeningOptions, 176 | ) -> anyhow::Result<()> { 177 | // Check first if our fragment does not yet exist 178 | let fragment_path = self.fragment_path(PROFILING_FRAGMENT_NAME, false); 179 | anyhow::ensure!( 180 | !fragment_path.is_file(), 181 | "Fragment config already exists at {fragment_path:?}" 182 | ); 183 | let harden_fragment_path = self.fragment_path(HARDENING_FRAGMENT_NAME, true); 184 | anyhow::ensure!( 185 | !harden_fragment_path.is_file(), 186 | "Hardening config already exists at {harden_fragment_path:?} and may conflict with profiling" 187 | ); 188 | 189 | let config_paths_bufs = self.config_paths()?; 190 | let config_paths = config_paths_bufs 191 | .iter() 192 | .map(PathBuf::as_path) 193 | .collect::>(); 194 | log::info!("Located unit config file(s): {config_paths:?}"); 195 | 196 | // Write new fragment 197 | #[expect(clippy::unwrap_used)] // fragment_path guarantees by construction we have a parent 198 | fs::create_dir_all(fragment_path.parent().unwrap())?; 199 | let mut fragment_file = BufWriter::new(File::create(&fragment_path)?); 200 | Self::write_fragment_header(&mut fragment_file)?; 201 | writeln!(fragment_file, "[Service]")?; 202 | // writeln!(fragment_file, "AmbientCapabilities=CAP_SYS_PTRACE")?; 203 | // needed because strace becomes the main process 204 | writeln!(fragment_file, "NotifyAccess=all")?; 205 | writeln!(fragment_file, "Environment=RUST_BACKTRACE=1")?; 206 | if !Self::config_vals("SystemCallFilter", &config_paths)?.is_empty() { 207 | // Allow ptracing, only if a syscall filter is already in place, otherwise it becomes a whitelist 208 | writeln!(fragment_file, "SystemCallFilter=@debug")?; 209 | } 210 | // strace may slow down enough to risk reaching some service timeouts 211 | writeln!(fragment_file, "TimeoutStartSec=infinity")?; 212 | writeln!(fragment_file, "KillMode=control-group")?; 213 | writeln!(fragment_file, "StandardOutput=journal")?; 214 | 215 | // Profile data dir 216 | // %t maps to /run for system instances or $XDG_RUNTIME_DIR (usually /run/user/[UID]) for user instance 217 | let mut rng = rand::rng(); 218 | let profile_data_dir = PathBuf::from(format!( 219 | "%t/{}-profile-data_{:08x}", 220 | env!("CARGO_BIN_NAME"), 221 | rng.random::() 222 | )); 223 | #[expect(clippy::unwrap_used)] 224 | writeln!( 225 | fragment_file, 226 | "RuntimeDirectory={}", 227 | profile_data_dir.file_name().unwrap().to_str().unwrap() 228 | )?; 229 | 230 | let shh_bin = env::current_exe()? 231 | .to_str() 232 | .ok_or_else(|| anyhow::anyhow!("Unable to decode current executable path"))? 233 | .to_owned(); 234 | 235 | // Wrap ExecStartXxx directives 236 | let mut exec_start_idx = 1; 237 | let mut profile_data_paths = Vec::new(); 238 | for exec_start_opt in ["ExecStartPre", "ExecStart", "ExecStartPost"] { 239 | let exec_start_cmds = Self::config_vals(exec_start_opt, &config_paths)?; 240 | if !exec_start_cmds.is_empty() { 241 | writeln!(fragment_file, "{exec_start_opt}=")?; 242 | } 243 | for cmd in exec_start_cmds { 244 | if cmd.starts_with(PRIVILEGED_PREFIX) { 245 | // TODO handle other special prefixes? 246 | // Write command unchanged 247 | writeln!(fragment_file, "{exec_start_opt}={cmd}")?; 248 | } else { 249 | let profile_data_path = profile_data_dir.join(format!("{exec_start_idx:03}")); 250 | exec_start_idx += 1; 251 | #[expect(clippy::unwrap_used)] 252 | writeln!( 253 | fragment_file, 254 | "{}={} run {} -p {} -- {}", 255 | exec_start_opt, 256 | shh_bin, 257 | self.instance 258 | .to_cmd_args() 259 | .into_iter() 260 | .chain(hardening_opts.to_cmd_args()) 261 | .collect::>() 262 | .join(" "), 263 | profile_data_path.to_str().unwrap(), 264 | cmd 265 | )?; 266 | profile_data_paths.push(profile_data_path); 267 | } 268 | } 269 | } 270 | 271 | // Add invocation that merges previous profiles 272 | #[expect(clippy::unwrap_used)] 273 | writeln!( 274 | fragment_file, 275 | "ExecStopPost={} merge-profile-data {} {}", 276 | shh_bin, 277 | self.instance 278 | .to_cmd_args() 279 | .into_iter() 280 | .chain(hardening_opts.to_cmd_args()) 281 | .collect::>() 282 | .join(" "), 283 | profile_data_paths 284 | .iter() 285 | .map(|p| p.to_str().unwrap()) 286 | .join(" ") 287 | )?; 288 | 289 | log::info!("Config fragment written in {fragment_path:?}"); 290 | Ok(()) 291 | } 292 | 293 | pub(crate) fn remove_profile_fragment(&self) -> anyhow::Result<()> { 294 | let fragment_path = self.fragment_path(PROFILING_FRAGMENT_NAME, false); 295 | fs::remove_file(&fragment_path)?; 296 | log::info!("{fragment_path:?} removed"); 297 | // let mut parent_dir = fragment_path; 298 | // while let Some(parent_dir) = parent_dir.parent() { 299 | // if fs::remove_dir(parent_dir).is_err() { 300 | // // Likely directory not empty 301 | // break; 302 | // } 303 | // log::info!("{parent_dir:?} removed"); 304 | // } 305 | Ok(()) 306 | } 307 | 308 | pub(crate) fn remove_hardening_fragment(&self) -> anyhow::Result<()> { 309 | let fragment_path = self.fragment_path(HARDENING_FRAGMENT_NAME, true); 310 | fs::remove_file(&fragment_path)?; 311 | log::info!("{fragment_path:?} removed"); 312 | Ok(()) 313 | } 314 | 315 | pub(crate) fn add_hardening_fragment( 316 | &self, 317 | opts: Vec>, 318 | ) -> anyhow::Result { 319 | let fragment_path = self.fragment_path(HARDENING_FRAGMENT_NAME, true); 320 | #[expect(clippy::unwrap_used)] 321 | fs::create_dir_all(fragment_path.parent().unwrap())?; 322 | 323 | let mut fragment_file = BufWriter::new(File::create(&fragment_path)?); 324 | Self::write_fragment_header(&mut fragment_file)?; 325 | writeln!(fragment_file, "[Service]")?; 326 | for opt in opts { 327 | writeln!(fragment_file, "{opt}")?; 328 | } 329 | 330 | log::info!("Config fragment written in {fragment_path:?}"); 331 | Ok(fragment_path) 332 | } 333 | 334 | fn write_fragment_header(writer: &mut W) -> io::Result<()> { 335 | writeln!( 336 | writer, 337 | "# This file has been autogenerated by {} v{}", 338 | env!("CARGO_BIN_NAME"), 339 | env!("CARGO_PKG_VERSION"), 340 | ) 341 | } 342 | 343 | pub(crate) fn reload_unit_config(&self) -> anyhow::Result<()> { 344 | let mut cmd = Command::new("systemctl"); 345 | if matches!(self.instance, InstanceKind::User) { 346 | cmd.arg("--user"); 347 | } 348 | let status = cmd.arg("daemon-reload").status()?; 349 | if !status.success() { 350 | anyhow::bail!("systemctl failed: {status}"); 351 | } 352 | Ok(()) 353 | } 354 | 355 | pub(crate) fn action(&self, verb: &str, block: bool) -> anyhow::Result<()> { 356 | let unit_name = self.unit_name(); 357 | log::info!("{verb} {unit_name}"); 358 | let mut cmd = vec![verb]; 359 | if matches!(self.instance, InstanceKind::User) { 360 | cmd.push("--user"); 361 | } 362 | if !block { 363 | cmd.push("--no-block"); 364 | } 365 | cmd.push(&unit_name); 366 | let status = Command::new("systemctl").args(cmd).status()?; 367 | if !status.success() { 368 | anyhow::bail!("systemctl failed: {status}"); 369 | } 370 | Ok(()) 371 | } 372 | 373 | pub(crate) fn profiling_result_retry( 374 | &self, 375 | cursor: &JournalCursor, 376 | ) -> anyhow::Result>> { 377 | // DefaultTimeoutStopSec is typically 90s and services can dynamically extend it 378 | // See https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#TimeoutStopSec= 379 | const PROFILING_RESULT_TIMEOUT: Duration = Duration::from_secs(90); 380 | const PROFILING_RESULT_USLEEP: Duration = Duration::from_millis(300); 381 | const PROFILING_RESULT_WARN_DELAY: Duration = Duration::from_secs(3); 382 | // For user units, sometimes journalctl does not have the logs yet, so retry with a delay 383 | let time_start = Instant::now(); 384 | let mut slow_result_warned = false; 385 | loop { 386 | match self.profiling_result(cursor) { 387 | Ok(opts) => return Ok(opts), 388 | Err(err) => { 389 | let now = Instant::now(); 390 | let waited = now.saturating_duration_since(time_start); 391 | if waited > PROFILING_RESULT_TIMEOUT { 392 | return Err(err.context("Timeout waiting for profiling result")); 393 | } else if !slow_result_warned && (waited > PROFILING_RESULT_WARN_DELAY) { 394 | log::warn!( 395 | "Profiling result is not available after {}s ({}), this can be caused by slow service shutdown. Will retry and wait up to {}s", 396 | PROFILING_RESULT_WARN_DELAY.as_secs(), 397 | err, 398 | PROFILING_RESULT_TIMEOUT.as_secs() 399 | ); 400 | slow_result_warned = true; 401 | } 402 | } 403 | } 404 | sleep(PROFILING_RESULT_USLEEP); 405 | } 406 | } 407 | 408 | fn profiling_result( 409 | &self, 410 | cursor: &JournalCursor, 411 | ) -> anyhow::Result>> { 412 | // Start journalctl process 413 | let mut cmd = Command::new("journalctl"); 414 | if matches!(self.instance, InstanceKind::User) { 415 | cmd.arg("--user"); 416 | } 417 | let mut child = cmd 418 | .args([ 419 | "-r", 420 | "-o", 421 | "cat", 422 | "--output-fields=MESSAGE", 423 | "--no-tail", 424 | "--after-cursor", 425 | cursor.as_ref(), 426 | "-u", 427 | &self.unit_name(), 428 | ]) 429 | .stdin(Stdio::null()) 430 | .stdout(Stdio::piped()) 431 | .stderr(Stdio::null()) 432 | .env("LANG", "C") 433 | .spawn()?; 434 | 435 | // Parse its output 436 | #[expect(clippy::unwrap_used)] 437 | let reader = BufReader::new(child.stdout.take().unwrap()); 438 | let snippet_lines: Vec<_> = reader 439 | .lines() 440 | // Stream lines but bubble up errors 441 | .skip_while(|r| { 442 | r.as_ref() 443 | .map(|l| l != END_OPTION_OUTPUT_SNIPPET) 444 | .unwrap_or(false) 445 | }) 446 | .take_while_inclusive(|r| { 447 | r.as_ref() 448 | .map(|l| l != START_OPTION_OUTPUT_SNIPPET) 449 | .unwrap_or(true) 450 | }) 451 | .collect::>()?; 452 | if (snippet_lines.len() < 2) 453 | || (snippet_lines 454 | .last() 455 | .ok_or_else(|| anyhow::anyhow!("Unable to get profiling result lines"))? 456 | != START_OPTION_OUTPUT_SNIPPET) 457 | { 458 | anyhow::bail!("Unable to get profiling result snippet"); 459 | } 460 | // The output with '-r' flag is in reverse chronological order 461 | // (to get the end as fast as possible), so reverse it, after we have 462 | // removed marker lines 463 | #[expect(clippy::indexing_slicing)] 464 | let opts = snippet_lines[1..snippet_lines.len() - 1] 465 | .iter() 466 | .rev() 467 | .map(|l| l.parse::>()) 468 | .collect::>()?; 469 | 470 | // Stop journalctl 471 | child.kill()?; 472 | child.wait()?; 473 | 474 | Ok(opts) 475 | } 476 | 477 | fn config_vals(key: &str, config_paths: &[&Path]) -> anyhow::Result> { 478 | // Note: we could use 'systemctl show -p xxx' but its output is different from config 479 | // files, and we would need to interpret it anyway 480 | let mut vals = Vec::new(); 481 | for config_path in config_paths { 482 | let config_file = BufReader::new(File::open(config_path)?); 483 | let prefix = format!("{key}="); 484 | let mut file_vals = vec![]; 485 | let mut lines = config_file.lines(); 486 | while let Some(line) = lines.next() { 487 | let line = line?; 488 | if line.starts_with(&prefix) { 489 | let val = if line.ends_with('\\') { 490 | let mut val = line 491 | .split_once('=') 492 | .ok_or_else(|| anyhow::anyhow!("Unable to parse service option line"))? 493 | .1 494 | .trim() 495 | .to_owned(); 496 | // Remove trailing '\' 497 | val.pop(); 498 | // Append next lines 499 | loop { 500 | let next_line = lines 501 | .next() 502 | .ok_or_else(|| anyhow::anyhow!("Unexpected end of file"))??; 503 | val = format!("{} {}", val, next_line.trim_start()); 504 | if next_line.ends_with('\\') { 505 | // Remove trailing '\' 506 | val.pop(); 507 | } else { 508 | break; 509 | } 510 | } 511 | val 512 | } else { 513 | line.split_once('=') 514 | .ok_or_else(|| anyhow::anyhow!("Unable to parse service option line"))? 515 | .1 516 | .trim() 517 | .to_owned() 518 | }; 519 | file_vals.push(val); 520 | } 521 | } 522 | // Handles lines that reset previously set options 523 | if let Some((last, _)) = file_vals 524 | .split_inclusive(String::is_empty) 525 | .rev() 526 | .take(2) 527 | .collect_tuple() 528 | { 529 | file_vals = last.to_vec(); 530 | vals.clear(); 531 | } 532 | vals.extend(file_vals); 533 | } 534 | Ok(vals) 535 | } 536 | 537 | fn config_paths(&self) -> anyhow::Result> { 538 | let mut cmd = Command::new("systemctl"); 539 | if matches!(self.instance, InstanceKind::User) { 540 | cmd.arg("--user"); 541 | } 542 | let output = cmd 543 | .args(["status", "-n", "0", &self.unit_name()]) 544 | .env("LANG", "C") 545 | .output()?; 546 | let mut paths = Vec::new(); 547 | let mut drop_in_dir = None; 548 | for line in output.stdout.lines() { 549 | let line = line?; 550 | let line = line.trim_start(); 551 | if line.starts_with("Loaded:") { 552 | // Main unit file 553 | anyhow::ensure!(paths.is_empty()); 554 | let path = line 555 | .split_once('(') 556 | .ok_or_else(|| anyhow::anyhow!("Failed to locate main unit file"))? 557 | .1 558 | .split_once(';') 559 | .ok_or_else(|| anyhow::anyhow!("Failed to locate main unit file"))? 560 | .0; 561 | paths.push(PathBuf::from(path)); 562 | } else if line.starts_with("Drop-In:") { 563 | // Drop in base dir 564 | anyhow::ensure!(paths.len() == 1); 565 | anyhow::ensure!(drop_in_dir.is_none()); 566 | let dir = line 567 | .split_once(':') 568 | .ok_or_else(|| anyhow::anyhow!("Failed to locate unit config fragment dir"))? 569 | .1 570 | .trim_start(); 571 | drop_in_dir = Some(PathBuf::from(dir)); 572 | } else if let Some(dir) = drop_in_dir.as_ref() { 573 | if line.contains(':') { 574 | // Not a path, next key: val line 575 | break; 576 | } else if line.starts_with('/') { 577 | // New base dir 578 | drop_in_dir = Some(PathBuf::from(line)); 579 | } else { 580 | for filename in line.trim().chars().skip(2).collect::().split(", ") { 581 | let path = dir.join(filename); 582 | paths.push(path); 583 | } 584 | } 585 | } 586 | } 587 | Ok(paths) 588 | } 589 | 590 | fn fragment_path(&self, name: &str, persistent: bool) -> PathBuf { 591 | // https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#System%20Unit%20Search%20Path 592 | let base_dir = if persistent { 593 | let etc = "/etc"; 594 | match self.instance { 595 | InstanceKind::System => etc.to_owned(), 596 | InstanceKind::User => { 597 | // Use /etc if we can, which affects all user instances, otherwise use per user XDG dir 598 | let aflags = nix::unistd::AccessFlags::R_OK 599 | .union(nix::unistd::AccessFlags::W_OK) 600 | .union(nix::unistd::AccessFlags::X_OK); 601 | if nix::unistd::access(etc, aflags).is_ok() { 602 | etc.to_owned() 603 | } else { 604 | #[expect(clippy::unwrap_used)] 605 | env::var_os("XDG_CONFIG_DIR") 606 | .or_else(|| { 607 | env::var_os("HOME").map(|h| { 608 | PathBuf::from(h).join(".config").as_os_str().to_owned() 609 | }) 610 | }) 611 | .and_then(|p| p.to_str().map(ToOwned::to_owned)) 612 | .unwrap() 613 | } 614 | } 615 | } 616 | } else { 617 | match self.instance { 618 | InstanceKind::System => "/run".to_owned(), 619 | InstanceKind::User => env::var_os("XDG_RUNTIME_DIR") 620 | .and_then(|p| p.to_str().map(ToOwned::to_owned)) 621 | .unwrap_or_else(|| format!("/run/user/{}", nix::unistd::getuid().as_raw())), 622 | } 623 | }; 624 | [ 625 | &base_dir, 626 | "systemd", 627 | &self.instance.to_string(), 628 | &format!( 629 | "{}{}.service.d", 630 | self.name, 631 | if self.arg.is_some() { "@" } else { "" } 632 | ), 633 | &format!("zz_{}-{}.conf", env!("CARGO_BIN_NAME"), name), 634 | ] 635 | .iter() 636 | .collect() 637 | } 638 | } 639 | 640 | #[cfg(test)] 641 | mod tests { 642 | use super::*; 643 | 644 | #[test] 645 | fn test_config_vals() { 646 | let _ = simple_logger::SimpleLogger::new().init(); 647 | 648 | let mut cfg_file1 = tempfile::NamedTempFile::new().unwrap(); 649 | let mut cfg_file2 = tempfile::NamedTempFile::new().unwrap(); 650 | let mut cfg_file3 = tempfile::NamedTempFile::new().unwrap(); 651 | 652 | writeln!(cfg_file1, "blah=a").unwrap(); 653 | writeln!(cfg_file1, "blah=b").unwrap(); 654 | writeln!(cfg_file2, "blah=").unwrap(); 655 | writeln!(cfg_file2, "blah=c").unwrap(); 656 | writeln!(cfg_file2, "blih=e").unwrap(); 657 | writeln!(cfg_file2, "bloh=f").unwrap(); 658 | writeln!(cfg_file3, "blah=d").unwrap(); 659 | 660 | assert_eq!( 661 | Service::config_vals( 662 | "blah", 663 | &[cfg_file1.path(), cfg_file2.path(), cfg_file3.path()] 664 | ) 665 | .unwrap(), 666 | vec!["c", "d"] 667 | ); 668 | } 669 | 670 | #[test] 671 | fn test_config_val_multiline() { 672 | let _ = simple_logger::SimpleLogger::new().init(); 673 | 674 | let mut cfg_file = tempfile::NamedTempFile::new().unwrap(); 675 | 676 | writeln!( 677 | cfg_file, 678 | r#"ExecStartPre=/bin/sh -c "[ ! -e /usr/bin/galera_recovery ] && VAR= || \ 679 | VAR=`cd /usr/bin/..; /usr/bin/galera_recovery`; [ $? -eq 0 ] \ 680 | && systemctl set-environment _WSREP_START_POSITION=$VAR || exit 1""# 681 | ) 682 | .unwrap(); 683 | 684 | assert_eq!( 685 | Service::config_vals("ExecStartPre", &[cfg_file.path()]).unwrap(), 686 | vec![ 687 | r#"/bin/sh -c "[ ! -e /usr/bin/galera_recovery ] && VAR= || VAR=`cd /usr/bin/..; /usr/bin/galera_recovery`; [ $? -eq 0 ] && systemctl set-environment _WSREP_START_POSITION=$VAR || exit 1""# 688 | ] 689 | ); 690 | } 691 | } 692 | -------------------------------------------------------------------------------- /src/systemd/version.rs: -------------------------------------------------------------------------------- 1 | //! Systemd & kernel version 2 | 3 | use std::{fmt, io::BufRead as _, process::Command, str}; 4 | 5 | #[derive(Debug, Ord, PartialOrd, Eq, PartialEq)] 6 | pub(crate) struct SystemdVersion { 7 | pub major: u16, 8 | pub minor: u16, 9 | } 10 | 11 | impl SystemdVersion { 12 | pub(crate) fn new(major: u16, minor: u16) -> Self { 13 | Self { major, minor } 14 | } 15 | 16 | pub(crate) fn local_system() -> anyhow::Result { 17 | let output = Command::new("systemctl").arg("--version").output()?; 18 | if !output.status.success() { 19 | anyhow::bail!("systemctl invocation failed with code {:?}", output.status); 20 | } 21 | let line = output 22 | .stdout 23 | .lines() 24 | .next() 25 | .ok_or_else(|| anyhow::anyhow!("Unable to get systemd version"))??; 26 | Self::parse_version_line(&line) 27 | } 28 | 29 | fn parse_version_line(s: &str) -> anyhow::Result { 30 | let version = s 31 | .split_once('(') 32 | .ok_or_else(|| anyhow::anyhow!("Unable to parse systemd version"))? 33 | .1 34 | .split_once(')') 35 | .ok_or_else(|| anyhow::anyhow!("Unable to parse systemd version"))? 36 | .0; 37 | let major_str = version 38 | .chars() 39 | .take_while(char::is_ascii_digit) 40 | .collect::(); 41 | let major = major_str.parse()?; 42 | let minor = if let Some('.') = version.chars().nth(major_str.len()) { 43 | // Actual minor version 44 | version 45 | .chars() 46 | .skip(major_str.len() + 1) 47 | .take_while(char::is_ascii_digit) 48 | .collect::() 49 | .parse()? 50 | } else { 51 | // RC or distro suffix 52 | 0 53 | }; 54 | Ok(Self { major, minor }) 55 | } 56 | } 57 | 58 | impl fmt::Display for SystemdVersion { 59 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 60 | write!(f, "{}.{}", self.major, self.minor) 61 | } 62 | } 63 | 64 | #[derive(Ord, PartialOrd, Eq, PartialEq)] 65 | pub(crate) struct KernelVersion { 66 | major: u16, 67 | minor: u16, 68 | release: u16, 69 | } 70 | 71 | impl KernelVersion { 72 | pub(crate) fn new(major: u16, minor: u16, release: u16) -> Self { 73 | Self { 74 | major, 75 | minor, 76 | release, 77 | } 78 | } 79 | 80 | pub(crate) fn local_system() -> anyhow::Result { 81 | let output = Command::new("uname").arg("-r").output()?; 82 | if !output.status.success() { 83 | anyhow::bail!("uname invocation failed with code {:?}", output.status); 84 | } 85 | let tokens: Vec<_> = str::from_utf8(&output.stdout)?.splitn(3, '.').collect(); 86 | let release = tokens 87 | .get(2) 88 | .ok_or_else(|| anyhow::anyhow!("Unable to get kernel release version"))? 89 | .chars() 90 | .take_while(char::is_ascii_digit) 91 | .collect::(); 92 | Ok(Self { 93 | major: tokens 94 | .first() 95 | .ok_or_else(|| anyhow::anyhow!("Unable to get kernel major version"))? 96 | .parse()?, 97 | minor: tokens 98 | .get(1) 99 | .ok_or_else(|| anyhow::anyhow!("Unable to get kernel minor version"))? 100 | .parse()?, 101 | release: release.parse()?, 102 | }) 103 | } 104 | } 105 | 106 | impl fmt::Display for KernelVersion { 107 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 108 | write!(f, "{}.{}.{}", self.major, self.minor, self.release) 109 | } 110 | } 111 | 112 | #[cfg(test)] 113 | mod tests { 114 | use crate::systemd::SystemdVersion; 115 | 116 | #[test] 117 | fn test_parse_version() { 118 | assert_eq!( 119 | SystemdVersion::parse_version_line("systemd 254 (254.1)").unwrap(), 120 | SystemdVersion::new(254, 1) 121 | ); 122 | assert_eq!( 123 | SystemdVersion::parse_version_line("systemd 255 (255~rc3-2)").unwrap(), 124 | SystemdVersion::new(255, 0) 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /systemd_options.md: -------------------------------------------------------------------------------- 1 | # Supported systemd options 2 | 3 | - [`CapabilityBoundingSet`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#CapabilityBoundingSet=) 4 | 5 | - *dynamic blacklisting* 6 | 7 | - [`IPAddressDeny`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#IPAddressDeny=) 8 | 9 | - `any` 10 | - to support this option, other options may be dynamically enabled: 11 | - [`IPAddressAllow`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#IPAddressAllow=) 12 | 13 | - [`InaccessiblePaths`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#InaccessiblePaths=) 14 | 15 | - *dynamic path blacklisting* 16 | - to support this option, other options may be dynamically enabled: 17 | - [`PrivateMounts`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#PrivateMounts=) 18 | - [`TemporaryFileSystem`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#TemporaryFileSystem=) 19 | - [`BindReadOnlyPaths`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#BindReadOnlyPaths=) 20 | - [`BindPaths`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#BindPaths=) 21 | 22 | - [`LockPersonality`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#LockPersonality=) 23 | 24 | - `true` 25 | 26 | - [`MemoryDenyWriteExecute`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#MemoryDenyWriteExecute=) 27 | 28 | - `true` 29 | 30 | - [`NoExecPaths`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#NoExecPaths=) 31 | 32 | - *dynamic path blacklisting* 33 | - to support this option, other options may be dynamically enabled: 34 | - [`PrivateMounts`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#PrivateMounts=) 35 | - [`ExecPaths`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ExecPaths=) 36 | 37 | - [`PrivateDevices`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#PrivateDevices=) 38 | 39 | - `true` 40 | 41 | - [`PrivateMounts`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#PrivateMounts=) 42 | 43 | - `true` 44 | 45 | - [`PrivateNetwork`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#PrivateNetwork=) 46 | 47 | - `true` 48 | 49 | - [`PrivateTmp`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#PrivateTmp=) 50 | 51 | - `disconnected` 52 | 53 | - [`ProcSubset`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ProcSubset=) 54 | 55 | - `pid` 56 | 57 | - [`ProtectClock`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ProtectClock=) 58 | 59 | - `true` 60 | 61 | - [`ProtectControlGroups`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ProtectControlGroups=) 62 | 63 | - `true` 64 | 65 | - [`ProtectHome`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ProtectHome=) 66 | 67 | - `tmpfs` 68 | - `read-only` 69 | - `true` 70 | 71 | - [`ProtectKernelLogs`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ProtectKernelLogs=) 72 | 73 | - `true` 74 | 75 | - [`ProtectKernelModules`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ProtectKernelModules=) 76 | 77 | - `true` 78 | 79 | - [`ProtectKernelTunables`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ProtectKernelTunables=) 80 | 81 | - `true` 82 | 83 | - [`ProtectProc`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ProtectProc=) 84 | 85 | - `ptraceable` 86 | 87 | - [`ProtectSystem`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ProtectSystem=) 88 | 89 | - `true` 90 | - `full` 91 | - `strict` 92 | 93 | - [`ReadOnlyPaths`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ReadOnlyPaths=) 94 | 95 | - *dynamic path blacklisting* 96 | - to support this option, other options may be dynamically enabled: 97 | - [`PrivateMounts`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#PrivateMounts=) 98 | - [`ReadWritePaths`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ReadWritePaths=) 99 | 100 | - [`RestrictAddressFamilies`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#RestrictAddressFamilies=) 101 | 102 | - *dynamic whitelisting* 103 | 104 | - [`RestrictRealtime`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#RestrictRealtime=) 105 | 106 | - `true` 107 | 108 | - [`SocketBindDeny`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#SocketBindDeny=) 109 | 110 | - *dynamic blacklisting* 111 | 112 | - [`SystemCallArchitectures`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#SystemCallArchitectures=) 113 | 114 | - `native` 115 | 116 | - [`SystemCallFilter`](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#SystemCallFilter=) 117 | 118 | - *dynamic blacklisting* 119 | -------------------------------------------------------------------------------- /test-all: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | 3 | # shellcheck disable=SC2155,SC2164,SC2086 4 | 5 | readonly TEST_ARGS="$*" 6 | 7 | # auto cleanup 8 | at_exit() { 9 | [ "${TMP_DIR:-}" ] && rm -Rf "${TMP_DIR}" 10 | } 11 | trap at_exit EXIT 12 | 13 | readonly TMP_DIR="$(mktemp -d /tmp/"$(basename -- "$0")".XXXXXXXXXX)" 14 | 15 | if ! pwd | grep -q '^/home/' 16 | then 17 | echo 'This script should be run from /home' >&2 18 | exit 1 19 | fi 20 | 21 | if [ "${GITHUB_ACTIONS:-}" ] 22 | then 23 | # some tests with 'systemd-run --user' fail on GitHub Actions, but succeed on different 24 | # Debian and Arch environments 25 | # TODO figure out why 26 | INT_TEST_FEATURES=int-tests-as-root 27 | else 28 | INT_TEST_FEATURES=int-tests-as-root,int-tests-sd-user 29 | fi 30 | 31 | # 32 | # runs test in current dir 33 | # 34 | 35 | # unit tests + integration tests 36 | cargo test ${TEST_ARGS} 37 | 38 | # integration tests as root 39 | CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER='sudo -E' cargo test --features ${INT_TEST_FEATURES} --test '*' ${TEST_ARGS} 40 | 41 | # 42 | # runs tests in /tmp 43 | # 44 | 45 | cp -Ra . "${TMP_DIR}" 46 | pushd "${TMP_DIR}" 47 | 48 | # unit tests + integration tests 49 | cargo test --test '*' ${TEST_ARGS} 50 | 51 | # integration tests as root 52 | CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER='sudo -E' cargo test --features ${INT_TEST_FEATURES} --test '*' ${TEST_ARGS} 53 | 54 | popd 55 | -------------------------------------------------------------------------------- /tests/systemd-run.rs: -------------------------------------------------------------------------------- 1 | //! These tests run generated options with systemd-run to ensure they are valid 2 | //! and allow the program to execute normally 3 | 4 | #![expect( 5 | clippy::tests_outside_test_module, 6 | clippy::unwrap_used, 7 | clippy::shadow_unrelated 8 | )] 9 | 10 | use std::{ 11 | env, 12 | fs::{self, Permissions}, 13 | io::{BufRead as _, Write as _}, 14 | os::unix::fs::{FileTypeExt as _, PermissionsExt as _}, 15 | process, 16 | sync::LazyLock, 17 | }; 18 | 19 | use assert_cmd::{ 20 | Command, 21 | assert::{Assert, OutputAssertExt as _}, 22 | }; 23 | use predicates::prelude::predicate; 24 | 25 | static ALL_SHH_RUN_OPTS: LazyLock>> = LazyLock::new(all_shh_run_opts); 26 | const KERNEL_LOG_REGEX: &str = r"\[\ *[0-9]+\.[0-9]+\] "; 27 | 28 | const SYSTEMD_RUN_USER: &[bool] = if cfg!(feature = "int-tests-sd-user") { 29 | &[false, true] 30 | } else { 31 | &[false] 32 | }; 33 | 34 | /// Run `shh run` for a given command, and return generated systemd options 35 | fn generate_options(cmd: &[&str], run_opts: &[&str]) -> Vec { 36 | const START_OPTION_OUTPUT_SNIPPET: &str = 37 | "-------- Start of suggested service options --------"; 38 | const END_OPTION_OUTPUT_SNIPPET: &str = "-------- End of suggested service options --------"; 39 | let output = Command::cargo_bin("shh") 40 | .unwrap() 41 | .arg("run") 42 | .args(run_opts) 43 | .arg("--") 44 | .args(cmd) 45 | .unwrap(); 46 | let opts = output 47 | .stdout 48 | .clone() 49 | .lines() 50 | // Filter out delimiting lines while letting errors bubble up 51 | .skip_while(|r| { 52 | r.as_ref() 53 | .is_ok_and(|l| !l.starts_with(START_OPTION_OUTPUT_SNIPPET)) 54 | }) 55 | .skip(1) 56 | .take_while(|r| { 57 | r.as_ref().is_err() 58 | || r.as_ref() 59 | .is_ok_and(|l| !l.starts_with(END_OPTION_OUTPUT_SNIPPET)) 60 | }) 61 | .collect::>() 62 | .unwrap(); 63 | output.assert().success(); 64 | opts 65 | } 66 | 67 | /// Run systemd-run for given command, with options 68 | fn systemd_run(cmd: &[&str], sd_opts: &[String], user: bool) -> Assert { 69 | // TODO why do we need sudo to get output, even when already running as root through sudo wrapper? 70 | let mut sd_cmd = vec!["sudo".to_owned(), "systemd-run".to_owned()]; 71 | if user { 72 | sd_cmd.extend([ 73 | "--user".to_owned(), 74 | "-M".to_owned(), 75 | format!("{}@.host", env::var("SUDO_USER").unwrap()), 76 | ]); 77 | } 78 | sd_cmd.extend(["-P", "-G", "--wait"].into_iter().map(ToOwned::to_owned)); 79 | for sd_opt in sd_opts { 80 | // Some options are supported in systemd unit files but not by systemd-run, work around that 81 | let sd_opt = match sd_opt.as_str() { 82 | // https://github.com/systemd/systemd/issues/36222#issuecomment-2623967515 83 | "PrivateTmp=disconnected" => "PrivateTmpEx=disconnected", 84 | "RestrictAddressFamilies=none" => "RestrictAddressFamilies=", 85 | s => s, 86 | }; 87 | sd_cmd.extend(["-p", sd_opt].into_iter().map(ToOwned::to_owned)); 88 | } 89 | sd_cmd.extend( 90 | ["-p", "Environment=LANG=C", "--"] 91 | .into_iter() 92 | .map(ToOwned::to_owned), 93 | ); 94 | sd_cmd.extend(cmd.iter().map(|s| (*s).to_owned())); 95 | eprintln!( 96 | "{}", 97 | shlex::try_join(sd_cmd.iter().map(AsRef::as_ref)).unwrap() 98 | ); 99 | #[expect(clippy::indexing_slicing)] 100 | Command::new(&sd_cmd[0]) 101 | .args(sd_cmd) 102 | .unwrap() 103 | .assert() 104 | .success() 105 | } 106 | 107 | /// Generate all combinations of `shh run` args to test 108 | fn all_shh_run_opts() -> Vec> { 109 | let args_mode = vec![vec![], vec!["-m", "aggressive"]]; 110 | let args_fs = vec![ 111 | vec![], 112 | vec!["-w"], 113 | vec!["-w", "--merge-paths-threshold", "1"], 114 | vec!["-w", "--merge-paths-threshold", "2"], 115 | vec!["-w", "--merge-paths-threshold", "10"], 116 | vec!["-w", "--merge-paths-threshold", "100"], 117 | ]; 118 | let args_fw = vec![vec![], vec!["-f"]]; 119 | let mut combinations = Vec::with_capacity(args_mode.len() * args_fs.len() * args_fw.len()); 120 | for arg_mode in &args_mode { 121 | for arg_fs in &args_fs { 122 | for arg_fw in &args_fw { 123 | let mut args = Vec::with_capacity(arg_mode.len() + arg_fs.len() + arg_fw.len()); 124 | args.extend(arg_mode); 125 | args.extend(arg_fs); 126 | args.extend(arg_fw); 127 | combinations.push(args); 128 | } 129 | } 130 | } 131 | combinations 132 | } 133 | 134 | #[test] 135 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 136 | fn systemd_run_true() { 137 | let cmd = ["true"]; 138 | for user in SYSTEMD_RUN_USER { 139 | for shh_opts in &*ALL_SHH_RUN_OPTS { 140 | let mut shh_opts = shh_opts.clone(); 141 | if *user { 142 | shh_opts.extend(["-i", "user"]); 143 | } 144 | eprintln!("shh run option: {}", shh_opts.join(" ")); 145 | let sd_opts = generate_options(&cmd, &shh_opts); 146 | let _ = systemd_run(&cmd, &sd_opts, *user); 147 | } 148 | } 149 | } 150 | 151 | #[test] 152 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 153 | fn systemd_run_write_dev_null() { 154 | let cmd = ["sh", "-c", ": > /dev/null"]; 155 | for user in SYSTEMD_RUN_USER { 156 | for shh_opts in &*ALL_SHH_RUN_OPTS { 157 | let mut shh_opts = shh_opts.clone(); 158 | if *user { 159 | shh_opts.extend(["-i", "user"]); 160 | } 161 | eprintln!("shh run option: {}", shh_opts.join(" ")); 162 | let sd_opts = generate_options(&cmd, &shh_opts); 163 | let _ = systemd_run(&cmd, &sd_opts, *user); 164 | } 165 | } 166 | } 167 | 168 | #[test] 169 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 170 | fn systemd_run_ls_dev() { 171 | let cmd = ["ls", "/dev/"]; 172 | for user in SYSTEMD_RUN_USER { 173 | for shh_opts in &*ALL_SHH_RUN_OPTS { 174 | let mut shh_opts = shh_opts.clone(); 175 | if *user { 176 | shh_opts.extend(["-i", "user"]); 177 | } 178 | eprintln!("shh run option: {}", shh_opts.join(" ")); 179 | let sd_opts = generate_options(&cmd, &shh_opts); 180 | let asrt = systemd_run(&cmd, &sd_opts, *user); 181 | asrt.stdout(predicate::str::contains("block")) 182 | .stdout(predicate::str::contains("char")) 183 | .stdout(predicate::str::contains("log")); 184 | } 185 | } 186 | } 187 | 188 | #[test] 189 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 190 | fn systemd_run_ls_proc() { 191 | let cmd = ["ls", "/proc/1"]; 192 | for user in SYSTEMD_RUN_USER { 193 | for shh_opts in &*ALL_SHH_RUN_OPTS { 194 | let mut shh_opts = shh_opts.clone(); 195 | if *user { 196 | shh_opts.extend(["-i", "user"]); 197 | } 198 | eprintln!("shh run option: {}", shh_opts.join(" ")); 199 | let sd_opts = generate_options(&cmd, &shh_opts); 200 | let _ = systemd_run(&cmd, &sd_opts, *user); 201 | } 202 | } 203 | } 204 | 205 | #[test] 206 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 207 | fn systemd_run_read_kallsyms() { 208 | let cmd = ["head", "/proc/kallsyms"]; 209 | for user in SYSTEMD_RUN_USER { 210 | for shh_opts in &*ALL_SHH_RUN_OPTS { 211 | let mut shh_opts = shh_opts.clone(); 212 | if *user { 213 | shh_opts.extend(["-i", "user"]); 214 | } 215 | eprintln!("shh run option: {}", shh_opts.join(" ")); 216 | let sd_opts = generate_options(&cmd, &shh_opts); 217 | let _ = systemd_run(&cmd, &sd_opts, *user); 218 | } 219 | } 220 | } 221 | 222 | #[test] 223 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 224 | fn systemd_run_ls_modules() { 225 | let cmd = ["ls", "/usr/lib/modules/"]; 226 | for user in SYSTEMD_RUN_USER { 227 | for shh_opts in &*ALL_SHH_RUN_OPTS { 228 | let mut shh_opts = shh_opts.clone(); 229 | if *user { 230 | shh_opts.extend(["-i", "user"]); 231 | } 232 | eprintln!("shh run option: {}", shh_opts.join(" ")); 233 | let sd_opts = generate_options(&cmd, &shh_opts); 234 | let _ = systemd_run(&cmd, &sd_opts, *user); 235 | } 236 | } 237 | } 238 | 239 | #[test] 240 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 241 | fn systemd_run_dmesg() { 242 | let cmd = ["dmesg"]; 243 | for shh_opts in &*ALL_SHH_RUN_OPTS { 244 | eprintln!("shh run option: {}", shh_opts.join(" ")); 245 | let sd_opts = generate_options(&cmd, shh_opts); 246 | let asrt = systemd_run(&cmd, &sd_opts, false); 247 | asrt.stdout(predicate::str::is_match(KERNEL_LOG_REGEX).unwrap()); 248 | } 249 | } 250 | 251 | #[test] 252 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 253 | fn systemd_run_systemctl() { 254 | let cmd = ["systemctl"]; 255 | for user in SYSTEMD_RUN_USER { 256 | for shh_opts in &*ALL_SHH_RUN_OPTS { 257 | let mut shh_opts = shh_opts.clone(); 258 | if *user { 259 | shh_opts.extend(["-i", "user"]); 260 | } 261 | eprintln!("shh run option: {}", shh_opts.join(" ")); 262 | let sd_opts = generate_options(&cmd, &shh_opts); 263 | let _ = systemd_run(&cmd, &sd_opts, *user); 264 | } 265 | } 266 | } 267 | 268 | #[test] 269 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 270 | fn systemd_run_ss() { 271 | let cmd = ["ss", "-l"]; 272 | for user in SYSTEMD_RUN_USER { 273 | for shh_opts in &*ALL_SHH_RUN_OPTS { 274 | let mut shh_opts = shh_opts.clone(); 275 | if *user { 276 | shh_opts.extend(["-i", "user"]); 277 | } 278 | eprintln!("shh run option: {}", shh_opts.join(" ")); 279 | let sd_opts = generate_options(&cmd, &shh_opts); 280 | let _ = systemd_run(&cmd, &sd_opts, *user); 281 | } 282 | } 283 | } 284 | 285 | #[test] 286 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 287 | fn systemd_run_mmap_wx() { 288 | let cmd = [ 289 | "python3", 290 | "-c", 291 | "import mmap; mmap.mmap(-1, 4096, prot=mmap.PROT_WRITE|mmap.PROT_EXEC)", 292 | ]; 293 | for user in SYSTEMD_RUN_USER { 294 | for shh_opts in &*ALL_SHH_RUN_OPTS { 295 | let mut shh_opts = shh_opts.clone(); 296 | if *user { 297 | shh_opts.extend(["-i", "user"]); 298 | } 299 | eprintln!("shh run option: {}", shh_opts.join(" ")); 300 | let sd_opts = generate_options(&cmd, &shh_opts); 301 | let _ = systemd_run(&cmd, &sd_opts, *user); 302 | } 303 | } 304 | } 305 | 306 | #[test] 307 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 308 | fn systemd_run_sched_realtime() { 309 | let cmd = [ 310 | "python3", 311 | "-c", 312 | "import os; os.sched_setscheduler(0, os.SCHED_RR, os.sched_param(os.sched_get_priority_min(os.SCHED_RR)))", 313 | ]; 314 | for shh_opts in &*ALL_SHH_RUN_OPTS { 315 | eprintln!("shh run option: {}", shh_opts.join(" ")); 316 | let sd_opts = generate_options(&cmd, shh_opts); 317 | let _ = systemd_run(&cmd, &sd_opts, false); 318 | } 319 | } 320 | 321 | #[test] 322 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 323 | fn systemd_run_bind() { 324 | let cmd = [ 325 | "python3", 326 | "-c", 327 | "import socket; s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.bind((\"127.0.0.1\", 1234))", 328 | ]; 329 | for user in SYSTEMD_RUN_USER { 330 | for shh_opts in &*ALL_SHH_RUN_OPTS { 331 | let mut shh_opts = shh_opts.clone(); 332 | if *user { 333 | shh_opts.extend(["-i", "user"]); 334 | } 335 | eprintln!("shh run option: {}", shh_opts.join(" ")); 336 | let sd_opts = generate_options(&cmd, &shh_opts); 337 | let _ = systemd_run(&cmd, &sd_opts, *user); 338 | } 339 | } 340 | } 341 | 342 | #[test] 343 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 344 | fn systemd_run_sock_packet() { 345 | let cmd = [ 346 | "python3", 347 | "-c", 348 | "import socket; socket.socket(socket.AF_PACKET, socket.SOCK_RAW)", 349 | ]; 350 | for shh_opts in &*ALL_SHH_RUN_OPTS { 351 | eprintln!("shh run option: {}", shh_opts.join(" ")); 352 | let sd_opts = generate_options(&cmd, shh_opts); 353 | let _ = systemd_run(&cmd, &sd_opts, false); 354 | } 355 | } 356 | 357 | #[test] 358 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 359 | fn systemd_run_syslog() { 360 | let cmd = ["dmesg", "-S"]; 361 | for shh_opts in &*ALL_SHH_RUN_OPTS { 362 | eprintln!("shh run option: {}", shh_opts.join(" ")); 363 | let sd_opts = generate_options(&cmd, shh_opts); 364 | let _ = systemd_run(&cmd, &sd_opts, false); 365 | } 366 | } 367 | 368 | #[test] 369 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 370 | fn systemd_run_mknod() { 371 | let tmp_dir = tempfile::tempdir().unwrap(); 372 | 373 | let pipe_path = tmp_dir.path().join("pipe"); 374 | let cmd = ["mknod", pipe_path.as_os_str().to_str().unwrap(), "p"]; 375 | for shh_opts in &*ALL_SHH_RUN_OPTS { 376 | eprintln!("shh run option: {}", shh_opts.join(" ")); 377 | let mut sd_opts = generate_options(&cmd, shh_opts); 378 | let _ = fs::remove_file(&pipe_path); 379 | 380 | sd_opts.push(format!("BindPaths={}", tmp_dir.path().to_str().unwrap())); 381 | if let Some(inaccessible_path_opt) = sd_opts 382 | .iter_mut() 383 | .find(|o| o.starts_with("InaccessiblePaths=")) 384 | { 385 | *inaccessible_path_opt = inaccessible_path_opt 386 | .split(' ') 387 | .filter(|e| e.strip_prefix('-').unwrap_or(e) != "/tmp") 388 | .collect::>() 389 | .join(" "); 390 | } 391 | 392 | let _ = systemd_run(&cmd, &sd_opts, false); 393 | assert!(fs::metadata(&pipe_path).unwrap().file_type().is_fifo()); 394 | fs::remove_file(&pipe_path).unwrap(); 395 | } 396 | 397 | let dev_path = tmp_dir.path().join("dev"); 398 | let cmd = [ 399 | "mknod", 400 | dev_path.as_os_str().to_str().unwrap(), 401 | "b", 402 | "255", 403 | "255", 404 | ]; 405 | for shh_opts in &*ALL_SHH_RUN_OPTS { 406 | eprintln!("shh run option: {}", shh_opts.join(" ")); 407 | let mut sd_opts = generate_options(&cmd, shh_opts); 408 | let _ = fs::remove_file(&dev_path); 409 | 410 | sd_opts.push(format!("BindPaths={}", tmp_dir.path().to_str().unwrap())); 411 | if let Some(inaccessible_path_opt) = sd_opts 412 | .iter_mut() 413 | .find(|o| o.starts_with("InaccessiblePaths=")) 414 | { 415 | *inaccessible_path_opt = inaccessible_path_opt 416 | .split(' ') 417 | .filter(|e| e.strip_prefix('-').unwrap_or(e) != "/tmp") 418 | .collect::>() 419 | .join(" "); 420 | } 421 | 422 | let _ = systemd_run(&cmd, &sd_opts, false); 423 | assert!( 424 | fs::metadata(&dev_path) 425 | .unwrap() 426 | .file_type() 427 | .is_block_device() 428 | ); 429 | fs::remove_file(&dev_path).unwrap(); 430 | } 431 | } 432 | 433 | #[test] 434 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 435 | fn systemd_run_script() { 436 | let mut script = tempfile::Builder::new() 437 | .permissions(Permissions::from_mode(0o755)) 438 | .tempfile() 439 | .unwrap(); 440 | script 441 | .write_all("#!/usr/bin/env sh\necho 'from a script'".as_bytes()) 442 | .unwrap(); 443 | let script_path = script.into_temp_path(); 444 | let cmd = [script_path.to_str().unwrap()]; 445 | for user in SYSTEMD_RUN_USER { 446 | for shh_opts in &*ALL_SHH_RUN_OPTS { 447 | let mut shh_opts = shh_opts.clone(); 448 | if *user { 449 | shh_opts.extend(["-i", "user"]); 450 | } 451 | eprintln!("shh run option: {}", shh_opts.join(" ")); 452 | let sd_opts = generate_options(&cmd, &shh_opts); 453 | let asrt = systemd_run(&cmd, &sd_opts, *user); 454 | asrt.stdout(predicate::str::contains("from a script")); 455 | } 456 | } 457 | } 458 | 459 | #[test] 460 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 461 | fn systemd_run_curl() { 462 | let cmd = ["curl", "https://example.com"]; 463 | for user in SYSTEMD_RUN_USER { 464 | for shh_opts in &*ALL_SHH_RUN_OPTS { 465 | let mut shh_opts = shh_opts.clone(); 466 | if *user { 467 | shh_opts.extend(["-i", "user"]); 468 | } 469 | eprintln!("shh run option: {}", shh_opts.join(" ")); 470 | let sd_opts = generate_options(&cmd, &shh_opts); 471 | let asrt = systemd_run(&cmd, &sd_opts, *user); 472 | asrt.stdout(predicate::str::contains("")); 473 | } 474 | } 475 | } 476 | 477 | #[test] 478 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 479 | fn systemd_run_ping_4() { 480 | let cmd = ["ping", "-c", "1", "-4", "127.0.0.1"]; 481 | for user in SYSTEMD_RUN_USER { 482 | for shh_opts in &*ALL_SHH_RUN_OPTS { 483 | let mut shh_opts = shh_opts.clone(); 484 | if *user { 485 | shh_opts.extend(["-i", "user"]); 486 | } 487 | eprintln!("shh run option: {}", shh_opts.join(" ")); 488 | let sd_opts = generate_options(&cmd, &shh_opts); 489 | let asrt = systemd_run(&cmd, &sd_opts, *user); 490 | asrt.stdout(predicate::str::contains( 491 | "127.0.0.1 ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss", 492 | )); 493 | } 494 | } 495 | } 496 | 497 | #[test] 498 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 499 | fn systemd_run_ping_6() { 500 | let cmd = ["ping", "-c", "1", "-6", "::1"]; 501 | for user in SYSTEMD_RUN_USER { 502 | for shh_opts in &*ALL_SHH_RUN_OPTS { 503 | let mut shh_opts = shh_opts.clone(); 504 | if *user { 505 | shh_opts.extend(["-i", "user"]); 506 | } 507 | eprintln!("shh run option: {}", shh_opts.join(" ")); 508 | let sd_opts = generate_options(&cmd, &shh_opts); 509 | let asrt = systemd_run(&cmd, &sd_opts, *user); 510 | asrt.stdout(predicate::str::contains( 511 | "::1 ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss", 512 | )); 513 | } 514 | } 515 | } 516 | 517 | fn del_netns(ns: &str) { 518 | let mut cmd = process::Command::new("ip"); 519 | cmd.args(["netns", "del", ns]); 520 | assert!(cmd.status().unwrap().success()); 521 | assert!(!fs::exists(format!("/run/netns/{ns}")).unwrap()); 522 | } 523 | 524 | #[test] 525 | #[cfg_attr(not(feature = "int-tests-as-root"), ignore)] 526 | fn systemd_netns_create() { 527 | let ns = format!("t{}", rand::random::()); 528 | let cmd = ["ip", "netns", "add", &ns]; 529 | for shh_opts in &*ALL_SHH_RUN_OPTS { 530 | eprintln!("shh run option: {}", shh_opts.join(" ")); 531 | let sd_opts = generate_options(&cmd, shh_opts); 532 | assert!(fs::exists(format!("/run/netns/{ns}")).unwrap()); 533 | del_netns(&ns); 534 | let _ = systemd_run(&cmd, &sd_opts, false); 535 | assert!(fs::exists(format!("/run/netns/{ns}")).unwrap()); 536 | del_netns(&ns); 537 | } 538 | } 539 | --------------------------------------------------------------------------------