From d6e861520c854ce3a4309147dda454b6d673ff54 Mon Sep 17 00:00:00 2001 From: rifky adni putra Date: Sun, 28 Jun 2026 03:05:16 +0800 Subject: [PATCH 1/3] feat: sandboxed WASM security checks behind wasm-plugins feature Add an optional WASM plugin boundary for security checks, gated behind the off-by-default `wasm-plugins` feature so the standard build stays wasmtime-free. - wit/security-check.wit: canonical plugin contract (host grants read-file; plugin exports scan); host bindgen consumes it as the single source of truth - core/plugins: PluginHost (wasmtime engine, fuel-metered, deny-by-default read allowlist under /etc) + WasmAuditor + CompositeAuditor that merges native and WASM findings; apply() stays native-only - platform.rs: wrap the native auditor via plugins::security_auditor when the feature is on, transparent to the Security screen; falls back to native if no plugins load - examples/wasm-check-sshd: example guest component (PermitRootLogin check) - make wasm-example: build + componentize the example Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + Cargo.lock | 938 +++++++++++++++++++++++++++- Cargo.toml | 4 + Makefile | 15 +- cli/Cargo.toml | 7 + cli/src/core/mod.rs | 2 + cli/src/core/platform.rs | 13 +- cli/src/core/plugins/mod.rs | 224 +++++++ examples/wasm-check-sshd/Cargo.toml | 15 + examples/wasm-check-sshd/README.md | 55 ++ examples/wasm-check-sshd/src/lib.rs | 40 ++ wit/security-check.wit | 26 + 12 files changed, 1335 insertions(+), 8 deletions(-) create mode 100644 cli/src/core/plugins/mod.rs create mode 100644 examples/wasm-check-sshd/Cargo.toml create mode 100644 examples/wasm-check-sshd/README.md create mode 100644 examples/wasm-check-sshd/src/lib.rs create mode 100644 wit/security-check.wit diff --git a/.gitignore b/.gitignore index f94425e..43d210d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ # Rust /target/ crates/*/target/ +examples/*/target/ + +# Built WASM example components +examples/**/*.component.wasm # SQLite WAL files *.db-shm diff --git a/Cargo.lock b/Cargo.lock index 5aa84cf..4a0e78d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,27 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -82,6 +103,21 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object 0.37.3", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "async-trait" version = "0.1.89" @@ -108,6 +144,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -178,6 +220,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -247,6 +291,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -288,6 +341,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -297,6 +359,113 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-bforest" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ba4f80548f22dc9c43911907b5e322c5555544ee85f785115701e6a28c9abe1" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005884e3649c3e5ff2dc79e8a94b138f11569cc08a91244a292714d2a86e9156" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4036255ec33ce9a37495dfbcfc4e1118fd34e693eff9a1e106336b7cd16a9b" +dependencies = [ + "bumpalo", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.14.5", + "log", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7ca74f4b68319da11d39e894437cb6e20ec7c2e11fbbda823c3bf207beedff7" +dependencies = [ + "cranelift-codegen-shared", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e54f433a0269c4187871aa06d452214d5515d228d5bdc22219585e9eef895" + +[[package]] +name = "cranelift-control" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29cb4018f5bf59fb53f515fa9d80e6f8c5ce19f198dc538984ebd23ecf8965ec" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "305399fd781a2953ac78c1396f02ff53144f39c33eb7fc7789cf4e8936d13a96" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9230b460a128d53653456137751d27baf567947a3ab8c0c4d6e31fd08036d81e" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b961e24ae3ec9813a24a15ae64bbd2a42e4de4d79a7f3225a412e3b94e78d1c8" + +[[package]] +name = "cranelift-native" +version = "0.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d5bd76df6c9151188dfa428c863b33da5b34561b67f43c0cf3f24a794f9fa1f" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + [[package]] name = "crc" version = "3.4.0" @@ -312,6 +481,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -356,7 +534,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -415,6 +593,15 @@ dependencies = [ "syn", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -438,6 +625,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -464,6 +672,27 @@ dependencies = [ "serde", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -502,6 +731,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -605,6 +840,28 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "fxprof-processed-profile" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" +dependencies = [ + "bitflags", + "debugid", + "fxhash", + "serde", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -626,6 +883,39 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "serde", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -796,6 +1086,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -831,6 +1127,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -885,6 +1183,36 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -904,6 +1232,18 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.182" @@ -945,6 +1285,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -975,6 +1321,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1000,6 +1355,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "mio" version = "1.1.1" @@ -1088,6 +1452,27 @@ dependencies = [ "libm", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", + "memchr", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1189,6 +1574,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "postlab" version = "0.2.0" @@ -1212,6 +1609,7 @@ dependencies = [ "tracing-subscriber", "unicode-width 0.2.0", "walkdir", + "wasmtime", "whoami", ] @@ -1242,6 +1640,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulley-interpreter" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b8d81cf799e20564931e9867ca32de545188c6ee4c2e0f6e41d32f0c7dc6fb" +dependencies = [ + "cranelift-bitset", + "log", + "sptr", +] + [[package]] name = "quote" version = "1.0.44" @@ -1251,6 +1670,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -1278,7 +1703,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -1340,6 +1765,30 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regalloc2" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12908dbeb234370af84d0579b9f68258a0f67e201412dd9a2814e6f45b2fc0f0" +dependencies = [ + "hashbrown 0.14.5", + "log", + "rustc-hash", + "slice-group-by", + "smallvec", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1377,6 +1826,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "0.38.44" @@ -1386,10 +1847,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1417,6 +1891,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.228" @@ -1565,6 +2049,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slice-group-by" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7" + [[package]] name = "smallvec" version = "1.15.1" @@ -1603,6 +2093,12 @@ dependencies = [ "der", ] +[[package]] +name = "sptr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" + [[package]] name = "sqlx" version = "0.8.6" @@ -1622,7 +2118,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "crc", "crossbeam-queue", @@ -1695,7 +2191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "bytes", @@ -1737,7 +2233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "crc", @@ -1885,6 +2381,21 @@ dependencies = [ "windows", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2163,6 +2674,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.8" @@ -2187,6 +2704,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2221,6 +2748,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -2272,6 +2808,327 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.219.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aa79bcd666a043b58f5fa62b221b0b914dd901e6f620e8ab7371057a797f3e1" +dependencies = [ + "leb128", + "wasmparser 0.219.2", +] + +[[package]] +name = "wasm-encoder" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" +dependencies = [ + "leb128fmt", + "wasmparser 0.246.2", +] + +[[package]] +name = "wasmparser" +version = "0.219.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5220ee4c6ffcc0cb9d7c47398052203bc902c8ef3985b0c8134118440c0b2921" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.219.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68c93bcc5e934985afd8b65214bdd77abd3863b2e1855eae1b07a11c4ef30a8" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.219.2", +] + +[[package]] +name = "wasmtime" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b79302e3e084713249cc5622e8608e7410afdeeea8c8026d04f491d1fab0b4b" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "fxprof-processed-profile", + "gimli", + "hashbrown 0.14.5", + "indexmap", + "ittapi", + "libc", + "libm", + "log", + "mach2", + "memfd", + "object 0.36.7", + "once_cell", + "paste", + "postcard", + "psm", + "pulley-interpreter", + "rayon", + "rustix 0.38.44", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "sptr", + "target-lexicon", + "wasm-encoder 0.219.2", + "wasmparser 0.219.2", + "wasmtime-asm-macros", + "wasmtime-cache", + "wasmtime-component-macro", + "wasmtime-component-util", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-debug", + "wasmtime-jit-icache-coherence", + "wasmtime-slab", + "wasmtime-versioned-export-macros", + "wasmtime-winch", + "wat", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-asm-macros" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe53a24e7016a5222875d8ca3ad6024b464465985693c42098cd0bb710002c28" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-cache" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0677a7e76c24746b68e3657f7cc50c0ff122ee7e97bbda6e710c1b790ebc93cb" +dependencies = [ + "anyhow", + "base64 0.21.7", + "directories-next", + "log", + "postcard", + "rustix 0.38.44", + "serde", + "serde_derive", + "sha2", + "toml", + "windows-sys 0.59.0", + "zstd", +] + +[[package]] +name = "wasmtime-component-macro" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e118acbd2bc09b32ad8606bc7cef793bf5019c1b107772e64dc6c76b5055d40b" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-component-util", + "wasmtime-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-component-util" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6db4f3ee18c699629eabb9c64e77efe5a93a5137f098db7cab295037ba41c2" + +[[package]] +name = "wasmtime-cranelift" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b87e6c78f562b50aff1afd87ff32a57e241424c846c1c8f3c5fd352d2d62906" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.12.1", + "log", + "object 0.36.7", + "smallvec", + "target-lexicon", + "thiserror 1.0.69", + "wasmparser 0.219.2", + "wasmtime-environ", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-environ" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25bfeaa16432d59a0706e2463d315ef4c9ebcfaf5605670b99d46373bdf9f27" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object 0.36.7", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.219.2", + "wasmparser 0.219.2", + "wasmprinter", + "wasmtime-component-util", +] + +[[package]] +name = "wasmtime-fiber" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759ab0caa3821a6211743fe1eed448ab9df439e3af6c60dea15486c055611806" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "rustix 0.38.44", + "wasmtime-asm-macros", + "wasmtime-versioned-export-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-jit-debug" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2a056056e9ac6916c2b8e4743408560300c1355e078c344211f13210d449b3" +dependencies = [ + "object 0.36.7", + "rustix 0.38.44", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-jit-icache-coherence" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91b218a92866f74f35162f5d03a4e0f62cd0e1cc624285b1014275e5d4575fad" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-slab" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d5f8acf677ee6b3b8ba400dd9753ea4769e56a95c4b30b045ac6d2d54b2f8ea" + +[[package]] +name = "wasmtime-versioned-export-macros" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df09be00c38f49172ca9936998938476e3f2df782673a39ae2ef9fb0838341b6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasmtime-winch" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89d6b5297bea14d8387c3974b2b011de628cc9b188f135cec752b74fd368964b" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object 0.36.7", + "target-lexicon", + "wasmparser 0.219.2", + "wasmtime-cranelift", + "wasmtime-environ", + "winch-codegen", +] + +[[package]] +name = "wasmtime-wit-bindgen" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3963c9c29df91564d8bd181eb00d0dbaeafa1b2a01e15952bb7391166b704e" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "wit-parser", +] + +[[package]] +name = "wast" +version = "246.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3fe8e3bf88ad96d031b4181ddbd64634b17cb0d06dfc3de589ef43591a9a62" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width 0.2.0", + "wasm-encoder 0.246.2", +] + +[[package]] +name = "wat" +version = "1.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd7fda1199b94fff395c2d19a153f05dbe7807630316fa9673367666fd2ad8c" +dependencies = [ + "wast", +] + [[package]] name = "web-sys" version = "0.3.91" @@ -2324,6 +3181,23 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b42b678c8651ec4900d7600037d235429fc985c31cbc33515885ec0d2a9e158" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "wasmparser 0.219.2", + "wasmtime-cranelift", + "wasmtime-environ", +] + [[package]] name = "windows" version = "0.52.0" @@ -2633,6 +3507,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-parser" +version = "0.219.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca004bb251010fe956f4a5b9d4bf86b4e415064160dd6669569939e8cbf2504f" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.219.2", +] + [[package]] name = "writeable" version = "0.6.2" @@ -2747,3 +3645,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 85ace6c..7f92ede 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ resolver = "2" members = [ "cli", ] +# Guest builds for wasm32; keep it out of the host workspace/target matrix. +exclude = [ + "examples/wasm-check-sshd", +] [profile.release] strip = true diff --git a/Makefile b/Makefile index cd79112..b41bd11 100644 --- a/Makefile +++ b/Makefile @@ -13,10 +13,16 @@ else RELEASE_DIR := target/$(TARGET)/release endif -.PHONY: help build build-release release build-linux build-all run dev info list check test clean install link link-release docker-build docker-shell docker-cp docker-release +.PHONY: help build build-release release build-linux build-all run dev info list check test clean install link link-release docker-build docker-shell docker-cp docker-release wasm-example LINUX_TARGET := x86_64-unknown-linux-gnu +# Example WASM security-check guest. Excluded from the workspace, so it builds +# into its own target dir under the crate. +WASM_EXAMPLE_DIR := examples/wasm-check-sshd +WASM_EXAMPLE_MODULE := $(WASM_EXAMPLE_DIR)/target/wasm32-unknown-unknown/release/postlab_check_sshd.wasm +WASM_EXAMPLE_COMPONENT := $(WASM_EXAMPLE_DIR)/postlab_check_sshd.component.wasm + # --------------------------------------------------------------------------- # Help # --------------------------------------------------------------------------- @@ -65,6 +71,13 @@ link-release: ## Symlink release binary → binaries//postlab @mkdir -p binaries/$(TARGET) @ln -sf ../../$(RELEASE_DIR)/postlab binaries/$(TARGET)/postlab +wasm-example: ## Build example WASM check component (requires: rustup target add wasm32-unknown-unknown; cargo install wasm-tools) + cargo build --release --manifest-path $(WASM_EXAMPLE_DIR)/Cargo.toml --target wasm32-unknown-unknown + wasm-tools component new $(WASM_EXAMPLE_MODULE) -o $(WASM_EXAMPLE_COMPONENT) + @echo " Component: $(WASM_EXAMPLE_COMPONENT)" + @echo " Install: sudo cp $(WASM_EXAMPLE_COMPONENT) /etc/postlab/plugins/" + @ls -lh $(WASM_EXAMPLE_COMPONENT) + # --------------------------------------------------------------------------- # Run # --------------------------------------------------------------------------- diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 741856e..7a3a0fe 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -4,8 +4,15 @@ version = "0.2.0" edition = "2021" license = "Apache-2.0" +[features] +default = [] +# Experimental: load sandboxed WASM security checks. Pulls in wasmtime (heavy); +# kept off the default build so `make check`/`make test` stay fast and dep-light. +wasm-plugins = ["dep:wasmtime"] + [dependencies] async-trait = "0.1" +wasmtime = { version = "27", optional = true } tokio = { workspace = true } anyhow = { workspace = true } serde = { workspace = true } diff --git a/cli/src/core/mod.rs b/cli/src/core/mod.rs index 3e1cfea..317f8ef 100644 --- a/cli/src/core/mod.rs +++ b/cli/src/core/mod.rs @@ -7,6 +7,8 @@ pub mod models; pub mod nats; pub mod packages; pub mod platform; +#[cfg(feature = "wasm-plugins")] +pub mod plugins; pub mod portcheck; pub mod processes; pub mod security; diff --git a/cli/src/core/platform.rs b/cli/src/core/platform.rs index d860185..c685329 100644 --- a/cli/src/core/platform.rs +++ b/cli/src/core/platform.rs @@ -76,7 +76,7 @@ pub fn detect() -> Result { let os = OsFamily::detect(); let system = Arc::new(SysinfoManager::new()); let processes = Arc::new(SysinfoProcessManager::new()); - let security = Arc::new(DefaultSecurityAuditor::new(os)); + let security = build_security_auditor(os); let fail2ban = Arc::new(DefaultFail2Ban) as Arc; let gateway = Arc::new(CaddyManager); let tailscale = Arc::new(TailscaleManager); @@ -119,6 +119,17 @@ pub fn detect() -> Result { }) } +#[cfg(feature = "wasm-plugins")] +fn build_security_auditor(os: OsFamily) -> Arc { + let native: Arc = Arc::new(DefaultSecurityAuditor::new(os)); + crate::core::plugins::security_auditor(native) +} + +#[cfg(not(feature = "wasm-plugins"))] +fn build_security_auditor(os: OsFamily) -> Arc { + Arc::new(DefaultSecurityAuditor::new(os)) +} + fn detect_firewall() -> Arc { if which("ufw") { return Arc::new(UfwManager); diff --git a/cli/src/core/plugins/mod.rs b/cli/src/core/plugins/mod.rs new file mode 100644 index 0000000..6d8f0a0 --- /dev/null +++ b/cli/src/core/plugins/mod.rs @@ -0,0 +1,224 @@ +use crate::core::{ + models::{SecurityFinding, Severity}, + security::SecurityAuditor, +}; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use wasmtime::component::{Component, Linker}; +use wasmtime::{Config, Engine, Store}; + +// The plugin contract. Single source of truth lives in wit/security-check.wit +// (path is relative to this crate's manifest dir); guest components generate +// against the same file. +wasmtime::component::bindgen!({ + path: "../wit", + world: "security-plugin", +}); + +use postlab::plugin::host::Host; + +// Per-scan CPU budget. A runaway or hostile check traps instead of hanging the +// TUI; tune once real plugins exist. +const FUEL_PER_SCAN: u64 = 50_000_000; + +const PLUGIN_DIR: &str = "/etc/postlab/plugins"; + +// Plugins may only read where security configs live. Deny-by-default elsewhere. +const ALLOWED_ROOTS: &[&str] = &["/etc"]; + +// Returns the native auditor unchanged when no plugins are present or loading +// fails, so a broken plugin dir can never disable the built-in checks. +pub fn security_auditor(native: Arc) -> Arc { + let roots = ALLOWED_ROOTS.iter().map(PathBuf::from).collect(); + let mut host = match PluginHost::new(roots) { + Ok(h) => h, + Err(e) => { + tracing::warn!(error = %e, "wasm plugin host unavailable"); + return native; + } + }; + match host.load_dir(PLUGIN_DIR) { + Ok(0) => native, + Ok(n) => { + tracing::info!(count = n, "loaded wasm security checks"); + Arc::new(CompositeAuditor { + native, + wasm: WasmAuditor::new(host), + }) + } + Err(e) => { + tracing::warn!(error = %e, "failed loading wasm plugins"); + native + } + } +} + +struct HostState { + allowed_roots: Arc>, +} + +impl Host for HostState { + fn read_file(&mut self, path: String) -> Result { + let requested = PathBuf::from(&path); + // Deny-by-default: a plugin may only read under host-granted roots, and + // never via `..` traversal out of them. + let allowed = self + .allowed_roots + .iter() + .any(|root| requested.starts_with(root)) + && !path.contains(".."); + if !allowed { + return Err(format!("read denied: {path}")); + } + std::fs::read_to_string(&requested).map_err(|e| e.to_string()) + } +} + +struct LoadedPlugin { + name: String, + component: Component, +} + +pub struct PluginHost { + engine: Engine, + plugins: Vec, + allowed_roots: Arc>, +} + +impl PluginHost { + pub fn new(allowed_roots: Vec) -> Result { + let mut config = Config::new(); + config.consume_fuel(true); + let engine = Engine::new(&config).context("init wasmtime engine")?; + Ok(Self { + engine, + plugins: Vec::new(), + allowed_roots: Arc::new(allowed_roots), + }) + } + + pub fn load_dir(&mut self, dir: impl AsRef) -> Result { + let dir = dir.as_ref(); + if !dir.is_dir() { + return Ok(0); + } + let mut loaded = 0; + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.extension().and_then(|e| e.to_str()) != Some("wasm") { + continue; + } + let component = Component::from_file(&self.engine, &path) + .with_context(|| format!("compile {}", path.display()))?; + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("plugin") + .to_string(); + self.plugins.push(LoadedPlugin { name, component }); + loaded += 1; + } + Ok(loaded) + } + + fn run_scans(&self) -> Vec { + let mut findings = Vec::new(); + for plugin in &self.plugins { + match self.scan_one(plugin) { + Ok(mut f) => findings.append(&mut f), + // One bad plugin must not sink the whole audit. + Err(e) => tracing::warn!(plugin = %plugin.name, error = %e, "wasm check failed"), + } + } + findings + } + + fn scan_one(&self, plugin: &LoadedPlugin) -> Result> { + let mut linker: Linker = Linker::new(&self.engine); + SecurityPlugin::add_to_linker(&mut linker, |s| s)?; + + let state = HostState { + allowed_roots: Arc::clone(&self.allowed_roots), + }; + let mut store = Store::new(&self.engine, state); + store.set_fuel(FUEL_PER_SCAN)?; + + let instance = SecurityPlugin::instantiate(&mut store, &plugin.component, &linker)?; + let raw = instance.postlab_plugin_check().call_scan(&mut store)?; + Ok(raw.into_iter().map(map_finding).collect()) + } +} + +pub struct WasmAuditor { + host: Arc, +} + +impl WasmAuditor { + pub fn new(host: PluginHost) -> Self { + Self { + host: Arc::new(host), + } + } +} + +#[async_trait] +impl SecurityAuditor for WasmAuditor { + async fn scan(&self) -> Result> { + let host = Arc::clone(&self.host); + // Wasmtime calls are synchronous and CPU-bound; keep them off the reactor. + let findings = tokio::task::spawn_blocking(move || host.run_scans()).await?; + Ok(findings) + } + + // Mutating fixes stay native-only: plugins propose findings, they never + // write to disk. + async fn apply(&self, _id: &str) -> Result { + anyhow::bail!("wasm plugins cannot apply fixes") + } +} + +struct CompositeAuditor { + native: Arc, + wasm: WasmAuditor, +} + +#[async_trait] +impl SecurityAuditor for CompositeAuditor { + async fn scan(&self) -> Result> { + let mut findings = self.native.scan().await?; + // A plugin failure must not mask the native audit; log and carry on. + match self.wasm.scan().await { + Ok(mut extra) => findings.append(&mut extra), + Err(e) => tracing::warn!(error = %e, "wasm checks failed"), + } + Ok(findings) + } + + // Fix ids come only from native findings, so apply always routes there. + async fn apply(&self, id: &str) -> Result { + self.native.apply(id).await + } +} + +fn map_finding(f: exports::postlab::plugin::check::Finding) -> SecurityFinding { + SecurityFinding { + id: f.id, + title: f.title, + severity: map_severity(f.severity), + description: f.description, + file_path: f.file_path, + fix_description: f.fix_description, + } +} + +fn map_severity(level: u8) -> Severity { + match level { + 0 => Severity::Critical, + 1 => Severity::High, + 2 => Severity::Medium, + 3 => Severity::Low, + _ => Severity::Info, + } +} diff --git a/examples/wasm-check-sshd/Cargo.toml b/examples/wasm-check-sshd/Cargo.toml new file mode 100644 index 0000000..5405606 --- /dev/null +++ b/examples/wasm-check-sshd/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "postlab-check-sshd" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "0.36" + +[profile.release] +opt-level = "s" +strip = true diff --git a/examples/wasm-check-sshd/README.md b/examples/wasm-check-sshd/README.md new file mode 100644 index 0000000..cdb6820 --- /dev/null +++ b/examples/wasm-check-sshd/README.md @@ -0,0 +1,55 @@ +# Example WASM security check: sshd PermitRootLogin + +A minimal guest [component](../../wit/security-check.wit) implementing the +`postlab:plugin/security-plugin` world. It asks the host to read +`/etc/ssh/sshd_config` and emits a critical finding if `PermitRootLogin yes` is +set. This mirrors postlab's native `ssh_root_login` check, but runs sandboxed. + +## Build + +This crate is excluded from the host workspace because it targets wasm32, so it +builds into its own `target/` dir here. It produces a **component**, not a core +module. + +```sh +# one-time toolchain +rustup target add wasm32-unknown-unknown +cargo install wasm-tools +``` + +From the repo root, the Makefile wraps both steps: + +```sh +make wasm-example # builds + componentizes → postlab_check_sshd.component.wasm +``` + +Equivalent manual steps from this directory: + +```sh +cargo build --release --target wasm32-unknown-unknown +wasm-tools component new \ + target/wasm32-unknown-unknown/release/postlab_check_sshd.wasm \ + -o postlab_check_sshd.component.wasm +``` + +(Alternatively `cargo component build --release` produces the component in one +step if you have `cargo-component` installed.) + +## Install + +The host loads every `*.wasm` under `/etc/postlab/plugins`: + +```sh +sudo mkdir -p /etc/postlab/plugins +sudo cp postlab_check_sshd.component.wasm /etc/postlab/plugins/ +``` + +Then run a postlab build with the feature enabled: + +```sh +cargo build --release -p postlab --features wasm-plugins +``` + +Open **Security → Findings** and trigger a scan; the WASM finding appears +alongside the native checks. The host grants read access only under `/etc`, so +the plugin can read `sshd_config` but nothing outside that root. diff --git a/examples/wasm-check-sshd/src/lib.rs b/examples/wasm-check-sshd/src/lib.rs new file mode 100644 index 0000000..b069e43 --- /dev/null +++ b/examples/wasm-check-sshd/src/lib.rs @@ -0,0 +1,40 @@ +wit_bindgen::generate!({ + path: "../../wit", + world: "security-plugin", +}); + +use exports::postlab::plugin::check::{Finding, Guest}; +use postlab::plugin::host::read_file; + +const SSHD_CONFIG: &str = "/etc/ssh/sshd_config"; + +struct Component; + +impl Guest for Component { + fn scan() -> Vec { + // read-file is the only capability the host grants; an error means the + // path was denied or unreadable, so there is nothing to report. + let Ok(content) = read_file(SSHD_CONFIG) else { + return Vec::new(); + }; + let enabled = content.lines().any(|l| { + let l = l.trim(); + !l.starts_with('#') + && l.to_lowercase().starts_with("permitrootlogin") + && l.contains("yes") + }); + if !enabled { + return Vec::new(); + } + vec![Finding { + id: "wasm_ssh_root_login".to_string(), + title: "SSH root login enabled".to_string(), + severity: 0, + description: "PermitRootLogin yes (reported by example wasm check)".to_string(), + file_path: Some(SSHD_CONFIG.to_string()), + fix_description: "Set PermitRootLogin no".to_string(), + }] + } +} + +export!(Component); diff --git a/wit/security-check.wit b/wit/security-check.wit new file mode 100644 index 0000000..c56f0a2 --- /dev/null +++ b/wit/security-check.wit @@ -0,0 +1,26 @@ +package postlab:plugin@0.1.0; + +// Capabilities the host grants to a plugin. Deny-by-default: a plugin can only +// touch what is declared here. Today that is read-only file access, which the +// host further restricts to an allowlist of roots. +interface host { + read-file: func(path: string) -> result; +} + +// What a security-check plugin must export. +interface check { + record finding { + id: string, + title: string, + severity: u8, // 0=critical 1=high 2=medium 3=low 4=info + description: string, + file-path: option, + fix-description: string, + } + scan: func() -> list; +} + +world security-plugin { + import host; + export check; +} From 2d9d503010d67917263c50a38cab2418cb409c2f Mon Sep 17 00:00:00 2001 From: rifky Date: Sat, 18 Jul 2026 11:08:19 +0800 Subject: [PATCH 2/3] consolidate real_home() into single canonical implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All SUDO_USER-based home directory resolution (~5 divergent copies, some with /home/{user} convention broken on macOS, one in main.rs ignoring SUDO_USER entirely) now routes through core::real_home() which uses nix::unistd::User::from_name for a correct passwd lookup on all platforms. Also: fix plugin sandbox symlink escape — resolve canonical path before authorizing reads under allowed roots (plugins run as root). --- cli/src/core/mod.rs | 9 +++++++++ cli/src/core/pi_agent/mod.rs | 11 ++--------- cli/src/core/pi_agent/rpc.rs | 2 +- cli/src/core/plugins/mod.rs | 21 ++++++++++++--------- cli/src/core/projects/mod.rs | 11 ++--------- cli/src/core/ssh/mod.rs | 5 +---- cli/src/core/tunnel/cloudflare.rs | 15 +-------------- cli/src/core/wasm_cloud/cli.rs | 2 +- cli/src/main.rs | 2 +- 9 files changed, 30 insertions(+), 48 deletions(-) diff --git a/cli/src/core/mod.rs b/cli/src/core/mod.rs index 317f8ef..2e67130 100644 --- a/cli/src/core/mod.rs +++ b/cli/src/core/mod.rs @@ -25,3 +25,12 @@ pub mod pi_agent; pub mod projects; pub use platform::Platform; + +pub fn real_home() -> String { + if let Ok(sudo_user) = std::env::var("SUDO_USER") { + if let Ok(Some(user)) = nix::unistd::User::from_name(&sudo_user) { + return user.dir.to_string_lossy().to_string(); + } + } + std::env::var("HOME").unwrap_or_default() +} diff --git a/cli/src/core/pi_agent/mod.rs b/cli/src/core/pi_agent/mod.rs index 0e48663..064b066 100644 --- a/cli/src/core/pi_agent/mod.rs +++ b/cli/src/core/pi_agent/mod.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use tokio::process::Command; +use crate::core::real_home; + // ── Data types ──────────────────────────────────────────────────────────── #[derive(Debug, Clone)] @@ -54,15 +56,6 @@ const POSTLAB_SKILL: &str = // ── Path helpers ────────────────────────────────────────────────────────── -/// Real (non-root) home: prefer SUDO_USER-derived path so postlab running -/// under sudo still finds files in the invoking user's home directory. -pub(super) fn real_home() -> String { - std::env::var("SUDO_USER") - .ok() - .map(|u| format!("/home/{}", u)) - .unwrap_or_else(|| std::env::var("HOME").unwrap_or_else(|_| "/root".into())) -} - fn pi_dir() -> PathBuf { PathBuf::from(real_home()).join(".pi") } diff --git a/cli/src/core/pi_agent/rpc.rs b/cli/src/core/pi_agent/rpc.rs index 27938fb..7b3fe1c 100644 --- a/cli/src/core/pi_agent/rpc.rs +++ b/cli/src/core/pi_agent/rpc.rs @@ -41,7 +41,7 @@ pub async fn spawn_rpc( // pi-agent doesn't run with root privileges. let sudo_uid: Option = std::env::var("SUDO_UID").ok().and_then(|s| s.parse().ok()); let sudo_gid: Option = std::env::var("SUDO_GID").ok().and_then(|s| s.parse().ok()); - let home = super::real_home(); + let home = crate::core::real_home(); let current_path = std::env::var("PATH").unwrap_or_default(); let pi_bin_dir = std::path::Path::new(&bin) diff --git a/cli/src/core/plugins/mod.rs b/cli/src/core/plugins/mod.rs index 6d8f0a0..42b8a84 100644 --- a/cli/src/core/plugins/mod.rs +++ b/cli/src/core/plugins/mod.rs @@ -61,18 +61,21 @@ struct HostState { impl Host for HostState { fn read_file(&mut self, path: String) -> Result { - let requested = PathBuf::from(&path); - // Deny-by-default: a plugin may only read under host-granted roots, and - // never via `..` traversal out of them. - let allowed = self - .allowed_roots - .iter() - .any(|root| requested.starts_with(root)) - && !path.contains(".."); + // Deny-by-default: a plugin may only read under host-granted roots. + // Resolve symlinks and `..` first, then authorize the *real* target — a + // symlink under an allowed root would otherwise escape the sandbox, and + // we're running as root. Read via the resolved path so it can't be + // re-followed elsewhere after the check. + let resolved = std::fs::canonicalize(&path).map_err(|e| e.to_string())?; + let allowed = self.allowed_roots.iter().any(|root| { + std::fs::canonicalize(root) + .map(|root| resolved.starts_with(root)) + .unwrap_or(false) + }); if !allowed { return Err(format!("read denied: {path}")); } - std::fs::read_to_string(&requested).map_err(|e| e.to_string()) + std::fs::read_to_string(&resolved).map_err(|e| e.to_string()) } } diff --git a/cli/src/core/projects/mod.rs b/cli/src/core/projects/mod.rs index 4e0f267..edbeb44 100644 --- a/cli/src/core/projects/mod.rs +++ b/cli/src/core/projects/mod.rs @@ -42,10 +42,7 @@ pub struct ProjectsManager; pub fn expand_home(path: &str) -> String { if path.starts_with('~') { - let home = std::env::var("SUDO_USER") - .ok() - .map(|u| format!("/home/{}", u)) - .unwrap_or_else(|| std::env::var("HOME").unwrap_or_else(|_| "/root".to_string())); + let home = crate::core::real_home(); path.replacen('~', &home, 1) } else { path.to_string() @@ -253,11 +250,7 @@ impl ProjectsManager { let expanded = expand_home(dir); let user = std::env::var("SUDO_USER").unwrap_or_default(); - let home = if user.is_empty() { - std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()) - } else { - format!("/home/{user}") - }; + let home = crate::core::real_home(); // .bashrc has an interactivity guard and can't be sourced from a non-interactive // process. Instead: init nvm directly (its script has no guard) and prepend the // well-known bin dirs used by nvm, pi-node, volta, and npm-global. diff --git a/cli/src/core/ssh/mod.rs b/cli/src/core/ssh/mod.rs index 56baca4..ecb6cb4 100644 --- a/cli/src/core/ssh/mod.rs +++ b/cli/src/core/ssh/mod.rs @@ -20,10 +20,7 @@ impl DefaultSshKeyManager { fn get_ssh_dir() -> PathBuf { // When running as root via sudo, use the invoking user's home so we // read/write their ~/.ssh, not /root/.ssh. - let home = std::env::var("SUDO_USER") - .ok() - .map(|u| format!("/home/{}", u)) - .unwrap_or_else(|| std::env::var("HOME").unwrap_or_default()); + let home = crate::core::real_home(); Path::new(&home).join(".ssh") } diff --git a/cli/src/core/tunnel/cloudflare.rs b/cli/src/core/tunnel/cloudflare.rs index 12f80b5..271d2c6 100644 --- a/cli/src/core/tunnel/cloudflare.rs +++ b/cli/src/core/tunnel/cloudflare.rs @@ -1,5 +1,6 @@ use super::TunnelManager; use crate::core::models::{Tunnel, TunnelRoute}; +use crate::core::real_home; use anyhow::Result; use async_trait::async_trait; @@ -500,20 +501,6 @@ async fn svc_action(action: &str) -> Result<()> { } } -/// Returns the real user's home directory. -/// When running via `sudo`, $HOME is /root but the cloudflared config lives -/// under the invoking user's home. Use SUDO_USER → getpwnam to find it. -fn real_home() -> String { - if let Ok(sudo_user) = std::env::var("SUDO_USER") { - if !sudo_user.is_empty() { - if let Ok(Some(user)) = nix::unistd::User::from_name(&sudo_user) { - return user.dir.to_string_lossy().to_string(); - } - } - } - std::env::var("HOME").unwrap_or_default() -} - fn cf_dir() -> String { format!("{}/.cloudflared", real_home()) } diff --git a/cli/src/core/wasm_cloud/cli.rs b/cli/src/core/wasm_cloud/cli.rs index 55e8e6c..622f7ce 100644 --- a/cli/src/core/wasm_cloud/cli.rs +++ b/cli/src/core/wasm_cloud/cli.rs @@ -10,7 +10,7 @@ pub struct WasmCloudCliManager; /// Returns the path to the wash binary, checking PATH and the default install location. pub fn find_wash() -> Option { - let home = std::env::var("HOME").unwrap_or_default(); + let home = crate::core::real_home(); let candidates = [ format!("{home}/.wash/bin/wash"), format!("{home}/.cargo/bin/wash"), diff --git a/cli/src/main.rs b/cli/src/main.rs index b1dc0e2..e9180d1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -598,7 +598,7 @@ fn truncate_str(s: &str, max: usize) -> String { fn expand_tilde(path: &str) -> String { if path.starts_with("~/") { - let home = std::env::var("HOME").unwrap_or_default(); + let home = crate::core::real_home(); path.replacen("~", &home, 1) } else { path.to_string() From 751a6d3c206f21f1526d944cb873a4c6620409b0 Mon Sep 17 00:00:00 2001 From: rifky Date: Sat, 18 Jul 2026 11:19:24 +0800 Subject: [PATCH 3/3] consolidate backup_file() and expand_home() into core - backup_file(): was duplicated in security/checks.rs (format-based) and ssh/mod.rs (with_extension-based, fragile for files with dots). Single canonical version in core/mod.rs using AsRef, timestamp pattern {path}.bak.{timestamp}. Removes chrono::Local and Path imports from both modules. - expand_home(): was duplicated as expand_tilde() in main.rs (~/ only) and expand_home() in projects/mod.rs (~ any). Single version in core/mod.rs with shell-correct starts_with("~/") matching. Re-exported from projects for existing callers including tui/app.rs. --- cli/src/core/mod.rs | 19 +++++++++++++++++++ cli/src/core/projects/mod.rs | 12 +++--------- cli/src/core/security/checks.rs | 15 ++------------- cli/src/core/ssh/mod.rs | 14 ++------------ cli/src/main.rs | 10 +--------- cli/src/tui/app.rs | 2 +- 6 files changed, 28 insertions(+), 44 deletions(-) diff --git a/cli/src/core/mod.rs b/cli/src/core/mod.rs index 2e67130..6590adb 100644 --- a/cli/src/core/mod.rs +++ b/cli/src/core/mod.rs @@ -26,6 +26,25 @@ pub mod projects; pub use platform::Platform; +pub fn expand_home(path: &str) -> String { + if path.starts_with("~/") { + let home = real_home(); + path.replacen("~", &home, 1) + } else { + path.to_string() + } +} + +pub async fn backup_file(path: impl AsRef) -> anyhow::Result<()> { + let path = path.as_ref(); + if path.exists() { + let ts = chrono::Local::now().format("%Y%m%dT%H%M%S"); + let backup = format!("{}.bak.{}", path.display(), ts); + tokio::fs::copy(path, &backup).await?; + } + Ok(()) +} + pub fn real_home() -> String { if let Ok(sudo_user) = std::env::var("SUDO_USER") { if let Ok(Some(user)) = nix::unistd::User::from_name(&sudo_user) { diff --git a/cli/src/core/projects/mod.rs b/cli/src/core/projects/mod.rs index edbeb44..1a8a31b 100644 --- a/cli/src/core/projects/mod.rs +++ b/cli/src/core/projects/mod.rs @@ -40,14 +40,7 @@ pub struct GitStatus { pub struct ProjectsManager; -pub fn expand_home(path: &str) -> String { - if path.starts_with('~') { - let home = crate::core::real_home(); - path.replacen('~', &home, 1) - } else { - path.to_string() - } -} +pub use crate::core::expand_home; /// Inline env for git over SSH in a non-interactive (piped) context: auto-accept a /// first-seen host key instead of blocking on the yes/no prompt, and fail fast rather @@ -347,7 +340,8 @@ impl ProjectsManager { #[cfg(test)] mod tests { - use super::{expand_home, ProjectsManager}; + use super::ProjectsManager; + use crate::core::expand_home; use std::fs; #[test] diff --git a/cli/src/core/security/checks.rs b/cli/src/core/security/checks.rs index 6458066..c0fda64 100644 --- a/cli/src/core/security/checks.rs +++ b/cli/src/core/security/checks.rs @@ -6,8 +6,6 @@ use crate::core::{ }; use anyhow::Result; use async_trait::async_trait; -use chrono::Local; -use std::path::Path; use tokio::fs; pub struct DefaultSecurityAuditor { @@ -226,17 +224,8 @@ async fn check_dnf_automatic() -> Option { // ── apply fixes ─────────────────────────────────────────────────────────── -async fn backup_file(path: &str) -> Result<()> { - if Path::new(path).exists() { - let ts = Local::now().format("%Y%m%dT%H%M%S"); - let backup = format!("{}.bak.{}", path, ts); - fs::copy(path, &backup).await?; - } - Ok(()) -} - async fn sed_in_place(path: &str, from: &str, to: &str) -> Result { - backup_file(path).await?; + crate::core::backup_file(path).await?; let content = fs::read_to_string(path).await?; let updated = content.replace(from, to); fs::write(path, &updated).await?; @@ -346,7 +335,7 @@ impl SecurityAuditor for DefaultSecurityAuditor { if !self.os.is_linux() { anyhow::bail!("ASLR fix is only applicable on Linux"); } - backup_file("/etc/sysctl.conf").await?; + crate::core::backup_file("/etc/sysctl.conf").await?; let mut content = fs::read_to_string("/etc/sysctl.conf") .await .unwrap_or_default(); diff --git a/cli/src/core/ssh/mod.rs b/cli/src/core/ssh/mod.rs index ecb6cb4..10af538 100644 --- a/cli/src/core/ssh/mod.rs +++ b/cli/src/core/ssh/mod.rs @@ -1,7 +1,6 @@ use crate::core::models::SshKey; use anyhow::Result; use async_trait::async_trait; -use chrono::Local; use std::path::{Path, PathBuf}; use tokio::fs; @@ -24,15 +23,6 @@ impl DefaultSshKeyManager { Path::new(&home).join(".ssh") } - async fn backup_authorized_keys(&self) -> Result<()> { - let path = Self::get_ssh_dir().join("authorized_keys"); - if path.exists() { - let ts = Local::now().format("%Y%m%dT%H%M%S"); - let backup = path.with_extension(format!("bak.{}", ts)); - fs::copy(&path, &backup).await?; - } - Ok(()) - } } #[async_trait] @@ -143,7 +133,7 @@ impl SshKeyManager for DefaultSshKeyManager { } async fn authorize_key(&self, key_content: &str) -> Result<()> { - self.backup_authorized_keys().await?; + crate::core::backup_file(Self::get_ssh_dir().join("authorized_keys")).await?; let ssh_dir = Self::get_ssh_dir(); fs::create_dir_all(&ssh_dir).await?; let path = ssh_dir.join("authorized_keys"); @@ -180,7 +170,7 @@ impl SshKeyManager for DefaultSshKeyManager { } async fn deauthorize_key(&self, fingerprint: &str) -> Result<()> { - self.backup_authorized_keys().await?; + crate::core::backup_file(Self::get_ssh_dir().join("authorized_keys")).await?; let path = Self::get_ssh_dir().join("authorized_keys"); if !path.exists() { return Ok(()); diff --git a/cli/src/main.rs b/cli/src/main.rs index e9180d1..c17448c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -196,7 +196,7 @@ async fn main() -> Result<()> { let cli = Cli::parse(); - let db_path = expand_tilde(&cli.database); + let db_path = crate::core::expand_home(&cli.database); if let Some(parent) = std::path::Path::new(&db_path).parent() { tokio::fs::create_dir_all(parent).await?; } @@ -596,11 +596,3 @@ fn truncate_str(s: &str, max: usize) -> String { } } -fn expand_tilde(path: &str) -> String { - if path.starts_with("~/") { - let home = crate::core::real_home(); - path.replacen("~", &home, 1) - } else { - path.to_string() - } -} diff --git a/cli/src/tui/app.rs b/cli/src/tui/app.rs index 1279df2..07ec8f1 100644 --- a/cli/src/tui/app.rs +++ b/cli/src/tui/app.rs @@ -3688,7 +3688,7 @@ impl App { self.projects.new_name_exists = if name.is_empty() { false } else { - let target = format!("{}/{}", crate::core::projects::expand_home(&self.projects.dir), name); + let target = format!("{}/{}", crate::core::expand_home(&self.projects.dir), name); std::path::Path::new(&target).exists() }; }