diff --git a/Cargo.lock b/Cargo.lock index 70ad01b..a9ef57b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,36 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +68,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +79,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,6 +88,86 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bincode" version = "1.3.3" @@ -73,18 +183,77 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -143,6 +312,137 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[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-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -156,7 +456,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", ] [[package]] @@ -165,12 +486,150 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -182,6 +641,18 @@ 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 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -190,7 +661,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -227,12 +698,263 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hudsucker" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb9d62508d54891fe529dc3a3e169aa7938b89898ba5ab0431ac5bafe66a249" +dependencies = [ + "async-compression", + "bstr", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tungstenite", + "hyper-util", + "moka", + "rand", + "rcgen", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-graceful", + "tokio-rustls", + "tokio-tungstenite", + "tokio-util", + "tracing", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 0.26.11", +] + +[[package]] +name = "hyper-tungstenite" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" +dependencies = [ + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-tungstenite", + "tungstenite", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -269,115 +991,314 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[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.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[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.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", ] [[package]] -name = "jiff-static" -version = "0.2.23" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ - "proc-macro2", - "quote", - "syn", + "memchr", + "minimal-lexical", ] [[package]] -name = "jiff-tzdb" -version = "0.1.6" +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "jiff-tzdb", + "num-integer", + "num-traits", ] [[package]] -name = "js-sys" -version = "0.3.94" +name = "num-conv" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "once_cell", - "wasm-bindgen", + "num-traits", ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] [[package]] -name = "libc" -version = "0.2.184" +name = "oid-registry" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "log" -version = "0.4.29" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "memchr" -version = "2.8.0" +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] -name = "mio" -version = "1.2.0" +name = "parking_lot" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "libc", - "wasi", - "windows-sys", + "lock_api", + "parking_lot_core", ] [[package]] -name = "nix" -version = "0.29.0" +name = "parking_lot_core" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "bitflags", "cfg-if", - "cfg_aliases", "libc", + "redox_syscall", + "smallvec", + "windows-link", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "pathdiff" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "pem" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] [[package]] -name = "pathdiff" -version = "0.2.3" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" @@ -385,6 +1306,18 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plain" version = "0.2.3" @@ -406,6 +1339,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -443,6 +1391,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 = "r-efi" version = "6.0.0" @@ -479,6 +1433,69 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -489,7 +1506,41 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -527,6 +1578,7 @@ version = "0.4.8" dependencies = [ "bincode", "goblin", + "hudsucker", "libc", "nix", "pathdiff", @@ -535,7 +1587,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "toml", "uuid", @@ -550,6 +1602,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -628,6 +1692,60 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "socket2" version = "0.6.3" @@ -635,15 +1753,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -656,36 +1786,123 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys", -] +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] -name = "thiserror" -version = "2.0.18" +name = "time-macros" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ - "thiserror-impl", + "num-conv", + "time-core", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "tinystr" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "displaydoc", + "zerovec", ] [[package]] @@ -698,9 +1915,23 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-graceful" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "627ba4daa4cbce14740603401c895e72d47ecd86690a18e3f0841266e9340de7" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", ] [[package]] @@ -714,6 +1945,46 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -755,6 +2026,107 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -767,6 +2139,36 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -784,6 +2186,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -794,6 +2208,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -897,13 +2320,31 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -912,6 +2353,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -921,6 +2380,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -1018,6 +2541,62 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -1038,8 +2617,96 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] 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/README.md b/README.md index 5d092c1..8139d0a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ protects your working directory automatically. | Kernel | Shared | Shared | Separate guest | | Filesystem isolation | Landlock + seccomp COW | Overlay | Block-level | | Network isolation | Landlock + seccomp notif | Network namespace | TAP device | +| HTTP-level ACL | Method + host + path rules | N/A | N/A | | Syscall filtering | seccomp-bpf | seccomp | N/A | | Resource limits | seccomp notif + SIGSTOP | cgroup v2 | VM config | @@ -102,6 +103,19 @@ sandlock run -m 512M -P 20 -t 30 -- ./compute.sh # Domain-based network isolation sandlock run --net-allow-host api.openai.com -r /usr -r /lib -r /etc -- python3 agent.py +# HTTP-level ACL (method + host + path rules via transparent proxy) +sandlock run \ + --http-allow "GET docs.python.org/*" \ + --http-allow "POST api.openai.com/v1/chat/completions" \ + --http-deny "* */admin/*" \ + -r /usr -r /lib -r /etc -- python3 agent.py + +# HTTPS MITM with user-provided CA (enables ACL on port 443) +sandlock run \ + --http-allow "POST api.openai.com/v1/*" \ + --https-ca ca.pem --https-key ca-key.pem \ + -r /usr -r /lib -r /etc -- python3 agent.py + # TCP port restrictions (Landlock) sandlock run --net-bind 8080 --net-connect 443 -r /usr -r /lib -r /etc -- python3 server.py @@ -151,6 +165,14 @@ result = Sandbox(policy).run(["python3", "-c", "print('hello')"]) assert result.success assert b"hello" in result.stdout +# HTTP ACL: only allow specific API calls +agent_policy = Policy( + fs_readable=["/usr", "/lib", "/etc"], + http_allow=["POST api.openai.com/v1/chat/completions"], + http_deny=["* */admin/*"], +) +result = Sandbox(agent_policy).run(["python3", "agent.py"]) + # Confine the current process (Landlock filesystem only, irreversible) confine(Policy(fs_readable=["/usr", "/lib"], fs_writable=["/tmp"])) @@ -259,6 +281,14 @@ let policy = Policy::builder() let result = Sandbox::run(&policy, &["echo", "hello"]).await?; assert!(result.success()); +// HTTP ACL: restrict API access at the HTTP level +let policy = Policy::builder() + .fs_read("/usr").fs_read("/lib").fs_read("/etc") + .http_allow("POST api.openai.com/v1/chat/completions") + .http_deny("* */admin/*") + .build()?; +let result = Sandbox::run(&policy, &["python3", "agent.py"]).await?; + // Confine the current process (Landlock filesystem only, irreversible) let policy = Policy::builder() .fs_read("/usr").fs_read("/lib") @@ -342,7 +372,7 @@ The async notification supervisor (tokio) handles intercepted syscalls: |---|---| | `clone/fork/vfork` | Process count enforcement | | `mmap/munmap/brk/mremap` | Memory limit tracking | -| `connect/sendto/sendmsg` | IP allowlist + on-behalf execution | +| `connect/sendto/sendmsg` | IP allowlist + on-behalf execution + HTTP ACL redirect | | `bind` | On-behalf bind + port remapping | | `openat` | /proc virtualization, COW interception | | `unlinkat/mkdirat/renameat2` | COW write interception | @@ -469,6 +499,13 @@ Policy( net_bind=[8080], # TCP bind ports (Landlock ABI v4+) net_connect=[443], # TCP connect ports + # HTTP ACL (transparent proxy) + http_allow=["POST api.openai.com/v1/*"], # Allow rules (METHOD host/path) + http_deny=["* */admin/*"], # Deny rules (checked first) + http_ports=[80], # Ports to intercept (default: [80]) + https_ca="ca.pem", # CA cert for HTTPS MITM (adds port 443) + https_key="ca-key.pem", # CA key for HTTPS MITM + # Socket restrictions no_raw_sockets=True, # Block SOCK_RAW (default) no_udp=False, # Block SOCK_DGRAM diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index f8fb2a9..4d9a0e9 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -71,6 +71,19 @@ enum Command { net_allow: Vec, #[arg(long = "net-deny", value_name = "PROTO")] net_deny: Vec, + #[arg(long = "http-allow", value_name = "RULE")] + http_allow: Vec, + #[arg(long = "http-deny", value_name = "RULE")] + http_deny: Vec, + /// TCP ports to intercept for HTTP ACL (default: 80, plus 443 with --https-ca) + #[arg(long = "http-port", value_name = "PORT")] + http_ports: Vec, + /// PEM CA certificate for HTTPS MITM (enables port 443 interception) + #[arg(long = "https-ca", value_name = "PATH")] + https_ca: Option, + /// PEM CA private key for HTTPS MITM (required with --https-ca) + #[arg(long = "https-key", value_name = "PATH")] + https_key: Option, #[arg(long)] port_remap: bool, #[arg(long)] @@ -135,7 +148,7 @@ struct SandboxStatus { signal: Option, } -#[tokio::main] +#[tokio::main(worker_threads = 2)] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -145,6 +158,7 @@ async fn main() -> Result<()> { isolate_ipc, isolate_signals, clean_env, num_cpus, profile: profile_name, status_fd, max_cpu, max_open_files, chroot, uid, workdir, cwd, fs_isolation, fs_storage, max_disk, net_allow, net_deny, + http_allow, http_deny, http_ports, https_ca, https_key, port_remap, no_randomize_memory, no_huge_pages, deterministic_dirs, hostname, no_coredump, env_vars, exec_shell, interactive: _, fs_deny, cpu_cores, gpu_devices, image, dry_run, no_supervisor, cmd } => { @@ -152,7 +166,7 @@ async fn main() -> Result<()> { validate_no_supervisor( &max_memory, &max_processes, &max_cpu, &max_open_files, &timeout, &net_allow_host, &net_bind, &net_connect, - &net_allow, &net_deny, + &net_allow, &net_deny, &http_allow, &http_deny, &http_ports, &num_cpus, &random_seed, &time_start, no_randomize_memory, no_huge_pages, deterministic_dirs, &hostname, &chroot, &image, &uid, &workdir, &cwd, &fs_isolation, &fs_storage, @@ -217,6 +231,17 @@ async fn main() -> Result<()> { for h in &base.net_allow_hosts { b = b.net_allow_host(h); } for p in &base.net_bind { b = b.net_bind_port(*p); } for p in &base.net_connect { b = b.net_connect_port(*p); } + for rule in &base.http_allow { + let s = format!("{} {}{}", rule.method, rule.host, rule.path); + b = b.http_allow(&s); + } + for rule in &base.http_deny { + let s = format!("{} {}{}", rule.method, rule.host, rule.path); + b = b.http_deny(&s); + } + for port in &base.http_ports { + b = b.http_port(*port); + } if let Some(mem) = base.max_memory { b = b.max_memory(mem); } b = b.max_processes(base.max_processes); if let Some(cpu) = base.max_cpu { b = b.max_cpu(cpu); } @@ -281,6 +306,11 @@ async fn main() -> Result<()> { other => return Err(anyhow!("unknown --net-deny protocol: {}", other)), } } + for rule in &http_allow { builder = builder.http_allow(rule); } + for rule in &http_deny { builder = builder.http_deny(rule); } + for port in &http_ports { builder = builder.http_port(*port); } + if let Some(ref ca) = https_ca { builder = builder.https_ca(ca); } + if let Some(ref key) = https_key { builder = builder.https_key(key); } if port_remap { builder = builder.port_remap(true); } if !cpu_cores.is_empty() { builder = builder.cpu_cores(cpu_cores); } if !gpu_devices.is_empty() { builder = builder.gpu_devices(gpu_devices); } @@ -452,6 +482,9 @@ fn validate_no_supervisor( net_connect: &[u16], net_allow: &[String], net_deny: &[String], + http_allow: &[String], + http_deny: &[String], + http_ports: &[u16], num_cpus: &Option, random_seed: &Option, time_start: &Option, @@ -486,6 +519,9 @@ fn validate_no_supervisor( if !net_connect.is_empty() { bad.push("--net-connect"); } if !net_allow.is_empty() { bad.push("--net-allow"); } if !net_deny.is_empty() { bad.push("--net-deny"); } + if !http_allow.is_empty() { bad.push("--http-allow"); } + if !http_deny.is_empty() { bad.push("--http-deny"); } + if !http_ports.is_empty() { bad.push("--http-port"); } if num_cpus.is_some() { bad.push("--num-cpus"); } if random_seed.is_some() { bad.push("--random-seed"); } if time_start.is_some() { bad.push("--time-start"); } diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index f15b13e..c02de58 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -23,6 +23,7 @@ serde_json = "1" walkdir = "2" toml = "0.8" pathdiff = "0.2" +hudsucker = "0.22" [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index c303b86..46a8a74 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -242,7 +242,11 @@ pub fn notif_syscalls(policy: &Policy) -> Vec { nrs.push(libc::SYS_shmget as u32); } - if !policy.net_allow_hosts.is_empty() || policy.policy_fn.is_some() { + if !policy.net_allow_hosts.is_empty() + || policy.policy_fn.is_some() + || !policy.http_allow.is_empty() + || !policy.http_deny.is_empty() + { nrs.push(libc::SYS_connect as u32); nrs.push(libc::SYS_sendto as u32); nrs.push(libc::SYS_sendmsg as u32); diff --git a/crates/sandlock-core/src/http_acl.rs b/crates/sandlock-core/src/http_acl.rs new file mode 100644 index 0000000..a24f034 --- /dev/null +++ b/crates/sandlock-core/src/http_acl.rs @@ -0,0 +1,302 @@ +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::path::Path; +use std::sync::Arc; + +use hudsucker::certificate_authority::RcgenAuthority; +use hudsucker::hyper::{Request, Response, StatusCode}; +use hudsucker::rcgen::{CertificateParams, KeyPair}; +use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; + +use crate::policy::{http_acl_check, HttpRule}; + +/// Shared map from proxy client address to the original destination IP +/// that the sandboxed process tried to connect to. Written by the seccomp +/// supervisor on redirect, read by the proxy handler to verify the Host header. +pub type OrigDestMap = Arc>>; + +/// TTL-based DNS cache entry. +struct DnsCacheEntry { + ips: Vec, + expires: std::time::Instant, +} + +/// ACL-enforcing HTTP handler for hudsucker. +#[derive(Clone)] +struct AclHandler { + allow_rules: Arc>, + deny_rules: Arc>, + /// Map of client_addr → original destination IP, populated by supervisor. + orig_dest: OrigDestMap, + /// DNS resolution cache: hostname → resolved IPs with TTL. + dns_cache: Arc>>, +} + +/// DNS cache TTL — resolved IPs are reused for this duration. +const DNS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30); + +impl AclHandler { + /// Resolve a hostname with caching. Returns cached IPs if fresh, + /// otherwise performs a lookup and caches the result. + async fn resolve_cached(&self, host: &str) -> Option> { + // Check cache first. + { + let cache = self.dns_cache.lock().await; + if let Some(entry) = cache.get(host) { + if entry.expires > std::time::Instant::now() { + return Some(entry.ips.clone()); + } + } + } + + // Cache miss or expired — resolve. + let lookup = format!("{}:0", host); + let resolved = tokio::net::lookup_host(&lookup).await.ok()?; + let ips: Vec = resolved.map(|sa| sa.ip()).collect(); + + // Store in cache. + let mut cache = self.dns_cache.lock().await; + cache.insert( + host.to_string(), + DnsCacheEntry { + ips: ips.clone(), + expires: std::time::Instant::now() + DNS_CACHE_TTL, + }, + ); + Some(ips) + } + + /// Verify that the claimed host resolves to the original destination IP. + /// Returns true if verification passes or is not applicable. + async fn verify_host(&self, client_addr: &SocketAddr, claimed_host: &str) -> bool { + // Look up the original dest IP recorded by the supervisor. + let orig_ip = { + let map = self.orig_dest.read().unwrap_or_else(|e| e.into_inner()); + map.get(client_addr).copied() + }; + + let orig_ip = match orig_ip { + Some(ip) => ip, + // No mapping: this can happen for non-redirected connections + // (e.g. non-intercepted ports) or if the supervisor hasn't + // recorded it yet. Since we write the mapping before connect(), + // absence here means the connection was not redirected — allow. + None => return true, + }; + + // If the claimed host is already an IP, compare directly. + if let Ok(ip) = claimed_host.parse::() { + return ip == orig_ip; + } + + // Resolve the claimed hostname (with caching) and check if any result matches. + match self.resolve_cached(claimed_host).await { + Some(ips) => ips.iter().any(|ip| *ip == orig_ip), + // DNS failure for the claimed host — deny. + None => false, + } + } +} + +impl HttpHandler for AclHandler { + async fn handle_request( + &mut self, + ctx: &HttpContext, + req: Request, + ) -> RequestOrResponse { + let method = req.method().as_str().to_string(); + + // Extract host from URI authority or Host header. + let host = req + .uri() + .host() + .map(|h| h.to_string()) + .or_else(|| { + req.headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .map(|h| { + // Strip port from host header if present. + h.split(':').next().unwrap_or(h).to_string() + }) + }) + .unwrap_or_default(); + + let path = req.uri().path().to_string(); + + // Verify the Host header matches the original destination IP to + // prevent spoofing (e.g. Host: allowed.com while connecting to evil.com). + if !self.verify_host(&ctx.client_addr, &host).await { + // Clean up the mapping to prevent memory leaks on blocked requests. + if let Ok(mut map) = self.orig_dest.write() { + map.remove(&ctx.client_addr); + } + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("Blocked by sandlock: Host header does not match connection destination")) + .expect("failed to build 403 response") + .into(); + } + + // Clean up the mapping now that verification passed. + if let Ok(mut map) = self.orig_dest.write() { + map.remove(&ctx.client_addr); + } + + if http_acl_check(&self.allow_rules, &self.deny_rules, &method, &host, &path) { + // For transparent proxying, the client sends relative URIs + // (e.g. "GET /path"). hudsucker needs an absolute URI to know + // where to forward. Reconstruct it from the Host header. + let mut req = req; + if req.uri().authority().is_none() { + let host_port = req + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + if !host_port.is_empty() { + if let Ok(uri) = format!("http://{}{}", host_port, req.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/")).parse() { + *req.uri_mut() = uri; + } + } + } + req.into() + } else { + Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("Blocked by sandlock HTTP ACL policy")) + .expect("failed to build 403 response") + .into() + } + } +} + +/// Handle returned by [`spawn_http_acl_proxy`]. +pub struct HttpAclProxyHandle { + /// Local address the proxy is listening on. + pub addr: SocketAddr, + /// Shared map for the supervisor to record original destination IPs. + pub orig_dest: OrigDestMap, + /// Send to this channel to trigger graceful proxy shutdown. + shutdown_tx: Option>, +} + +impl Drop for HttpAclProxyHandle { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +/// Pre-generated dummy CA for HTTP-only mode, avoiding per-spawn keygen cost. +fn dummy_ca() -> std::io::Result<(KeyPair, hudsucker::rcgen::Certificate)> { + use hudsucker::rcgen::{BasicConstraints, IsCa}; + + let kp = KeyPair::generate().map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("keygen failed: {e}")) + })?; + let mut params = CertificateParams::default(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let cert = params.self_signed(&kp).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("self-sign failed: {e}")) + })?; + Ok((kp, cert)) +} + +static DUMMY_CA: std::sync::LazyLock, Vec)>> = + std::sync::LazyLock::new(|| { + let (kp, cert) = dummy_ca()?; + Ok((kp.serialize_pem().into_bytes(), cert.pem().into_bytes())) + }); + +/// Spawn a hudsucker-based HTTP ACL proxy. +/// +/// If `ca_cert` and `ca_key` are provided, the proxy also intercepts HTTPS +/// traffic via MITM using the given CA. Otherwise, only plaintext HTTP +/// (port 80) is intercepted. +pub async fn spawn_http_acl_proxy( + allow: Vec, + deny: Vec, + ca_cert: Option<&Path>, + ca_key: Option<&Path>, +) -> std::io::Result { + // Load CA for HTTPS MITM if provided. + + let (key_pair, cert) = if let (Some(cert_path), Some(key_path)) = (ca_cert, ca_key) { + let key_pem = std::fs::read_to_string(key_path).map_err(|e| { + std::io::Error::new(e.kind(), format!("failed to read --https-key {:?}: {e}", key_path)) + })?; + let cert_pem = std::fs::read_to_string(cert_path).map_err(|e| { + std::io::Error::new(e.kind(), format!("failed to read --https-ca {:?}: {e}", cert_path)) + })?; + let kp = KeyPair::from_pem(&key_pem).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("invalid CA key: {e}")) + })?; + let params = CertificateParams::from_ca_cert_pem(&cert_pem).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("invalid CA cert: {e}")) + })?; + let cert = params.self_signed(&kp).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("CA cert error: {e}")) + })?; + (kp, cert) + } else { + // HTTP-only mode — reuse a lazily-generated dummy CA to avoid + // expensive keygen on every spawn. + let (key_pem, cert_pem) = DUMMY_CA.as_ref().map_err(|e| { + std::io::Error::new(e.kind(), format!("dummy CA init failed: {e}")) + })?; + let kp = KeyPair::from_pem(std::str::from_utf8(key_pem).unwrap()).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("dummy CA key: {e}")) + })?; + let params = CertificateParams::from_ca_cert_pem(std::str::from_utf8(cert_pem).unwrap()) + .map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("dummy CA cert: {e}")) + })?; + let cert = params.self_signed(&kp).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("dummy CA sign: {e}")) + })?; + (kp, cert) + }; + + let ca = RcgenAuthority::new(key_pair, cert, 1_000); + + let orig_dest: OrigDestMap = Arc::new(std::sync::RwLock::new(HashMap::new())); + + let handler = AclHandler { + allow_rules: Arc::new(allow), + deny_rules: Arc::new(deny), + orig_dest: Arc::clone(&orig_dest), + dns_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + }; + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + + let proxy = Proxy::builder() + .with_listener(listener) + .with_rustls_client() + .with_ca(ca) + .with_http_handler(handler) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .build(); + + tokio::spawn(async move { + if let Err(e) = proxy.start().await { + eprintln!("sandlock HTTP ACL proxy error: {e}"); + } + }); + + Ok(HttpAclProxyHandle { + addr, + orig_dest, + shutdown_tx: Some(shutdown_tx), + }) +} diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index 6aaa0d6..ac6d30e 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod image; pub mod fork; pub(crate) mod chroot; pub mod dry_run; +pub(crate) mod http_acl; pub use error::SandlockError; pub use checkpoint::Checkpoint; diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index a2ad5d6..afdb018 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -48,6 +48,25 @@ fn parse_ip_from_sockaddr(bytes: &[u8]) -> Option { } } +// ============================================================ +// parse_port_from_sockaddr — parse TCP port from sockaddr bytes +// ============================================================ + +/// Parse TCP port from a sockaddr byte buffer. +/// Returns None for non-IP families (AF_UNIX etc.). +fn parse_port_from_sockaddr(bytes: &[u8]) -> Option { + if bytes.len() < 4 { + return None; + } + let family = u16::from_ne_bytes([bytes[0], bytes[1]]) as u32; + match family { + f if f == AF_INET || f == AF_INET6 => { + Some(u16::from_be_bytes([bytes[2], bytes[3]])) + } + _ => None, + } +} + // ============================================================ // connect_on_behalf — perform connect() on behalf of the child (TOCTOU-safe) // ============================================================ @@ -86,28 +105,172 @@ async fn connect_on_behalf( return NotifAction::Errno(ECONNREFUSED); } } + // Check for HTTP ACL redirect + let dest_port = parse_port_from_sockaddr(&addr_bytes); + let http_acl_addr = st.http_acl_addr; + let http_acl_intercept = dest_port.map_or(false, |p| st.http_acl_ports.contains(&p)); + let http_acl_orig_dest = st.http_acl_orig_dest.clone(); + let child_pidfd = match st.child_pidfd { Some(fd) => fd, None => return NotifAction::Errno(libc::ENOSYS), }; drop(st); + // Determine the actual connect target (redirect HTTP/HTTPS to proxy) + let mut redirected = false; + let is_ipv6 = parse_ip_from_sockaddr(&addr_bytes) + .map_or(false, |ip| ip.is_ipv6()); + let (connect_addr, connect_len) = if let Some(proxy_addr) = http_acl_addr { + if http_acl_intercept { + redirected = true; + if is_ipv6 { + // IPv6 socket: redirect via IPv4-mapped IPv6 address + // (::ffff:127.0.0.1) so it connects to the IPv4 proxy. + let mut sa6: libc::sockaddr_in6 = unsafe { std::mem::zeroed() }; + sa6.sin6_family = libc::AF_INET6 as u16; + sa6.sin6_port = proxy_addr.port().to_be(); + // Build ::ffff:127.0.0.1 + let mapped = std::net::Ipv6Addr::from( + match proxy_addr { + std::net::SocketAddr::V4(v4) => v4.ip().to_ipv6_mapped(), + std::net::SocketAddr::V6(v6) => *v6.ip(), + } + ); + sa6.sin6_addr.s6_addr = mapped.octets(); + let bytes = unsafe { + std::slice::from_raw_parts( + &sa6 as *const _ as *const u8, + std::mem::size_of::(), + ) + } + .to_vec(); + (bytes, std::mem::size_of::() as u32) + } else { + // IPv4 socket: redirect directly. + let mut sa: libc::sockaddr_in = unsafe { std::mem::zeroed() }; + sa.sin_family = libc::AF_INET as u16; + sa.sin_port = proxy_addr.port().to_be(); + match proxy_addr { + std::net::SocketAddr::V4(v4) => { + sa.sin_addr.s_addr = u32::from_ne_bytes(v4.ip().octets()); + } + std::net::SocketAddr::V6(_) => { + // Proxy always binds to 127.0.0.1 + return NotifAction::Errno(libc::EAFNOSUPPORT); + } + } + let bytes = unsafe { + std::slice::from_raw_parts( + &sa as *const _ as *const u8, + std::mem::size_of::(), + ) + } + .to_vec(); + (bytes, std::mem::size_of::() as u32) + } + } else { + (addr_bytes.clone(), addr_len) + } + } else { + (addr_bytes.clone(), addr_len) + }; + // 3. Duplicate child's socket into supervisor let dup_fd = match crate::seccomp::notif::dup_child_fd(child_pidfd, sockfd) { Ok(fd) => fd, Err(_) => return NotifAction::Errno(libc::ENOSYS), }; - // 4. Perform connect in supervisor with our validated sockaddr + // 4. Record original dest IP *before* connect to prevent TOCTOU race: + // the proxy may receive the request before we write the mapping if + // we do it after connect(). We already have the original IP from + // addr_bytes (our immune copy). + if redirected { + if let Some(ref orig_dest_map) = http_acl_orig_dest { + if let Some(orig_ip) = parse_ip_from_sockaddr(&addr_bytes) { + // Bind the socket so getsockname() returns the local addr + // the proxy will see as client_addr. + if is_ipv6 { + let mut bind_sa6: libc::sockaddr_in6 = unsafe { std::mem::zeroed() }; + bind_sa6.sin6_family = libc::AF_INET6 as u16; + // port 0 + IN6ADDR_ANY = kernel picks ephemeral port + unsafe { + libc::bind( + dup_fd.as_raw_fd(), + &bind_sa6 as *const _ as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ); + } + let mut local_sa6: libc::sockaddr_in6 = unsafe { std::mem::zeroed() }; + let mut local_len: libc::socklen_t = + std::mem::size_of::() as libc::socklen_t; + let gs_ret = unsafe { + libc::getsockname( + dup_fd.as_raw_fd(), + &mut local_sa6 as *mut _ as *mut libc::sockaddr, + &mut local_len, + ) + }; + if gs_ret == 0 { + let local_port = u16::from_be(local_sa6.sin6_port); + let local_ip = Ipv6Addr::from(local_sa6.sin6_addr.s6_addr); + let local_addr = std::net::SocketAddr::V6( + std::net::SocketAddrV6::new(local_ip, local_port, 0, 0), + ); + if let Ok(mut map) = orig_dest_map.write() { + map.insert(local_addr, orig_ip); + } + } + } else { + let mut bind_sa: libc::sockaddr_in = unsafe { std::mem::zeroed() }; + bind_sa.sin_family = libc::AF_INET as u16; + // port 0 + INADDR_ANY = kernel picks ephemeral port + unsafe { + libc::bind( + dup_fd.as_raw_fd(), + &bind_sa as *const _ as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ); + } + let mut local_sa: libc::sockaddr_in = unsafe { std::mem::zeroed() }; + let mut local_len: libc::socklen_t = + std::mem::size_of::() as libc::socklen_t; + let gs_ret = unsafe { + libc::getsockname( + dup_fd.as_raw_fd(), + &mut local_sa as *mut _ as *mut libc::sockaddr, + &mut local_len, + ) + }; + if gs_ret == 0 { + let local_port = u16::from_be(local_sa.sin_port); + let local_ip = Ipv4Addr::from(u32::from_be(local_sa.sin_addr.s_addr)); + let local_addr = std::net::SocketAddr::V4( + std::net::SocketAddrV4::new(local_ip, local_port), + ); + if let Ok(mut map) = orig_dest_map.write() { + map.insert(local_addr, orig_ip); + } + } + } + } + } + } + + // 5. Perform connect in supervisor with our validated sockaddr let ret = unsafe { libc::connect( dup_fd.as_raw_fd(), - addr_bytes.as_ptr() as *const libc::sockaddr, - addr_len as libc::socklen_t, + connect_addr.as_ptr() as *const libc::sockaddr, + connect_len as libc::socklen_t, ) }; - // 5. Return result + // 6. Return result. + // On failure, the stale orig_dest entry is harmless: the proxy never + // sees this connection, and the entry will be cleaned up on the next + // successful request from the same local address (or on shutdown). if ret == 0 { NotifAction::ReturnValue(0) } else { diff --git a/crates/sandlock-core/src/policy.rs b/crates/sandlock-core/src/policy.rs index 6f1c91c..7ece9a9 100644 --- a/crates/sandlock-core/src/policy.rs +++ b/crates/sandlock-core/src/policy.rs @@ -74,6 +74,176 @@ pub enum BranchAction { Keep, } +/// An HTTP access control rule. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct HttpRule { + pub method: String, + pub host: String, + pub path: String, +} + +impl HttpRule { + /// Parse a rule from "METHOD host/path" format. + /// + /// Examples: + /// - `"GET api.example.com/v1/*"` → method="GET", host="api.example.com", path="/v1/*" + /// - `"* */admin/*"` → method="*", host="*", path="/admin/*" + /// - `"GET example.com"` → method="GET", host="example.com", path="/*" + pub fn parse(s: &str) -> Result { + let s = s.trim(); + let (method, rest) = s + .split_once(char::is_whitespace) + .ok_or_else(|| PolicyError::Invalid(format!("invalid http rule: {}", s)))?; + let rest = rest.trim(); + if rest.is_empty() { + return Err(PolicyError::Invalid(format!("invalid http rule: {}", s))); + } + + let (host, path) = if let Some(pos) = rest.find('/') { + let (h, p) = rest.split_at(pos); + // Normalize the rule path, but preserve trailing * for glob matching. + let has_wildcard = p.ends_with('*'); + let mut normalized = normalize_path(p); + if has_wildcard && !normalized.ends_with('*') { + normalized.push('*'); + } + (h.to_string(), normalized) + } else { + (rest.to_string(), "/*".to_string()) + }; + + Ok(HttpRule { + method: method.to_uppercase(), + host, + path, + }) + } + + /// Check whether this rule matches the given request parameters. + /// The request path is normalized before matching to prevent bypasses + /// via `//`, `/../`, `/.`, or percent-encoding. + pub fn matches(&self, method: &str, host: &str, path: &str) -> bool { + // Method match + if self.method != "*" && !self.method.eq_ignore_ascii_case(method) { + return false; + } + // Host match + if self.host != "*" && !self.host.eq_ignore_ascii_case(host) { + return false; + } + // Path match — normalize to prevent encoding/traversal bypasses + let normalized = normalize_path(path); + prefix_or_exact_match(&self.path, &normalized) + } +} + +/// Normalize an HTTP path to prevent ACL bypasses via encoding tricks. +/// +/// - Decodes percent-encoded characters (e.g. `%2F` → `/`, `%61` → `a`) +/// - Collapses duplicate slashes (`//` → `/`) +/// - Resolves `.` and `..` segments +/// - Ensures the path starts with `/` +pub fn normalize_path(path: &str) -> String { + // 1. Percent-decode + let mut decoded = String::with_capacity(path.len()); + let mut chars = path.bytes(); + while let Some(b) = chars.next() { + if b == b'%' { + let hi = chars.next(); + let lo = chars.next(); + if let (Some(h), Some(l)) = (hi, lo) { + let hex = [h, l]; + if let Ok(s) = std::str::from_utf8(&hex) { + if let Ok(val) = u8::from_str_radix(s, 16) { + decoded.push(val as char); + continue; + } + } + // Malformed percent encoding — keep as-is + decoded.push(b as char); + decoded.push(h as char); + decoded.push(l as char); + } else { + decoded.push(b as char); + } + } else { + decoded.push(b as char); + } + } + + // 2. Split into segments, resolve . and .., skip empty segments (collapses //) + let mut segments: Vec<&str> = Vec::new(); + for seg in decoded.split('/') { + match seg { + "" | "." => {} + ".." => { + segments.pop(); + } + s => segments.push(s), + } + } + + // 3. Reconstruct with leading / + let mut result = String::with_capacity(decoded.len()); + result.push('/'); + result.push_str(&segments.join("/")); + result +} + +/// Simple prefix or exact matching for paths. Supports trailing `*` as a prefix match. +/// +/// Only supports: +/// - `"/*"` or `"*"` matches everything +/// - `"/v1/*"` matches "/v1/foo", "/v1/foo/bar" (prefix match) +/// - `"/v1/models"` matches exactly "/v1/models" (exact match) +/// +/// Does NOT support mid-pattern wildcards (e.g., "/v1/*/models"). +pub fn prefix_or_exact_match(pattern: &str, value: &str) -> bool { + if pattern == "/*" || pattern == "*" { + return true; + } + if let Some(prefix) = pattern.strip_suffix('*') { + value.starts_with(prefix) + } else { + pattern == value + } +} + +/// Evaluate HTTP ACL rules against a request. +/// +/// - Deny rules are checked first; if any match, return false. +/// - Allow rules are checked next; if any match, return true. +/// - If allow rules exist but none matched, return false (deny-by-default). +/// - If no rules at all, return true (unrestricted). +pub fn http_acl_check( + allow: &[HttpRule], + deny: &[HttpRule], + method: &str, + host: &str, + path: &str, +) -> bool { + // Deny rules checked first + for rule in deny { + if rule.matches(method, host, path) { + return false; + } + } + // Allow rules checked next + if allow.is_empty() && deny.is_empty() { + return true; // unrestricted + } + if allow.is_empty() { + // Only deny rules exist; anything not denied is allowed + return true; + } + for rule in allow { + if rule.matches(method, host, path) { + return true; + } + } + false // allow rules exist but none matched +} + /// Sandbox policy configuration. #[derive(Clone, Serialize, Deserialize)] pub struct Policy { @@ -93,6 +263,17 @@ pub struct Policy { pub no_raw_sockets: bool, pub no_udp: bool, + // HTTP ACL + pub http_allow: Vec, + pub http_deny: Vec, + /// TCP ports to intercept for HTTP ACL. Defaults to [80] (plus 443 when + /// https_ca is set). Override with `http_ports` to intercept custom ports. + pub http_ports: Vec, + /// PEM CA cert for HTTPS MITM. When set, port 443 is also intercepted. + pub https_ca: Option, + /// PEM CA key for HTTPS MITM. Required when https_ca is set. + pub https_key: Option, + // Namespace isolation pub isolate_ipc: bool, pub isolate_signals: bool, @@ -178,6 +359,12 @@ pub struct PolicyBuilder { no_raw_sockets: Option, no_udp: bool, + http_allow: Vec, + http_deny: Vec, + http_ports: Vec, + https_ca: Option, + https_key: Option, + isolate_ipc: bool, isolate_signals: bool, isolate_pids: bool, @@ -269,6 +456,31 @@ impl PolicyBuilder { self } + pub fn http_allow(mut self, rule: &str) -> Self { + self.http_allow.push(rule.to_string()); + self + } + + pub fn http_deny(mut self, rule: &str) -> Self { + self.http_deny.push(rule.to_string()); + self + } + + pub fn http_port(mut self, port: u16) -> Self { + self.http_ports.push(port); + self + } + + pub fn https_ca(mut self, path: impl Into) -> Self { + self.https_ca = Some(path.into()); + self + } + + pub fn https_key(mut self, path: impl Into) -> Self { + self.https_key = Some(path.into()); + self + } + pub fn isolate_ipc(mut self, v: bool) -> Self { self.isolate_ipc = v; self @@ -440,6 +652,36 @@ impl PolicyBuilder { } } + // Validate: https_ca and https_key must both be set or both unset + if self.https_ca.is_some() != self.https_key.is_some() { + return Err(PolicyError::Invalid( + "--https-ca and --https-key must both be provided together".into(), + )); + } + + // Parse HTTP rules (deferred from builder methods to propagate errors) + let http_allow: Vec = self + .http_allow + .iter() + .map(|s| HttpRule::parse(s)) + .collect::>()?; + let http_deny: Vec = self + .http_deny + .iter() + .map(|s| HttpRule::parse(s)) + .collect::>()?; + + // Default HTTP intercept ports: 80 always, 443 when HTTPS CA is configured. + let http_ports = if self.http_ports.is_empty() && (!http_allow.is_empty() || !http_deny.is_empty()) { + let mut ports = vec![80]; + if self.https_ca.is_some() { + ports.push(443); + } + ports + } else { + self.http_ports + }; + // Validate: fs_isolation != None requires workdir let fs_isolation = self.fs_isolation.unwrap_or_default(); if fs_isolation != FsIsolation::None && self.workdir.is_none() { @@ -457,6 +699,11 @@ impl PolicyBuilder { net_connect: self.net_connect, no_raw_sockets: self.no_raw_sockets.unwrap_or(true), no_udp: self.no_udp, + http_allow, + http_deny, + http_ports, + https_ca: self.https_ca, + https_key: self.https_key, isolate_ipc: self.isolate_ipc, isolate_signals: self.isolate_signals, isolate_pids: self.isolate_pids, @@ -491,3 +738,302 @@ impl PolicyBuilder { }) } } + +#[cfg(test)] +mod http_rule_tests { + use super::*; + + // --- HttpRule::parse tests --- + + #[test] + fn parse_basic_get() { + let rule = HttpRule::parse("GET api.example.com/v1/*").unwrap(); + assert_eq!(rule.method, "GET"); + assert_eq!(rule.host, "api.example.com"); + assert_eq!(rule.path, "/v1/*"); + } + + #[test] + fn parse_wildcard_method_and_host() { + let rule = HttpRule::parse("* */admin/*").unwrap(); + assert_eq!(rule.method, "*"); + assert_eq!(rule.host, "*"); + assert_eq!(rule.path, "/admin/*"); + } + + #[test] + fn parse_post_with_exact_path() { + let rule = HttpRule::parse("POST example.com/upload").unwrap(); + assert_eq!(rule.method, "POST"); + assert_eq!(rule.host, "example.com"); + assert_eq!(rule.path, "/upload"); + } + + #[test] + fn parse_no_path_defaults_to_wildcard() { + let rule = HttpRule::parse("GET example.com").unwrap(); + assert_eq!(rule.method, "GET"); + assert_eq!(rule.host, "example.com"); + assert_eq!(rule.path, "/*"); + } + + #[test] + fn parse_method_uppercased() { + let rule = HttpRule::parse("get example.com/foo").unwrap(); + assert_eq!(rule.method, "GET"); + } + + #[test] + fn parse_error_no_space() { + assert!(HttpRule::parse("GETexample.com").is_err()); + } + + #[test] + fn parse_error_empty_host() { + assert!(HttpRule::parse("GET ").is_err()); + } + + // --- prefix_or_exact_match tests --- + + #[test] + fn prefix_or_exact_match_wildcard_all() { + assert!(prefix_or_exact_match("/*", "/anything")); + assert!(prefix_or_exact_match("*", "/anything")); + assert!(prefix_or_exact_match("/*", "/")); + } + + #[test] + fn prefix_or_exact_match_prefix() { + assert!(prefix_or_exact_match("/v1/*", "/v1/foo")); + assert!(prefix_or_exact_match("/v1/*", "/v1/foo/bar")); + assert!(prefix_or_exact_match("/v1/*", "/v1/")); + assert!(!prefix_or_exact_match("/v1/*", "/v2/foo")); + } + + #[test] + fn prefix_or_exact_match_exact() { + assert!(prefix_or_exact_match("/v1/models", "/v1/models")); + assert!(!prefix_or_exact_match("/v1/models", "/v1/models/extra")); + assert!(!prefix_or_exact_match("/v1/models", "/v1/model")); + } + + // --- HttpRule::matches tests --- + + #[test] + fn matches_exact() { + let rule = HttpRule::parse("GET api.example.com/v1/models").unwrap(); + assert!(rule.matches("GET", "api.example.com", "/v1/models")); + assert!(!rule.matches("POST", "api.example.com", "/v1/models")); + assert!(!rule.matches("GET", "other.com", "/v1/models")); + assert!(!rule.matches("GET", "api.example.com", "/v1/other")); + } + + #[test] + fn matches_wildcard_method() { + let rule = HttpRule::parse("* api.example.com/v1/*").unwrap(); + assert!(rule.matches("GET", "api.example.com", "/v1/foo")); + assert!(rule.matches("POST", "api.example.com", "/v1/bar")); + } + + #[test] + fn matches_wildcard_host() { + let rule = HttpRule::parse("GET */v1/*").unwrap(); + assert!(rule.matches("GET", "any.host.com", "/v1/foo")); + } + + #[test] + fn matches_case_insensitive_method() { + let rule = HttpRule::parse("GET example.com/foo").unwrap(); + assert!(rule.matches("get", "example.com", "/foo")); + assert!(rule.matches("Get", "example.com", "/foo")); + } + + #[test] + fn matches_case_insensitive_host() { + let rule = HttpRule::parse("GET Example.COM/foo").unwrap(); + assert!(rule.matches("GET", "example.com", "/foo")); + } + + // --- http_acl_check tests --- + + #[test] + fn acl_no_rules_allows_all() { + assert!(http_acl_check(&[], &[], "GET", "example.com", "/foo")); + } + + #[test] + fn acl_allow_only_permits_matching() { + let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()]; + assert!(http_acl_check(&allow, &[], "GET", "api.example.com", "/v1/foo")); + assert!(!http_acl_check(&allow, &[], "POST", "api.example.com", "/v1/foo")); + assert!(!http_acl_check(&allow, &[], "GET", "other.com", "/v1/foo")); + } + + #[test] + fn acl_deny_only_blocks_matching() { + let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings")); + assert!(http_acl_check(&[], &deny, "GET", "example.com", "/public/page")); + } + + #[test] + fn acl_deny_takes_precedence_over_allow() { + let allow = vec![HttpRule::parse("* example.com/*").unwrap()]; + let deny = vec![HttpRule::parse("* example.com/admin/*").unwrap()]; + assert!(http_acl_check(&allow, &deny, "GET", "example.com", "/public")); + assert!(!http_acl_check(&allow, &deny, "GET", "example.com", "/admin/settings")); + } + + #[test] + fn acl_allow_deny_by_default_when_no_match() { + let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()]; + // Different host, not matched by allow -> denied + assert!(!http_acl_check(&allow, &[], "GET", "evil.com", "/v1/foo")); + } + + // --- PolicyBuilder integration --- + + #[test] + fn builder_http_rules() { + let policy = Policy::builder() + .http_allow("GET api.example.com/v1/*") + .http_deny("* */admin/*") + .build() + .unwrap(); + assert_eq!(policy.http_allow.len(), 1); + assert_eq!(policy.http_deny.len(), 1); + assert_eq!(policy.http_allow[0].method, "GET"); + assert_eq!(policy.http_deny[0].host, "*"); + } + + #[test] + fn builder_invalid_http_allow_returns_error() { + let result = Policy::builder() + .http_allow("GETexample.com") + .build(); + assert!(result.is_err()); + } + + #[test] + fn builder_invalid_http_deny_returns_error() { + let result = Policy::builder() + .http_deny("BADRULE") + .build(); + assert!(result.is_err()); + } + + #[test] + fn builder_https_ca_without_key_returns_error() { + let result = Policy::builder() + .https_ca("/tmp/ca.pem") + .build(); + assert!(result.is_err()); + } + + #[test] + fn builder_https_key_without_ca_returns_error() { + let result = Policy::builder() + .https_key("/tmp/key.pem") + .build(); + assert!(result.is_err()); + } + + #[test] + fn builder_https_ca_and_key_together_ok() { + let policy = Policy::builder() + .https_ca("/tmp/ca.pem") + .https_key("/tmp/key.pem") + .build() + .unwrap(); + assert!(policy.https_ca.is_some()); + assert!(policy.https_key.is_some()); + } + + // --- normalize_path tests --- + + #[test] + fn normalize_path_basic() { + assert_eq!(normalize_path("/foo/bar"), "/foo/bar"); + assert_eq!(normalize_path("/"), "/"); + } + + #[test] + fn normalize_path_double_slashes() { + assert_eq!(normalize_path("/foo//bar"), "/foo/bar"); + assert_eq!(normalize_path("//foo///bar//"), "/foo/bar"); + } + + #[test] + fn normalize_path_dot_segments() { + assert_eq!(normalize_path("/foo/./bar"), "/foo/bar"); + assert_eq!(normalize_path("/foo/../bar"), "/bar"); + assert_eq!(normalize_path("/foo/bar/../../baz"), "/baz"); + } + + #[test] + fn normalize_path_dotdot_at_root() { + assert_eq!(normalize_path("/../foo"), "/foo"); + assert_eq!(normalize_path("/../../foo"), "/foo"); + } + + #[test] + fn normalize_path_percent_encoding() { + // %2F = /, %61 = a + assert_eq!(normalize_path("/foo%2Fbar"), "/foo/bar"); + assert_eq!(normalize_path("/%61dmin/settings"), "/admin/settings"); + } + + #[test] + fn normalize_path_mixed_bypass_attempts() { + // Double-encoded traversal + assert_eq!(normalize_path("/v1/./admin/settings"), "/v1/admin/settings"); + assert_eq!(normalize_path("/v1/../admin/settings"), "/admin/settings"); + assert_eq!(normalize_path("/v1//admin/settings"), "/v1/admin/settings"); + assert_eq!(normalize_path("/v1/%2e%2e/admin"), "/admin"); + } + + // --- ACL bypass prevention tests --- + + #[test] + fn acl_deny_prevents_double_slash_bypass() { + let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; + // These should all be caught by the deny rule + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings")); + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "//admin/settings")); + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin//settings")); + } + + #[test] + fn acl_deny_prevents_dot_segment_bypass() { + let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/./admin/settings")); + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/public/../admin/settings")); + } + + #[test] + fn acl_deny_prevents_percent_encoding_bypass() { + let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; + // %61dmin = admin + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/%61dmin/settings")); + } + + #[test] + fn acl_allow_normalized_path_still_works() { + let allow = vec![HttpRule::parse("GET example.com/v1/models").unwrap()]; + assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/models")); + assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/./models")); + assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1//models")); + // These resolve to different paths and should be denied + assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v1/models/extra")); + assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v2/models")); + } + + #[test] + fn parse_normalizes_rule_path() { + let rule = HttpRule::parse("GET example.com/v1/./models/*").unwrap(); + assert_eq!(rule.path, "/v1/models/*"); + + let rule = HttpRule::parse("GET example.com/v1//models").unwrap(); + assert_eq!(rule.path, "/v1/models"); + } +} diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 7c4cfcd..92b0415 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -54,6 +54,15 @@ pub fn parse_profile(content: &str) -> Result { if let Some(hosts) = sandbox.get("net_allow_hosts").and_then(|v| v.as_array()) { for h in hosts { if let Some(s) = h.as_str() { builder = builder.net_allow_host(s); } } } + if let Some(rules) = sandbox.get("http_allow").and_then(|v| v.as_array()) { + for r in rules { if let Some(s) = r.as_str() { builder = builder.http_allow(s); } } + } + if let Some(rules) = sandbox.get("http_deny").and_then(|v| v.as_array()) { + for r in rules { if let Some(s) = r.as_str() { builder = builder.http_deny(s); } } + } + if let Some(ports) = sandbox.get("http_ports").and_then(|v| v.as_array()) { + for p in ports { if let Some(v) = p.as_integer() { builder = builder.http_port(v as u16); } } + } // Parse integers if let Some(v) = sandbox.get("max_processes").and_then(|v| v.as_integer()) { diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 2676a62..d8a3a7f 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -93,6 +93,8 @@ pub struct Sandbox { work_fn: Option>, /// Optional fd overrides for stdin/stdout/stderr (used by Pipeline). io_overrides: Option<(Option, Option, Option)>, + /// HTTP ACL proxy handle — kept alive so the proxy runs while the child is alive. + http_acl_handle: Option, } impl Sandbox { @@ -142,6 +144,7 @@ impl Sandbox { init_fn: None, work_fn: None, io_overrides: None, + http_acl_handle: None, } } @@ -648,7 +651,18 @@ impl Sandbox { std::collections::HashSet::new() }; - // 5. Create COW branch if requested + // 5. Spawn HTTP ACL proxy if rules are configured + if !self.policy.http_allow.is_empty() || !self.policy.http_deny.is_empty() { + let handle = crate::http_acl::spawn_http_acl_proxy( + self.policy.http_allow.clone(), + self.policy.http_deny.clone(), + self.policy.https_ca.as_deref(), + self.policy.https_key.as_deref(), + ).await.map_err(SandboxError::Io)?; + self.http_acl_handle = Some(handle); + } + + // 6. Create COW branch if requested let cow_branch: Option> = match self.policy.fs_isolation { FsIsolation::OverlayFs => { let workdir = self.policy.workdir.as_ref() @@ -822,7 +836,9 @@ impl Sandbox { max_processes: self.policy.max_processes, has_memory_limit: self.policy.max_memory.is_some(), has_net_allowlist: !self.policy.net_allow_hosts.is_empty() - || self.policy.policy_fn.is_some(), + || self.policy.policy_fn.is_some() + || !self.policy.http_allow.is_empty() + || !self.policy.http_deny.is_empty(), has_random_seed: self.policy.random_seed.is_some(), has_time_start: self.policy.time_start.is_some(), time_offset: time_offset_val, @@ -837,6 +853,7 @@ impl Sandbox { chroot_denied: self.policy.fs_denied.clone(), deterministic_dirs: self.policy.deterministic_dirs, hostname: self.policy.hostname.clone(), + has_http_acl: !self.policy.http_allow.is_empty() || !self.policy.http_deny.is_empty(), }; // Create SupervisorState @@ -863,6 +880,10 @@ impl Sandbox { sup_state.child_pidfd = Some(pfd.as_raw_fd()); } + sup_state.http_acl_addr = self.http_acl_handle.as_ref().map(|h| h.addr); + sup_state.http_acl_ports = self.policy.http_ports.iter().copied().collect(); + sup_state.http_acl_orig_dest = self.http_acl_handle.as_ref().map(|h| h.orig_dest.clone()); + // Seccomp COW branch if self.policy.workdir.is_some() && self.policy.fs_isolation == FsIsolation::None { let workdir = self.policy.workdir.as_ref().unwrap(); diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 14e4065..3f0fc40 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -97,6 +97,12 @@ pub struct SupervisorState { pub live_policy: Option>>, /// Dynamically denied paths from policy_fn. pub denied_paths: std::sync::Arc>>, + /// HTTP ACL proxy address (None if HTTP ACL not active). + pub http_acl_addr: Option, + /// TCP ports to intercept and redirect to the HTTP ACL proxy. + pub http_acl_ports: std::collections::HashSet, + /// Shared map for recording original destination IPs on proxy redirect. + pub http_acl_orig_dest: Option, } impl SupervisorState { @@ -130,6 +136,9 @@ impl SupervisorState { pid_ip_overrides: std::sync::Arc::new(std::sync::RwLock::new(HashMap::new())), live_policy: None, denied_paths: std::sync::Arc::new(std::sync::RwLock::new(HashSet::new())), + http_acl_addr: None, + http_acl_ports: std::collections::HashSet::new(), + http_acl_orig_dest: None, } } } @@ -219,6 +228,7 @@ pub struct NotifPolicy { pub chroot_denied: Vec, pub deterministic_dirs: bool, pub hostname: Option, + pub has_http_acl: bool, } // ============================================================ @@ -497,7 +507,7 @@ async fn dispatch( } // Network syscalls - if policy.has_net_allowlist + if (policy.has_net_allowlist || policy.has_http_acl) && (nr == libc::SYS_connect || nr == libc::SYS_sendto || nr == libc::SYS_sendmsg) { return crate::network::handle_net(notif, state, notif_fd).await; diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index e1131fc..f531e9e 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -48,3 +48,6 @@ mod test_chroot; #[path = "integration/test_dry_run.rs"] mod test_dry_run; + +#[path = "integration/test_http_acl.rs"] +mod test_http_acl; diff --git a/crates/sandlock-core/tests/integration/test_http_acl.rs b/crates/sandlock-core/tests/integration/test_http_acl.rs new file mode 100644 index 0000000..6547c19 --- /dev/null +++ b/crates/sandlock-core/tests/integration/test_http_acl.rs @@ -0,0 +1,523 @@ +use sandlock_core::{Policy, Sandbox}; +use std::io::{BufRead, BufReader, Read as _, Write as _}; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::thread; + +fn temp_file(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "sandlock-test-http-{}-{}", + name, + std::process::id() + )) +} + +fn base_policy() -> sandlock_core::PolicyBuilder { + Policy::builder() + .fs_read("/usr") + .fs_read("/lib") + .fs_read("/lib64") + .fs_read("/bin") + .fs_read("/etc") + .fs_read("/proc") + .fs_read("/dev") + .fs_read("/tmp") + .fs_write("/tmp") +} + +/// Spawn a minimal HTTP server on 127.0.0.1:0 that accepts `n` requests. +/// Returns (port, join_handle). The server responds 200 with body "ok" to +/// every request regardless of method/path — ACL enforcement happens in +/// the proxy, not the origin server. +fn spawn_http_server(n: usize) -> (u16, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = thread::spawn(move || { + for _ in 0..n { + if let Ok(mut stream) = listener.accept().map(|(s, _)| s) { + handle_http_conn(&mut stream); + } + } + }); + (port, handle) +} + +/// Spawn a minimal HTTP server on [::1]:0 (IPv6 loopback). +fn spawn_http_server_v6(n: usize) -> (u16, thread::JoinHandle<()>) { + let listener = TcpListener::bind("[::1]:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = thread::spawn(move || { + for _ in 0..n { + if let Ok(mut stream) = listener.accept().map(|(s, _)| s) { + handle_http_conn(&mut stream); + } + } + }); + (port, handle) +} + +/// Read one HTTP request and write a 200 OK response. +fn handle_http_conn(stream: &mut TcpStream) { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + // Read request line + headers until blank line. + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + if line.to_lowercase().starts_with("content-length:") { + content_length = line.split(':').nth(1) + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0); + } + if line == "\r\n" || line == "\n" { + break; + } + } + // Drain request body if any. + if content_length > 0 { + let mut body = vec![0u8; content_length]; + let _ = reader.read_exact(&mut body); + } + let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +fn http_script(url: &str, out: &std::path::Path) -> String { + format!( + concat!( + "import urllib.request, urllib.error\n", + "try:\n", + " resp = urllib.request.urlopen('{url}')\n", + " open('{out}', 'w').write('OK:' + str(resp.status))\n", + "except urllib.error.HTTPError as e:\n", + " open('{out}', 'w').write('HTTP:' + str(e.code))\n", + "except Exception as e:\n", + " open('{out}', 'w').write('ERR:' + str(e))\n", + ), + url = url, + out = out.display(), + ) +} + +fn post_script(url: &str, out: &std::path::Path) -> String { + format!( + concat!( + "import urllib.request, urllib.error\n", + "try:\n", + " req = urllib.request.Request('{url}', method='POST', data=b'test')\n", + " resp = urllib.request.urlopen(req)\n", + " open('{out}', 'w').write('OK:' + str(resp.status))\n", + "except urllib.error.HTTPError as e:\n", + " open('{out}', 'w').write('HTTP:' + str(e.code))\n", + "except Exception as e:\n", + " open('{out}', 'w').write('ERR:' + str(e))\n", + ), + url = url, + out = out.display(), + ) +} + +// ============================================================ +// Tests using local HTTP server — no external network required +// ============================================================ + +/// Allowed GET request passes through the ACL proxy to local server. +#[tokio::test] +async fn test_http_allow_get() { + let out = temp_file("allow-get"); + let (port, srv) = spawn_http_server(1); + + let policy = base_policy() + .http_allow(&format!("GET 127.0.0.1/*")) + .http_port(port) + .build() + .unwrap(); + + let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "expected OK:200, got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out); +} + +/// GET to a non-matching path should be blocked (403) by the proxy. +#[tokio::test] +async fn test_http_deny_non_matching() { + let out = temp_file("deny-nonmatch"); + // Server won't receive a connection (blocked by proxy), so don't wait. + let (port, _srv) = spawn_http_server(1); + + let policy = base_policy() + .http_allow(&format!("GET 127.0.0.1/allowed")) + .http_port(port) + .build() + .unwrap(); + + let script = http_script(&format!("http://127.0.0.1:{}/denied", port), &out); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("HTTP:403"), "expected HTTP:403, got: {}", content); + + let _ = std::fs::remove_file(&out); +} + +/// Deny rules take precedence over allow rules. +#[tokio::test] +async fn test_http_deny_precedence() { + let out_allowed = temp_file("deny-prec-allowed"); + let out_denied = temp_file("deny-prec-denied"); + let (port, srv) = spawn_http_server(1); // only 1 request gets through + + let policy = base_policy() + .http_allow(&format!("* 127.0.0.1/*")) + .http_deny(&format!("* 127.0.0.1/secret")) + .http_port(port) + .build() + .unwrap(); + + // GET /public — should succeed + let script = http_script(&format!("http://127.0.0.1:{}/public", port), &out_allowed); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_allowed).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "expected OK:200 for /public, got: {}", content); + + // GET /secret — should be denied + let script = http_script(&format!("http://127.0.0.1:{}/secret", port), &out_denied); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_denied).unwrap_or_default(); + assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for /secret, got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out_allowed); + let _ = std::fs::remove_file(&out_denied); +} + +/// Without any HTTP ACL rules, traffic passes through normally. +#[tokio::test] +async fn test_http_no_acl_unrestricted() { + let out = temp_file("no-acl"); + let (port, srv) = spawn_http_server(1); + + let policy = base_policy().build().unwrap(); + + let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "expected OK:200 (unrestricted), got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out); +} + +/// Allow GET but not POST to the same endpoint — verifies method-level ACL. +#[tokio::test] +async fn test_http_method_filtering() { + let out_get = temp_file("method-get"); + let out_post = temp_file("method-post"); + let (port, srv) = spawn_http_server(1); // only GET goes through + + let policy = base_policy() + .http_allow(&format!("GET 127.0.0.1/anything")) + .http_port(port) + .build() + .unwrap(); + + // GET should succeed + let script = http_script(&format!("http://127.0.0.1:{}/anything", port), &out_get); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_get).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "expected OK:200 for GET, got: {}", content); + + // POST should be denied + let script = post_script(&format!("http://127.0.0.1:{}/anything", port), &out_post); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_post).unwrap_or_default(); + assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for POST, got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out_get); + let _ = std::fs::remove_file(&out_post); +} + +/// Multiple allow rules — only matching ones pass. +#[tokio::test] +async fn test_http_multiple_allow_rules() { + let out_get = temp_file("multi-get"); + let out_other = temp_file("multi-other"); + let (port, srv) = spawn_http_server(1); + + let policy = base_policy() + .http_allow(&format!("GET 127.0.0.1/get")) + .http_allow(&format!("POST 127.0.0.1/post")) + .http_port(port) + .build() + .unwrap(); + + // GET /get — should succeed (matches first rule) + let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out_get); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_get).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "expected OK:200 for /get, got: {}", content); + + // GET /anything — should be denied (not in allow list) + let script = http_script(&format!("http://127.0.0.1:{}/anything", port), &out_other); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_other).unwrap_or_default(); + assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for /anything, got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out_get); + let _ = std::fs::remove_file(&out_other); +} + +/// Wildcard host allow with a specific deny — deny takes precedence. +#[tokio::test] +async fn test_http_wildcard_host() { + let out_get = temp_file("wildcard-get"); + let out_denied = temp_file("wildcard-denied"); + let (port, srv) = spawn_http_server(1); + + let policy = base_policy() + .http_allow(&format!("* 127.0.0.1/*")) + .http_deny("* */admin/*") + .http_port(port) + .build() + .unwrap(); + + // GET /get — should succeed + let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out_get); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_get).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "expected OK:200 for /get, got: {}", content); + + // GET /admin/settings — should be denied + let script = http_script(&format!("http://127.0.0.1:{}/admin/settings", port), &out_denied); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_denied).unwrap_or_default(); + assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for /admin/settings, got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out_get); + let _ = std::fs::remove_file(&out_denied); +} + +/// Non-intercepted port traffic should NOT go through the proxy. +#[tokio::test] +async fn test_http_non_intercepted_port() { + let out = temp_file("non-intercept"); + + // ACL intercepts port 80 by default, not random ports + let policy = base_policy() + .http_allow("GET example.com/get") + .build() + .unwrap(); + + let script = format!( + concat!( + "import socket, threading\n", + "try:\n", + " srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + " srv.bind(('127.0.0.1', 0))\n", + " port = srv.getsockname()[1]\n", + " srv.listen(1)\n", + " def accept_one():\n", + " conn, _ = srv.accept()\n", + " conn.send(b'HELLO')\n", + " conn.close()\n", + " t = threading.Thread(target=accept_one, daemon=True)\n", + " t.start()\n", + " c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + " c.settimeout(2)\n", + " c.connect(('127.0.0.1', port))\n", + " data = c.recv(10)\n", + " c.close()\n", + " srv.close()\n", + " open('{out}', 'w').write('OK:' + data.decode())\n", + "except Exception as e:\n", + " open('{out}', 'w').write('ERR:' + str(e))\n", + ), + out = out.display(), + ); + + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:HELLO"), "expected OK:HELLO, got: {}", content); + + let _ = std::fs::remove_file(&out); +} + +// ============================================================ +// IPv6 tests +// ============================================================ + +/// IPv6 loopback: allowed GET via [::1] passes through the ACL proxy. +#[tokio::test] +async fn test_http_acl_ipv6_allow() { + let out = temp_file("ipv6-allow"); + let (port, srv) = spawn_http_server_v6(1); + + let policy = base_policy() + .http_allow("GET */*") + .http_port(port) + .build() + .unwrap(); + + let script = http_script(&format!("http://[::1]:{}/get", port), &out); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "expected OK:200 for IPv6 allow, got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out); +} + +/// IPv6 loopback: non-matching path denied by ACL proxy. +#[tokio::test] +async fn test_http_acl_ipv6_deny() { + let out = temp_file("ipv6-deny"); + let (port, _srv) = spawn_http_server_v6(1); + + let policy = base_policy() + .http_allow("GET */allowed") + .http_port(port) + .build() + .unwrap(); + + let script = http_script(&format!("http://[::1]:{}/denied", port), &out); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for IPv6 deny, got: {}", content); + + let _ = std::fs::remove_file(&out); +} + +/// IPv6 non-intercepted port should pass through without proxy interference. +#[tokio::test] +async fn test_http_ipv6_non_intercepted_port() { + let out = temp_file("ipv6-non-intercept"); + + let policy = base_policy() + .http_allow("GET example.com/get") + .build() + .unwrap(); + + let script = format!( + concat!( + "import socket, threading\n", + "try:\n", + " srv = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)\n", + " srv.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)\n", + " srv.bind(('::1', 0))\n", + " port = srv.getsockname()[1]\n", + " srv.listen(1)\n", + " def accept_one():\n", + " conn, _ = srv.accept()\n", + " conn.send(b'HELLO6')\n", + " conn.close()\n", + " t = threading.Thread(target=accept_one, daemon=True)\n", + " t.start()\n", + " c = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)\n", + " c.settimeout(2)\n", + " c.connect(('::1', port))\n", + " data = c.recv(10)\n", + " c.close()\n", + " srv.close()\n", + " open('{out}', 'w').write('OK:' + data.decode())\n", + "except Exception as e:\n", + " open('{out}', 'w').write('ERR:' + str(e))\n", + ), + out = out.display(), + ); + + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!(content.starts_with("OK:HELLO6"), "expected OK:HELLO6, got: {}", content); + + let _ = std::fs::remove_file(&out); +} + +/// IPv6 method filtering: allow GET but deny POST via [::1]. +#[tokio::test] +async fn test_http_acl_ipv6_method_filtering() { + let out_get = temp_file("ipv6-method-get"); + let out_post = temp_file("ipv6-method-post"); + let (port, srv) = spawn_http_server_v6(1); // only GET goes through + + let policy = base_policy() + .http_allow("GET */*") + .http_port(port) + .build() + .unwrap(); + + // GET should succeed + let script = http_script(&format!("http://[::1]:{}/anything", port), &out_get); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_get).unwrap_or_default(); + assert!(content.starts_with("OK:200"), "expected OK:200 for IPv6 GET, got: {}", content); + + // POST should be denied + let script = post_script(&format!("http://[::1]:{}/anything", port), &out_post); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + assert!(result.success()); + let content = std::fs::read_to_string(&out_post).unwrap_or_default(); + assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for IPv6 POST, got: {}", content); + + srv.join().unwrap(); + let _ = std::fs::remove_file(&out_get); + let _ = std::fs::remove_file(&out_post); +} diff --git a/crates/sandlock-core/tests/integration/test_policy_fn.rs b/crates/sandlock-core/tests/integration/test_policy_fn.rs index 8d823e2..b64f488 100644 --- a/crates/sandlock-core/tests/integration/test_policy_fn.rs +++ b/crates/sandlock-core/tests/integration/test_policy_fn.rs @@ -214,7 +214,7 @@ async fn test_policy_fn_execve_argv() { /// Test argv_contains helper. #[tokio::test] async fn test_policy_fn_deny_by_argv() { - let out = temp_file("deny-argv"); + let _out = temp_file("deny-argv"); let policy = base_policy() .policy_fn(move |event, _ctx| { diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index badc6af..2429313 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -367,6 +367,74 @@ pub unsafe extern "C" fn sandlock_policy_builder_uid( Box::into_raw(Box::new(builder.uid(id))) } +// ---------------------------------------------------------------- +// Policy Builder — HTTP ACL +// ---------------------------------------------------------------- + +/// # Safety +/// `b` and `rule` must be valid pointers. +#[no_mangle] +pub unsafe extern "C" fn sandlock_policy_builder_http_allow( + b: *mut PolicyBuilder, + rule: *const c_char, +) -> *mut PolicyBuilder { + if b.is_null() || rule.is_null() { return b; } + let rule = CStr::from_ptr(rule).to_str().unwrap_or(""); + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.http_allow(rule))) +} + +/// # Safety +/// `b` and `rule` must be valid pointers. +#[no_mangle] +pub unsafe extern "C" fn sandlock_policy_builder_http_deny( + b: *mut PolicyBuilder, + rule: *const c_char, +) -> *mut PolicyBuilder { + if b.is_null() || rule.is_null() { return b; } + let rule = CStr::from_ptr(rule).to_str().unwrap_or(""); + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.http_deny(rule))) +} + +/// # Safety +/// `b` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn sandlock_policy_builder_http_port( + b: *mut PolicyBuilder, + port: u16, +) -> *mut PolicyBuilder { + if b.is_null() { return b; } + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.http_port(port))) +} + +/// # Safety +/// `b` and `path` must be valid pointers. +#[no_mangle] +pub unsafe extern "C" fn sandlock_policy_builder_https_ca( + b: *mut PolicyBuilder, + path: *const c_char, +) -> *mut PolicyBuilder { + if b.is_null() || path.is_null() { return b; } + let path = CStr::from_ptr(path).to_str().unwrap_or(""); + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.https_ca(path))) +} + +/// # Safety +/// `b` and `path` must be valid pointers. +#[no_mangle] +pub unsafe extern "C" fn sandlock_policy_builder_https_key( + b: *mut PolicyBuilder, + path: *const c_char, +) -> *mut PolicyBuilder { + if b.is_null() || path.is_null() { return b; } + let path = CStr::from_ptr(path).to_str().unwrap_or(""); + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.https_key(path))) +} + // ---------------------------------------------------------------- // Policy Builder — isolation & determinism // ---------------------------------------------------------------- diff --git a/python/README.md b/python/README.md index 5a02e8a..856fca4 100644 --- a/python/README.md +++ b/python/README.md @@ -67,6 +67,37 @@ Unset fields mean "no restriction" unless noted otherwise. | `no_raw_sockets` | `bool` | `True` | Block raw IP sockets | | `no_udp` | `bool` | `False` | Block UDP sockets | +#### HTTP ACL + +Enforce method + host + path rules on HTTP traffic via a transparent +MITM proxy. When `http_allow` is set, all non-matching HTTP requests are +denied by default. Deny rules are checked first and take precedence. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `http_allow` | `list[str]` | `[]` | Allow rules in `"METHOD host/path"` format | +| `http_deny` | `list[str]` | `[]` | Deny rules in `"METHOD host/path"` format | +| `http_ports` | `list[int]` | `[80]` | TCP ports to intercept (443 added when `https_ca` is set) | +| `https_ca` | `str \| None` | `None` | CA certificate for HTTPS MITM | +| `https_key` | `str \| None` | `None` | CA private key for HTTPS MITM | + +Rule format: `"METHOD host/path"` where method and host can be `*` for +wildcard, and path supports trailing `*` for prefix matching. Paths are +normalized (percent-decoding, `..` resolution, `//` collapsing) before +matching to prevent bypasses. + +```python +policy = Policy( + fs_readable=["/usr", "/lib", "/etc"], + http_allow=[ + "GET docs.python.org/*", + "POST api.openai.com/v1/chat/completions", + ], + http_deny=["* */admin/*"], +) +result = Sandbox(policy).run(["python3", "agent.py"]) +``` + #### IPC and process isolation | Parameter | Type | Default | Description | diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index ebf7eb2..d7b0a7d 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -93,6 +93,11 @@ def _builder_fn(name, *extra_args): _b_port_remap = _builder_fn("sandlock_policy_builder_port_remap", ctypes.c_bool) _b_no_raw_sockets = _builder_fn("sandlock_policy_builder_no_raw_sockets", ctypes.c_bool) _b_no_udp = _builder_fn("sandlock_policy_builder_no_udp", ctypes.c_bool) +_b_http_allow = _builder_fn("sandlock_policy_builder_http_allow", ctypes.c_char_p) +_b_http_deny = _builder_fn("sandlock_policy_builder_http_deny", ctypes.c_char_p) +_b_http_port = _builder_fn("sandlock_policy_builder_http_port", ctypes.c_uint16) +_b_https_ca = _builder_fn("sandlock_policy_builder_https_ca", ctypes.c_char_p) +_b_https_key = _builder_fn("sandlock_policy_builder_https_key", ctypes.c_char_p) _b_uid = _builder_fn("sandlock_policy_builder_uid", ctypes.c_uint32) _b_isolate_ipc = _builder_fn("sandlock_policy_builder_isolate_ipc", ctypes.c_bool) _b_isolate_signals = _builder_fn("sandlock_policy_builder_isolate_signals", ctypes.c_bool) @@ -676,6 +681,7 @@ def __del__(self): "cpu_cores", "gpu_devices", "net_allow_hosts", "net_bind", "net_connect", "port_remap", "no_raw_sockets", "no_udp", + "http_allow", "http_deny", "http_ports", "https_ca", "https_key", "uid", "isolate_ipc", "isolate_signals", "random_seed", "time_start", "clean_env", "close_fds", "env", "deny_syscalls", "allow_syscalls", "isolate_pids", "max_open_files", @@ -759,6 +765,17 @@ def _build_from_policy(policy: PolicyDataclass): for port in parse_ports(policy.net_connect) if policy.net_connect else []: b = _b_net_connect_port(b, port) + for rule in (policy.http_allow or []): + b = _b_http_allow(b, _encode(str(rule))) + for rule in (policy.http_deny or []): + b = _b_http_deny(b, _encode(str(rule))) + for port in (policy.http_ports or []): + b = _b_http_port(b, int(port)) + if policy.https_ca: + b = _b_https_ca(b, _encode(str(policy.https_ca))) + if policy.https_key: + b = _b_https_key(b, _encode(str(policy.https_key))) + if policy.port_remap: b = _b_port_remap(b, True) b = _b_no_raw_sockets(b, policy.no_raw_sockets) diff --git a/python/src/sandlock/mcp/_policy.py b/python/src/sandlock/mcp/_policy.py index 7ff4629..c3836fc 100644 --- a/python/src/sandlock/mcp/_policy.py +++ b/python/src/sandlock/mcp/_policy.py @@ -69,10 +69,12 @@ def policy_for_tool( workspace, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", _PYTHON_PREFIX, ])), - "net_connect": [], + "net_bind": [0], + "net_connect": [0], "isolate_pids": True, "isolate_ipc": True, "no_raw_sockets": True, + "no_udp": True, "clean_env": True, } diff --git a/python/src/sandlock/policy.py b/python/src/sandlock/policy.py index b9f43ef..25239c0 100644 --- a/python/src/sandlock/policy.py +++ b/python/src/sandlock/policy.py @@ -186,6 +186,26 @@ class Policy: IP-family sockets — AF_UNIX datagrams are unaffected. Useful when only TCP connectivity is desired. Enforced via seccomp BPF.""" + # HTTP ACL + http_allow: Sequence[str] = field(default_factory=list) + """HTTP allow rules. Format: "METHOD host/path" with glob matching. + When non-empty, all other HTTP requests are denied by default. + A transparent MITM proxy is spawned in the supervisor.""" + + http_deny: Sequence[str] = field(default_factory=list) + """HTTP deny rules. Checked before allow rules. Format: "METHOD host/path".""" + + http_ports: Sequence[int] = field(default_factory=list) + """TCP ports to intercept for HTTP ACL. Defaults to [80] (plus 443 with + https_ca). Override to intercept custom ports like 8080.""" + + https_ca: str | None = None + """PEM CA certificate path for HTTPS MITM. When set, port 443 is also + intercepted by the HTTP ACL proxy.""" + + https_key: str | None = None + """PEM CA private key path for HTTPS MITM. Required with https_ca.""" + # Resource limits max_memory: str | int | None = None """Memory limit. String like '512M' or int bytes.""" diff --git a/python/tests/test_mcp.py b/python/tests/test_mcp.py index dc6f340..a8e6782 100644 --- a/python/tests/test_mcp.py +++ b/python/tests/test_mcp.py @@ -14,7 +14,9 @@ def test_no_capabilities(self): policy = policy_for_tool(workspace="/tmp/ws") assert policy.fs_writable == [] assert "/tmp/ws" in policy.fs_readable - assert policy.net_connect == [] + assert policy.net_connect == [0] + assert policy.net_bind == [0] + assert policy.no_udp is True assert policy.isolate_pids is True assert policy.isolate_ipc is True assert policy.no_raw_sockets is True @@ -22,7 +24,7 @@ def test_no_capabilities(self): def test_empty_capabilities(self): policy = policy_for_tool(workspace="/tmp/ws", capabilities={}) assert policy.fs_writable == [] - assert policy.net_connect == [] + assert policy.net_connect == [0] class TestCapabilities: