diff --git a/.gitignore b/.gitignore index 35d1f8e..386a6f0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ __pycache__/ .DS_Store .idea/ .vscode/ + +# Rust build output +capture-sidecar/target/ +iot-sidecar/target/ diff --git a/capture-sidecar/Cargo.lock b/capture-sidecar/Cargo.lock new file mode 100644 index 0000000..281af81 --- /dev/null +++ b/capture-sidecar/Cargo.lock @@ -0,0 +1,1026 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "capture-sidecar" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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 = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[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-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +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 = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +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 = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[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 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[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 2.0.119", +] + +[[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 = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/capture-sidecar/Cargo.toml b/capture-sidecar/Cargo.toml new file mode 100644 index 0000000..8c5f2cf --- /dev/null +++ b/capture-sidecar/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "capture-sidecar" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "BearBrowser network-visibility sidecar: session packet capture (tshark/tcpdump/dumpcap), live connection map, and per-domain firewall. Every effectful action is governed by the canonical agent-control-bridge policy engine; loopback-only; no telemetry." +license = "MPL-2.0" +publish = false + +[[bin]] +name = "capture-sidecar" +path = "src/main.rs" + +[dependencies] +# Async runtime + HTTP/WS server (loopback only). +tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "net", "process", "sync", "signal", "io-util", "time"] } +axum = { version = "0.7", features = ["ws", "json", "tokio"] } + +# Serialization of the wire API and the bridge Decision JSON. +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Error handling. +anyhow = "1" +thiserror = "1" + +# WS fan-out of capture lines + connection records. +tokio-stream = { version = "0.1", features = ["sync"] } + +# Structured logging (stderr only; never the network). +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } + +# Event / capture ids. +uuid = { version = "1", features = ["v4"] } diff --git a/capture-sidecar/src/capture.rs b/capture-sidecar/src/capture.rs new file mode 100644 index 0000000..a7b9850 --- /dev/null +++ b/capture-sidecar/src/capture.rs @@ -0,0 +1,212 @@ +//! Session packet capture. Detects an engine (dumpcap > tshark > tcpdump), +//! spawns it on the "any" interface, streams parsed lines to the event bus, and +//! buffers output so it can be saved. Ported from the shell's BBPacketCapture, +//! extended with dumpcap (true pcapng) and cross-platform binary search. + +use crate::model::{Engine, SidecarEvent}; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::{broadcast, Mutex}; + +/// Candidate binary paths per engine, in preference order. Covers Homebrew +/// (arm64 + intel), system, common Linux, and Windows (Wireshark install dir). +fn candidates() -> Vec<(Engine, &'static str)> { + vec![ + // dumpcap — writes real pcapng; best fidelity. + (Engine::Dumpcap, "/opt/homebrew/bin/dumpcap"), + (Engine::Dumpcap, "/usr/local/bin/dumpcap"), + (Engine::Dumpcap, "/usr/bin/dumpcap"), + (Engine::Dumpcap, r"C:\Program Files\Wireshark\dumpcap.exe"), + // tshark — parsed fields. + (Engine::Tshark, "/opt/homebrew/bin/tshark"), + (Engine::Tshark, "/usr/local/bin/tshark"), + (Engine::Tshark, "/usr/bin/tshark"), + (Engine::Tshark, r"C:\Program Files\Wireshark\tshark.exe"), + // tcpdump — always-present fallback on unix. + (Engine::Tcpdump, "/opt/homebrew/bin/tcpdump"), + (Engine::Tcpdump, "/usr/local/bin/tcpdump"), + (Engine::Tcpdump, "/usr/sbin/tcpdump"), + (Engine::Tcpdump, "/usr/bin/tcpdump"), + ] +} + +/// The detected engine + its absolute binary path. +#[derive(Clone, Debug)] +pub struct Detected { + pub engine: Engine, + pub binary: PathBuf, +} + +/// First installed + executable engine binary, or None (the panel then shows +/// the "install Wireshark / grant capture perms" guidance, like the shell). +pub fn detect() -> Option { + for (engine, path) in candidates() { + let p = PathBuf::from(path); + if is_executable(&p) { + return Some(Detected { engine, binary: p }); + } + } + None +} + +#[cfg(unix)] +fn is_executable(p: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(p) + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(p: &std::path::Path) -> bool { + p.is_file() +} + +/// Build the engine argv for a whole-session capture on "any". `pcap_out` is +/// Some only for dumpcap (real pcapng); tshark/tcpdump stream text we buffer. +/// `host` optionally narrows to one endpoint (mirrors the shell's host filter). +fn argv(engine: Engine, host: Option<&str>, pcap_out: Option<&PathBuf>) -> Vec { + let s = |x: &str| x.to_string(); + match engine { + Engine::Dumpcap => { + // -i any -w ; -f capture filter narrows to a host if given. + let mut a = vec![s("-i"), s("any"), s("-w")]; + a.push(pcap_out.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| s("-"))); + if let Some(h) = host { + a.push(s("-f")); + a.push(format!("host {h}")); + } + a + } + Engine::Tshark => { + let mut a = vec![s("-i"), s("any")]; + if let Some(h) = host { + a.push(s("-Y")); + a.push(format!("ip.host contains \"{h}\"")); + } + a.extend([ + s("-T"), s("fields"), + s("-e"), s("frame.time_relative"), + s("-e"), s("ip.src"), + s("-e"), s("ip.dst"), + s("-e"), s("tcp.dstport"), + s("-e"), s("frame.len"), + s("-E"), s("separator= | "), + ]); + a + } + Engine::Tcpdump => { + let mut a = vec![s("-l"), s("-n"), s("-i"), s("any")]; + match host { + Some(h) => { + a.push(s("host")); + a.push(h.to_string()); + } + None => a.extend([s("port"), s("443"), s("or"), s("port"), s("80")]), + } + a + } + } +} + +/// A running capture. Dropping/stopping it terminates the child. +pub struct Session { + pub engine: Engine, + pub pcap_path: Option, + child: Child, + /// Text buffer for engines that stream to stdout (tshark/tcpdump). + pub buffer: Arc>, +} + +impl Session { + /// Spawn the engine and pump each stdout line onto `bus` as a Packet event. + /// For dumpcap the packets go to the pcap file; we still surface a heartbeat. + pub fn start( + det: &Detected, + host: Option<&str>, + pcap_path: Option, + bus: broadcast::Sender, + ) -> std::io::Result { + let args = argv(det.engine, host, pcap_path.as_ref()); + let mut cmd = Command::new(&det.binary); + cmd.args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd.spawn()?; + + let buffer = Arc::new(Mutex::new(String::new())); + + // stdout: for tshark/tcpdump this is the packet text; for dumpcap the + // packets go to the file and stdout is quiet, so we also pump stderr + // (dumpcap prints "Packets captured: N" progress there). + if let Some(out) = child.stdout.take() { + spawn_pump(out, bus.clone(), buffer.clone(), det.engine.writes_pcap()); + } + if let Some(err) = child.stderr.take() { + // stderr lines are surfaced but never buffered as capture payload. + spawn_pump(err, bus.clone(), Arc::new(Mutex::new(String::new())), true); + } + + let _ = bus.send(SidecarEvent::CaptureState { + running: true, + engine: Some(det.engine.label().to_string()), + }); + + Ok(Session { + engine: det.engine, + pcap_path, + child, + buffer, + }) + } + + pub async fn stop(mut self, bus: &broadcast::Sender) { + let _ = self.child.start_kill(); + let _ = self.child.wait().await; + let _ = bus.send(SidecarEvent::CaptureState { + running: false, + engine: Some(self.engine.label().to_string()), + }); + } + + /// Save the capture. dumpcap already wrote a pcapng to `pcap_path`; for the + /// text engines we flush the buffer to `dest`. Returns the written path. + pub async fn save(&self, dest: &PathBuf) -> std::io::Result { + if let Some(pcap) = &self.pcap_path { + if pcap.exists() { + std::fs::copy(pcap, dest)?; + return Ok(dest.clone()); + } + } + let text = self.buffer.lock().await.clone(); + std::fs::write(dest, text)?; + Ok(dest.clone()) + } +} + +fn spawn_pump( + reader: impl tokio::io::AsyncRead + Unpin + Send + 'static, + bus: broadcast::Sender, + buffer: Arc>, + buffer_is_noop: bool, +) { + tokio::spawn(async move { + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.is_empty() { + continue; + } + if !buffer_is_noop { + let mut b = buffer.lock().await; + b.push_str(&line); + b.push('\n'); + } + // A closed bus (no subscribers) is fine — we keep buffering. + let _ = bus.send(SidecarEvent::Packet { line }); + } + }); +} diff --git a/capture-sidecar/src/firewall.rs b/capture-sidecar/src/firewall.rs new file mode 100644 index 0000000..e12c9b0 --- /dev/null +++ b/capture-sidecar/src/firewall.rs @@ -0,0 +1,106 @@ +//! Per-domain firewall — Ask / Allow / Block, persisted across sessions. +//! Ported from the native shell's BBFirewall (which used NSUserDefaults); here +//! rules persist to a small JSON file under the sidecar state dir. + +use crate::model::FirewallDecision; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Mutex; + +pub struct Firewall { + rules: Mutex>, + path: Option, +} + +impl Firewall { + /// Load rules from `path` if present. A missing/corrupt file yields an empty + /// ruleset (fail-open to "Ask", never a hard error — the browser must boot). + pub fn load(path: Option) -> Self { + let rules = path + .as_ref() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default(); + Firewall { + rules: Mutex::new(rules), + path, + } + } + + pub fn decision_for(&self, domain: &str) -> FirewallDecision { + self.rules + .lock() + .ok() + .and_then(|r| r.get(domain).copied()) + .unwrap_or(FirewallDecision::Ask) + } + + /// Set (or clear, when Ask) a rule and persist. `Ask` removes the entry so + /// the ruleset only stores explicit decisions — matching the shell. + pub fn set(&self, domain: &str, decision: FirewallDecision) { + if let Ok(mut r) = self.rules.lock() { + match decision { + FirewallDecision::Ask => { + r.remove(domain); + } + d => { + r.insert(domain.to_string(), d); + } + } + self.persist(&r); + } + } + + pub fn all(&self) -> BTreeMap { + self.rules.lock().map(|r| r.clone()).unwrap_or_default() + } + + pub fn clear(&self) { + if let Ok(mut r) = self.rules.lock() { + r.clear(); + self.persist(&r); + } + } + + fn persist(&self, rules: &BTreeMap) { + let Some(path) = &self.path else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(rules) { + // Best-effort: a failed write must not crash the browser's sidecar. + let _ = std::fs::write(path, json); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn set_get_and_ask_clears() { + let fw = Firewall::load(None); + assert_eq!(fw.decision_for("x.com"), FirewallDecision::Ask); + fw.set("x.com", FirewallDecision::Block); + assert_eq!(fw.decision_for("x.com"), FirewallDecision::Block); + fw.set("x.com", FirewallDecision::Ask); + assert_eq!(fw.decision_for("x.com"), FirewallDecision::Ask); + assert!(fw.all().is_empty()); + } + + #[test] + fn persists_round_trip() { + let dir = std::env::temp_dir().join(format!("bb-fw-{}", uuid::Uuid::new_v4())); + let path = dir.join("firewall.json"); + { + let fw = Firewall::load(Some(path.clone())); + fw.set("ads.example", FirewallDecision::Block); + fw.set("good.example", FirewallDecision::Allow); + } + let fw2 = Firewall::load(Some(path.clone())); + assert_eq!(fw2.decision_for("ads.example"), FirewallDecision::Block); + assert_eq!(fw2.decision_for("good.example"), FirewallDecision::Allow); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/capture-sidecar/src/gate.rs b/capture-sidecar/src/gate.rs new file mode 100644 index 0000000..686bd44 --- /dev/null +++ b/capture-sidecar/src/gate.rs @@ -0,0 +1,152 @@ +//! The gate — the ONE-ENGINE invariant (cloned from iot-sidecar). +//! +//! The sidecar reimplements NO policy. Every effectful action (start/stop/save a +//! capture, set a firewall rule) is evaluated by the canonical enforcement +//! engine, `scripts/agent-control-bridge.py --surface capture`, invoked as a +//! subprocess. We permit the action ONLY when `decision == "permit"`. Any other +//! outcome — an explicit deny, a non-zero exit, a spawn failure, or unparseable +//! output — FAILS CLOSED. Read-only reads (status, map snapshot, firewall list) +//! do NOT pass through the gate; only side effects do. + +use crate::model::Command; +use serde::Deserialize; +use std::path::PathBuf; +use tokio::process::Command as Proc; + +/// The attested ReasoningEvent embedded in a decision. Fields are parsed for +/// completeness and surfaced in the decision echo; not all are read on the hot +/// path, hence the allow. +#[derive(Clone, Debug, Deserialize)] +#[allow(dead_code)] +pub struct AttestedEvent { + #[serde(default)] + pub id: Option, + #[serde(rename = "eventType", default)] + pub event_type: Option, + #[serde(rename = "actionClass", default)] + pub action_class: Option, +} + +/// The bridge's Decision JSON. Field names mirror the bridge output exactly. +/// Several fields are echoed to the cockpit rather than read here. +#[derive(Clone, Debug, Deserialize, serde::Serialize)] +#[allow(dead_code)] +pub struct Decision { + #[serde(rename = "requestedAction")] + pub requested_action: String, + #[serde(rename = "effectiveAction", default)] + pub effective_action: Option, + #[serde(rename = "actionClass")] + pub action_class: String, + /// "permit" | "deny". + pub decision: String, + pub reason: String, + #[serde(rename = "reclassifiedBy", default)] + pub reclassified_by: Option, + #[serde(rename = "attestedEvent", default)] + #[serde(skip_serializing)] + pub attested_event: Option, + #[serde(rename = "runRef", default)] + pub run_ref: Option, +} + +impl Decision { + pub fn permitted(&self) -> bool { + self.decision == "permit" + } + + fn fail_closed(action: &str, reason: impl Into) -> Self { + Decision { + requested_action: action.to_string(), + effective_action: Some(action.to_string()), + action_class: "prohibited".to_string(), + decision: "deny".to_string(), + reason: reason.into(), + reclassified_by: None, + attested_event: None, + run_ref: None, + } + } +} + +#[derive(Clone, Debug)] +pub struct GateConfig { + pub python: String, + pub bridge_path: PathBuf, + pub repo_root: PathBuf, +} + +impl GateConfig { + pub fn from_repo_root(repo_root: impl Into) -> Self { + let repo_root = repo_root.into(); + let bridge_path = repo_root.join("scripts").join("agent-control-bridge.py"); + GateConfig { + python: "python3".to_string(), + bridge_path, + repo_root, + } + } +} + +/// Evaluate a command against the canonical engine. Infallible by design: every +/// error path folds into a fail-closed deny. +pub async fn evaluate(cfg: &GateConfig, command: &Command) -> Decision { + let mut proc = Proc::new(&cfg.python); + proc.arg(&cfg.bridge_path) + .arg("--surface") + .arg("capture") + .arg("--action") + .arg(&command.action) + .arg("--json") + .current_dir(&cfg.repo_root); + + proc.arg("--param").arg(format!("actor={}", command.actor.as_str())); + proc.arg("--param") + .arg(format!("userGesture={}", command.user_gesture)); + + for (k, v) in &command.params { + if k == "actor" || k == "userGesture" { + continue; + } + proc.arg("--param").arg(format!("{k}={v}")); + } + + if let Some(token) = &command.approval_token { + proc.arg("--approval-token").arg(token); + } + + let output = match proc.output().await { + Ok(o) => o, + Err(e) => { + tracing::error!(action = %command.action, error = %e, "gate: bridge spawn failed"); + return Decision::fail_closed( + &command.action, + format!("gate: bridge spawn failed: {e}"), + ); + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + match serde_json::from_str::(&stdout) { + Ok(decision) => { + if !output.status.success() && decision.permitted() { + tracing::warn!( + action = %command.action, + "gate: bridge reported permit but exited non-zero; failing closed" + ); + return Decision::fail_closed( + &command.action, + "gate: bridge permit contradicted by non-zero exit", + ); + } + decision + } + Err(e) => { + tracing::error!(action = %command.action, error = %e, "gate: unparseable bridge output; failing closed"); + Decision::fail_closed( + &command.action, + format!("gate: unparseable bridge output: {e}"), + ) + } + } +} diff --git a/capture-sidecar/src/main.rs b/capture-sidecar/src/main.rs new file mode 100644 index 0000000..2ef7db8 --- /dev/null +++ b/capture-sidecar/src/main.rs @@ -0,0 +1,147 @@ +//! capture-sidecar — BearBrowser network-visibility sidecar. +//! +//! Loopback-only REST+WS server providing three governed surfaces ported from +//! the native macOS shell: session packet capture, a live connection map, and a +//! per-domain firewall. Every side effect is classified by the canonical +//! `scripts/agent-control-bridge.py --surface capture` engine — this binary +//! reimplements NO policy and refuses to start if the bridge is absent. + +mod capture; +mod firewall; +mod gate; +mod model; +mod netmap; +mod server; + +use firewall::Firewall; +use gate::GateConfig; +use netmap::NetworkMonitor; +use server::AppState; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::broadcast; + +struct Config { + port: u16, + repo_root: PathBuf, + state_dir: PathBuf, + save_dir: PathBuf, +} + +impl Config { + fn from_args() -> anyhow::Result { + let mut port: u16 = std::env::var("CAPTURE_SIDECAR_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let mut repo_root = std::env::var("BEARBROWSER_REPO_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default()); + let mut state_dir = std::env::var("CAPTURE_SIDECAR_STATE") + .map(PathBuf::from) + .unwrap_or_else(|_| default_state_dir()); + + let args: Vec = std::env::args().collect(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--port" => { + i += 1; + port = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(port); + } + "--repo-root" => { + i += 1; + if let Some(v) = args.get(i) { + repo_root = PathBuf::from(v); + } + } + "--state" => { + i += 1; + if let Some(v) = args.get(i) { + state_dir = PathBuf::from(v); + } + } + other => anyhow::bail!("unknown argument: {other}"), + } + i += 1; + } + + // Captures default to the user's Desktop (as the shell did), falling + // back to the state dir when there's no home. + let save_dir = dirs_desktop().unwrap_or_else(|| state_dir.clone()); + + Ok(Config { + port, + repo_root, + state_dir, + save_dir, + }) + } +} + +fn default_state_dir() -> PathBuf { + // ~/.local/share/BearBrowser/capture (unix) — a stable per-user location. + if let Some(home) = home_dir() { + home.join(".local/share/BearBrowser/capture") + } else { + std::env::temp_dir().join("bearbrowser-capture") + } +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn dirs_desktop() -> Option { + home_dir().map(|h| h.join("Desktop")).filter(|p| p.is_dir()) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_env("CAPTURE_SIDECAR_LOG") + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + let cfg = Config::from_args()?; + + // ONE-ENGINE invariant: refuse to run ungoverned. If the canonical bridge is + // not present under repo-root, there is no policy engine to consult — a + // capture sidecar that ran anyway would be an unclassified side-effect + // surface, exactly what the architecture forbids. + let bridge = cfg.repo_root.join("scripts").join("agent-control-bridge.py"); + if !bridge.is_file() { + anyhow::bail!( + "refusing to start: canonical policy bridge not found at {}. \ + Pass --repo-root pointing at the BearBrowser checkout.", + bridge.display() + ); + } + + std::fs::create_dir_all(&cfg.state_dir).ok(); + let (events, _) = broadcast::channel(1024); + let state = AppState { + gate: Arc::new(GateConfig::from_repo_root(&cfg.repo_root)), + firewall: Arc::new(Firewall::load(Some(cfg.state_dir.join("firewall.json")))), + monitor: Arc::new(NetworkMonitor::new(2048)), + events, + session: Arc::new(tokio::sync::Mutex::new(None)), + save_dir: cfg.save_dir, + }; + + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), cfg.port); + + tokio::select! { + r = server::serve(addr, state) => r?, + _ = tokio::signal::ctrl_c() => { + tracing::info!("shutting down"); + } + } + Ok(()) +} diff --git a/capture-sidecar/src/model.rs b/capture-sidecar/src/model.rs new file mode 100644 index 0000000..889eacc --- /dev/null +++ b/capture-sidecar/src/model.rs @@ -0,0 +1,125 @@ +//! Wire + domain types shared across the capture sidecar. + +use serde::{Deserialize, Serialize}; + +/// Who initiated an action. Mirrors the bridge's `actor` param — only `user` +/// with a real gesture can reclassify a gated action down to permitted. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Actor { + User, + Agent, +} + +impl Actor { + pub fn as_str(&self) -> &'static str { + match self { + Actor::User => "user", + Actor::Agent => "agent", + } + } +} + +impl Default for Actor { + fn default() -> Self { + Actor::Agent + } +} + +/// A request to perform a governed action, forwarded verbatim to the gate. +/// `action` is the natural action name (e.g. "capture-start", "firewall-set"). +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Command { + pub action: String, + #[serde(default)] + pub actor: Actor, + #[serde(rename = "userGesture", default)] + pub user_gesture: bool, + #[serde(default)] + pub params: Vec<(String, String)>, + #[serde(rename = "approvalToken", default)] + pub approval_token: Option, +} + +/// Which capture engine the host has, in preference order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Engine { + /// Wireshark's headless capture tool — writes true pcapng. + Dumpcap, + /// Wireshark CLI — parsed fields, human-readable. + Tshark, + /// BSD/Linux tcpdump. + Tcpdump, +} + +impl Engine { + pub fn label(&self) -> &'static str { + match self { + Engine::Dumpcap => "dumpcap", + Engine::Tshark => "tshark", + Engine::Tcpdump => "tcpdump", + } + } + /// dumpcap emits a real pcapng file; the others emit text we buffer as-is. + pub fn writes_pcap(&self) -> bool { + matches!(self, Engine::Dumpcap) + } +} + +/// Category of a remote endpoint, ported from the native shell's classifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConnCategory { + Tracker, + Analytics, + Cdn, + Unknown, +} + +/// A firewall decision for a domain. Persisted across sessions. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FirewallDecision { + /// Default: follow the blocklist, prompt on unknowns. + Ask, + Allow, + Block, +} + +impl Default for FirewallDecision { + fn default() -> Self { + FirewallDecision::Ask + } +} + +/// One observed connection, streamed to the cockpit map panel. +#[derive(Clone, Debug, Serialize)] +pub struct ConnectionRecord { + /// eTLD+1 of the endpoint (e.g. "doubleclick.net"). + pub domain: String, + #[serde(rename = "pageUrl")] + pub page_url: String, + #[serde(rename = "resourceType")] + pub resource_type: String, + pub category: ConnCategory, + pub blocked: bool, + /// Unix seconds. + pub timestamp: u64, +} + +/// Events fanned out over the `/events` WebSocket. One text-JSON frame each, +/// matching the iot-sidecar convention. +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum SidecarEvent { + /// A raw line from the capture engine. + Packet { line: String }, + /// A newly observed connection (from the browser's own network telemetry). + Connection(ConnectionRecord), + /// Capture lifecycle transitions, so the panel can update its controls. + CaptureState { + running: bool, + engine: Option, + }, +} diff --git a/capture-sidecar/src/netmap.rs b/capture-sidecar/src/netmap.rs new file mode 100644 index 0000000..6db0db8 --- /dev/null +++ b/capture-sidecar/src/netmap.rs @@ -0,0 +1,135 @@ +//! Connection map — records who the session talks to, classifies each endpoint. +//! Ported from the native shell's BBConnectionRecord / BBNetworkMonitor. + +use crate::model::{ConnCategory, ConnectionRecord}; +use std::collections::VecDeque; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Naive eTLD+1: last two labels. Matches the shell's `etldForHost:` exactly +/// (deliberately simple — the classifier lists are keyed on these forms). +pub fn etld_plus_one(host: &str) -> String { + if host.is_empty() { + return String::new(); + } + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() < 2 { + return host.to_string(); + } + format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]) +} + +/// Classify an eTLD+1 into tracker / analytics / cdn / unknown. Lists ported +/// verbatim from the native shell's BBConnectionRecord classifier. +pub fn classify(etld: &str) -> ConnCategory { + const TRACKERS: &[&str] = &[ + "doubleclick.net", "googlesyndication.com", "connect.facebook.net", + "criteo.com", "adnxs.com", "rubiconproject.com", "pubmatic.com", "openx.net", + "taboola.com", "outbrain.com", "moatads.com", "scorecardresearch.com", + "quantserve.com", "turn.com", "bidswitch.net", "casalemedia.com", + ]; + const ANALYTICS: &[&str] = &[ + "google-analytics.com", "googletagmanager.com", "mixpanel.com", + "amplitude.com", "segment.io", "segment.com", "heap.io", "hotjar.com", + "fullstory.com", "logrocket.com", "smartlook.com", + ]; + const CDNS: &[&str] = &[ + "cloudflare.com", "fastly.net", "cloudfront.net", + "akamaized.net", "jsdelivr.net", "unpkg.com", "amazonaws.com", + "googleapis.com", "gstatic.com", "bootstrapcdn.com", "jquery.com", + "cdnjs.cloudflare.com", "azureedge.net", "stackpath.bootstrapcdn.com", + ]; + if TRACKERS.contains(&etld) { + ConnCategory::Tracker + } else if ANALYTICS.contains(&etld) { + ConnCategory::Analytics + } else if CDNS.contains(&etld) { + ConnCategory::Cdn + } else { + ConnCategory::Unknown + } +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Bounded, in-memory record of observed connections. No persistence — the map +/// is per-session, exactly like the shell. +pub struct NetworkMonitor { + recs: Mutex>, + cap: usize, +} + +impl NetworkMonitor { + pub fn new(cap: usize) -> Self { + NetworkMonitor { + recs: Mutex::new(VecDeque::with_capacity(cap.min(1024))), + cap, + } + } + + /// Build + store a record from a raw host, returning the built record so the + /// caller can fan it out over the event bus. + pub fn record( + &self, + host: &str, + page_url: &str, + resource_type: &str, + blocked: bool, + ) -> ConnectionRecord { + let domain = etld_plus_one(host); + let rec = ConnectionRecord { + category: classify(&domain), + domain, + page_url: page_url.to_string(), + resource_type: resource_type.to_string(), + blocked, + timestamp: now_secs(), + }; + if let Ok(mut q) = self.recs.lock() { + if q.len() >= self.cap { + q.pop_front(); + } + q.push_back(rec.clone()); + } + rec + } + + pub fn snapshot(&self) -> Vec { + self.recs + .lock() + .map(|q| q.iter().cloned().collect()) + .unwrap_or_default() + } + + pub fn clear(&self) { + if let Ok(mut q) = self.recs.lock() { + q.clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn etld_extraction() { + assert_eq!(etld_plus_one("ad.doubleclick.net"), "doubleclick.net"); + assert_eq!(etld_plus_one("example.com"), "example.com"); + assert_eq!(etld_plus_one("localhost"), "localhost"); + assert_eq!(etld_plus_one(""), ""); + } + + #[test] + fn classification() { + assert_eq!(classify("doubleclick.net"), ConnCategory::Tracker); + assert_eq!(classify("google-analytics.com"), ConnCategory::Analytics); + assert_eq!(classify("fastly.net"), ConnCategory::Cdn); + assert_eq!(classify("example.com"), ConnCategory::Unknown); + } +} diff --git a/capture-sidecar/src/server.rs b/capture-sidecar/src/server.rs new file mode 100644 index 0000000..825338f --- /dev/null +++ b/capture-sidecar/src/server.rs @@ -0,0 +1,303 @@ +//! Loopback REST + WS server. Refuses any non-loopback bind. Read-only routes +//! (status, map, firewall list) answer directly; every side effect (capture +//! start/stop/save, firewall set) is gated through the canonical bridge first. + +use crate::capture::{self, Session}; +use crate::gate::{self, Decision, GateConfig}; +use crate::firewall::Firewall; +use crate::model::{Actor, Command, FirewallDecision, SidecarEvent}; +use crate::netmap::NetworkMonitor; +use anyhow::{bail, Context, Result}; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::{broadcast, Mutex}; + +#[derive(Clone)] +pub struct AppState { + pub gate: Arc, + pub firewall: Arc, + pub monitor: Arc, + pub events: broadcast::Sender, + /// The single active capture session, if any. + pub session: Arc>>, + /// Where saved captures default to (chosen path overrides per-request). + pub save_dir: PathBuf, +} + +struct ApiError(StatusCode, String); +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.0, Json(json!({ "error": self.1 }))).into_response() + } +} +type ApiResult = std::result::Result; + +fn denied(d: &Decision) -> ApiError { + ApiError( + StatusCode::FORBIDDEN, + format!("gate denied '{}': {}", d.requested_action, d.reason), + ) +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/health", get(|| async { Json(json!({"ok": true})) })) + .route("/capture/status", get(status)) + .route("/capture/start", post(start)) + .route("/capture/stop", post(stop)) + .route("/capture/save", post(save)) + .route("/map", get(map).post(map_ingest).delete(map_clear)) + .route("/firewall", get(fw_list).post(fw_set).delete(fw_clear)) + .route("/events", get(ws_upgrade)) + .with_state(state) +} + +/// Bind loopback ONLY. Refuses both a non-loopback request and a non-loopback +/// actual bind (defense in depth, mirroring iot-sidecar). +pub async fn serve(addr: SocketAddr, state: AppState) -> Result<()> { + if !addr.ip().is_loopback() { + bail!("refusing non-loopback bind: {addr}"); + } + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("bind {addr}"))?; + let bound = listener.local_addr().context("local_addr")?; + if !bound.ip().is_loopback() { + bail!("refusing non-loopback bound addr: {bound}"); + } + tracing::info!(%bound, "capture-sidecar listening (loopback only)"); + println!("capture-sidecar listening on http://{bound}"); + axum::serve(listener, router(state)).await.context("serve")?; + Ok(()) +} + +async fn status(State(st): State) -> Json { + let det = capture::detect(); + let running = st.session.lock().await.is_some(); + Json(json!({ + "available": det.is_some(), + "engine": det.as_ref().map(|d| d.engine.label()), + "binary": det.as_ref().map(|d| d.binary.to_string_lossy()), + "running": running, + "guidance": det.is_none().then_some( + "No capture engine found. Install Wireshark (provides dumpcap/tshark) \ + or ensure tcpdump is on PATH. On macOS you may also need read access \ + to /dev/bpf* (add yourself to the access_bpf group). On Linux, grant \ + dumpcap CAP_NET_RAW or run via the wireshark group." + ), + })) +} + +#[derive(Deserialize, Default)] +struct StartBody { + #[serde(default)] + host: Option, + #[serde(default)] + actor: Actor, + #[serde(rename = "userGesture", default)] + user_gesture: bool, + #[serde(rename = "approvalToken", default)] + approval_token: Option, +} + +async fn start(State(st): State, Json(body): Json) -> ApiResult { + let cmd = Command { + action: "capture-start".into(), + actor: body.actor, + user_gesture: body.user_gesture, + approval_token: body.approval_token, + params: vec![], + }; + let decision = gate::evaluate(&st.gate, &cmd).await; + if !decision.permitted() { + return Err(denied(&decision)); + } + + let mut slot = st.session.lock().await; + if slot.is_some() { + return Err(ApiError(StatusCode::CONFLICT, "capture already running".into())); + } + let det = capture::detect().ok_or_else(|| { + ApiError(StatusCode::SERVICE_UNAVAILABLE, "no capture engine installed".into()) + })?; + let pcap_path = det.engine.writes_pcap().then(|| { + st.save_dir + .join(format!("bearbrowser-capture-{}.pcapng", uuid::Uuid::new_v4())) + }); + let session = Session::start(&det, body.host.as_deref(), pcap_path, st.events.clone()) + .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("spawn failed: {e}")))?; + *slot = Some(session); + Ok(Json(json!({ "started": true, "engine": det.engine.label(), "decision": decision })).into_response()) +} + +#[derive(Deserialize, Default)] +struct GestureBody { + #[serde(default)] + actor: Actor, + #[serde(rename = "userGesture", default)] + user_gesture: bool, + #[serde(rename = "approvalToken", default)] + approval_token: Option, + #[serde(default)] + path: Option, +} + +async fn stop(State(st): State, Json(body): Json) -> ApiResult { + let cmd = Command { + action: "capture-stop".into(), + actor: body.actor, + user_gesture: body.user_gesture, + approval_token: body.approval_token, + params: vec![], + }; + let decision = gate::evaluate(&st.gate, &cmd).await; + if !decision.permitted() { + return Err(denied(&decision)); + } + let sess = st.session.lock().await.take(); + match sess { + Some(s) => { + s.stop(&st.events).await; + Ok(Json(json!({ "stopped": true })).into_response()) + } + None => Ok(Json(json!({ "stopped": false, "reason": "no capture running" })).into_response()), + } +} + +async fn save(State(st): State, Json(body): Json) -> ApiResult { + let cmd = Command { + action: "capture-export".into(), + actor: body.actor, + user_gesture: body.user_gesture, + approval_token: body.approval_token, + params: vec![], + }; + let decision = gate::evaluate(&st.gate, &cmd).await; + if !decision.permitted() { + return Err(denied(&decision)); + } + let slot = st.session.lock().await; + let sess = slot.as_ref().ok_or_else(|| { + ApiError(StatusCode::BAD_REQUEST, "no capture to save".into()) + })?; + let dest = match &body.path { + Some(p) => PathBuf::from(p), + None => st + .save_dir + .join(format!("bearbrowser-capture-{}.txt", uuid::Uuid::new_v4())), + }; + let written = sess + .save(&dest) + .await + .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")))?; + Ok(Json(json!({ "saved": written.to_string_lossy() })).into_response()) +} + +async fn map(State(st): State) -> Json { + Json(json!({ "connections": st.monitor.snapshot() })) +} + +async fn map_clear(State(st): State) -> Json { + st.monitor.clear(); + Json(json!({ "cleared": true })) +} + +#[derive(Deserialize)] +struct MapIngest { + host: String, + #[serde(rename = "pageUrl", default)] + page_url: String, + #[serde(rename = "resourceType", default)] + resource_type: String, +} + +/// Telemetry ingest from the trusted browser: it reports a connection it made +/// (the BearNav actor hook, mirroring the shell's WKNavigationDelegate feed). +/// Ungated — this only populates a view; enforcement is the firewall's job. The +/// `blocked` flag is derived from the current firewall rule, not client-supplied. +async fn map_ingest(State(st): State, Json(b): Json) -> Json { + let domain = crate::netmap::etld_plus_one(&b.host); + let blocked = st.firewall.decision_for(&domain) == FirewallDecision::Block; + let rec = st.monitor.record(&b.host, &b.page_url, &b.resource_type, blocked); + let _ = st.events.send(SidecarEvent::Connection(rec)); + Json(json!({ "recorded": domain, "blocked": blocked })) +} + +async fn fw_list(State(st): State) -> Json { + Json(json!({ "rules": st.firewall.all() })) +} + +/// Reset all firewall rules to default (Ask). Read-ish maintenance, ungated. +async fn fw_clear(State(st): State) -> Json { + st.firewall.clear(); + Json(json!({ "cleared": true })) +} + +#[derive(Deserialize)] +struct FwSetBody { + domain: String, + decision: FirewallDecision, + #[serde(default)] + actor: Actor, + #[serde(rename = "userGesture", default)] + user_gesture: bool, + #[serde(rename = "approvalToken", default)] + approval_token: Option, +} + +async fn fw_set(State(st): State, Json(body): Json) -> ApiResult { + let cmd = Command { + action: "firewall-set".into(), + actor: body.actor, + user_gesture: body.user_gesture, + approval_token: body.approval_token, + params: vec![ + ("domain".into(), body.domain.clone()), + ("decision".into(), format!("{:?}", body.decision).to_lowercase()), + ], + }; + let decision = gate::evaluate(&st.gate, &cmd).await; + if !decision.permitted() { + return Err(denied(&decision)); + } + st.firewall.set(&body.domain, body.decision); + Ok(Json(json!({ "set": body.domain, "decision": body.decision })).into_response()) +} + +async fn ws_upgrade(State(st): State, ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(move |socket| ws_loop(socket, st.events.subscribe())) +} + +async fn ws_loop(mut socket: WebSocket, mut rx: broadcast::Receiver) { + loop { + tokio::select! { + ev = rx.recv() => match ev { + Ok(ev) => { + let txt = match serde_json::to_string(&ev) { + Ok(t) => t, + Err(_) => continue, + }; + if socket.send(Message::Text(txt)).await.is_err() { + break; // client gone + } + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }, + incoming = socket.recv() => match incoming { + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} // ignore client chatter; this is a push channel + Some(Err(_)) => break, + }, + } + } +} diff --git a/policy/bearbrowser-contract.yaml b/policy/bearbrowser-contract.yaml index 7b246a4..715b81c 100644 --- a/policy/bearbrowser-contract.yaml +++ b/policy/bearbrowser-contract.yaml @@ -368,6 +368,57 @@ spec: reclassifyTo: prohibited as: unlock-door # --------------------------------------------------------------------------- + # captureActionContract — the SAME enforcing action→class model applied to the + # network-visibility sidecar (capture-sidecar): session packet capture, the + # live connection map, and the per-domain firewall. Read surfaces (status, map + # snapshot, firewall list) are allowed. Starting a whole-session packet capture + # and exporting the resulting pcap are the two EXFILTRATION vectors here, so + # they are prohibited by default — no agent or injected planner can silently + # record or export traffic — and reclassify to gated ONLY on an explicit, + # present-tense cockpit user gesture (actor=user AND userGesture=true). + # --------------------------------------------------------------------------- + captureActionContract: + policyRef: urn:srcos:policy:bearbrowser-capture + defaultDecision: deny + actionClasses: + allowed: + - action: capture-status + replayClass: exact + eventType: capture.capture-status + - action: list-connections + replayClass: exact + eventType: capture.list-connections + - action: list-firewall + replayClass: exact + eventType: capture.list-firewall + gated: + - action: capture-stop + replayClass: non-replayable-side-effect + eventType: capture.capture-stop + - action: firewall-set + replayClass: non-replayable-side-effect + eventType: capture.firewall-set + # eventType is the NATURAL action name; the bridge derives + # capture.policy.violation from the prohibited CLASS at decision time, so a + # gesture-permitted capture attests capture.capture-start (not the + # violation string) — same rule as the iot contract above. + prohibited: + - action: capture-start + eventType: capture.capture-start + - action: capture-export + eventType: capture.capture-export + policyConditions: + - id: capture-with-user-gesture + description: Starting a packet capture is permitted (as gated) only on an explicit cockpit user gesture. + appliesTo: capture-start + when: { "and": [ { "==": [ { "var": "actor" }, "user" ] }, { "==": [ { "var": "userGesture" }, true ] } ] } + reclassifyTo: gated + - id: export-with-user-gesture + description: Exporting a captured pcap is permitted (as gated) only on an explicit cockpit user gesture. + appliesTo: capture-export + when: { "and": [ { "==": [ { "var": "actor" }, "user" ] }, { "==": [ { "var": "userGesture" }, true ] } ] } + reclassifyTo: gated + # --------------------------------------------------------------------------- # agentMachineActionContract — the SAME enforcing action→class model applied to # the cockpit's local agent (the bundled Noetica agent-machine loopback sidecar, # 127.0.0.1). Lane 4 of docs/cockpit-composition-plan.md: the cockpit talks to diff --git a/scripts/agent-control-bridge.py b/scripts/agent-control-bridge.py index 7a9a2e1..d839396 100644 --- a/scripts/agent-control-bridge.py +++ b/scripts/agent-control-bridge.py @@ -712,6 +712,7 @@ def _policy_ref_safe(self: Policy) -> str: # noqa: D401 "browser": ("agentActionContract", "browser"), "iot": ("iotActionContract", "iot"), "agent-machine": ("agentMachineActionContract", "agent"), + "capture": ("captureActionContract", "capture"), } @@ -729,7 +730,7 @@ def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser( description="BearBrowser ENFORCING agent control bridge — blocks gated/prohibited " "(incl. injected) actions at decision time + attests every one.") - parser.add_argument("--surface", choices=("browser", "iot", "agent-machine"), + parser.add_argument("--surface", choices=tuple(_SURFACES.keys()), default="browser", help="Which action surface to govern: browser (default), " "iot (spec.iotActionContract), or agent-machine " @@ -760,7 +761,13 @@ def main(argv: Optional[list[str]] = None) -> int: for pair in args.param: if "=" in pair: k, _, v = pair.partition("=") - params[k.strip()] = v.strip() + # Coerce scalar types (true/false → bool, quoted → str) so jsonlogic + # policyConditions comparing to a boolean literal work through the CLI + # path — the Rust sidecar gates pass --param userGesture=true as a + # string, and without this a gesture-gated action could NEVER be + # permitted via the CLI (only via the in-process Python API the tests + # use). This makes the enforced path match the tested path. + params[k.strip()] = _parse_flow(v.strip()) if args.url: params.setdefault("url", args.url) diff --git a/scripts/bearbrowser-emit-event.py b/scripts/bearbrowser-emit-event.py index cfeb583..61555e7 100755 --- a/scripts/bearbrowser-emit-event.py +++ b/scripts/bearbrowser-emit-event.py @@ -43,6 +43,11 @@ "browser.upload.create", "browser.automation.run", "browser.diagnostics.export_redacted", + "capture.capture-start", + "capture.capture-stop", + "capture.capture-export", + "capture.firewall-set", + "capture.policy.violation", } SURFACES = { @@ -54,6 +59,7 @@ "credential-broker", "policy", "agent-sidecar", + "capture-sidecar", } PROFILES = {"human-secure", "agent-runtime", "bootstrap", "unknown"} diff --git a/scripts/build-capture-sidecar.sh b/scripts/build-capture-sidecar.sh new file mode 100755 index 0000000..3ae0577 --- /dev/null +++ b/scripts/build-capture-sidecar.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# build-capture-sidecar.sh — compile the network-visibility sidecar (packet +# capture + connection map + per-domain firewall) into a single release binary, +# staged into the .app alongside the agent-machine sidecar. +# +# Mirrors build-agent-machine-sidecar.sh: build once, self-smoke, stage. The +# smoke test proves the binary boots, refuses non-loopback, and serves /health — +# and that it REFUSES to run without the canonical policy bridge (the one-engine +# invariant), so a mis-staged app can never ship an ungoverned capture surface. +# +# Env: +# OUT=/path/to/Contents/Resources/sidecars # default: build/sidecars staging +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${BEARBROWSER_HOME:-$(cd "$SCRIPT_DIR/.." && pwd)}" +OUT="${OUT:-$REPO_ROOT/build/sidecars}" +CRATE="$REPO_ROOT/capture-sidecar" +BIN_NAME="bearbrowser-capture-sidecar-bin" + +log() { printf '[build-capture-sidecar] %s\n' "$*"; } + +command -v cargo >/dev/null 2>&1 || { log "ERROR: cargo (Rust) is required. Install: https://rustup.rs"; exit 1; } +[ -f "$CRATE/Cargo.toml" ] || { log "ERROR: crate not found at $CRATE"; exit 1; } + +log "cargo build --release ($CRATE)" +cargo build --release --manifest-path "$CRATE/Cargo.toml" + +BUILT="$CRATE/target/release/capture-sidecar" +[ -x "$BUILT" ] || { log "ERROR: build produced no binary at $BUILT"; exit 1; } + +mkdir -p "$OUT" +cp "$BUILT" "$OUT/$BIN_NAME" +log "staged → $OUT/$BIN_NAME" + +# ── Self-smoke ─────────────────────────────────────────────────────────────── +# 1. Refuses to run without the bridge (one-engine invariant). +log "smoke 1/3: refuses to start ungoverned (no bridge)" +tmp_nogate="$(mktemp -d)" +if "$OUT/$BIN_NAME" --repo-root "$tmp_nogate" --port 0 >/dev/null 2>&1; then + log "ERROR: sidecar started WITHOUT a policy bridge — governance invariant broken" + rm -rf "$tmp_nogate"; exit 1 +fi +rm -rf "$tmp_nogate" +log " ok — refused (no bridge under repo-root)" + +# 2 + 3. Boots against the real repo, serves /health, refuses non-loopback. +PORT="${SMOKE_PORT:-8096}" +STATE="$(mktemp -d)" +log "smoke 2/3: boot + /health on 127.0.0.1:$PORT" +BEARBROWSER_REPO_ROOT="$REPO_ROOT" CAPTURE_SIDECAR_STATE="$STATE" \ + "$OUT/$BIN_NAME" --repo-root "$REPO_ROOT" --port "$PORT" >/tmp/capture-smoke.log 2>&1 & +pid=$! +trap 'kill "$pid" 2>/dev/null; rm -rf "$STATE"' EXIT + +ok="" +for _ in $(seq 1 30); do + if [ "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/health" 2>/dev/null)" = "200" ]; then + ok=1; break + fi + sleep 0.3 +done +[ -n "$ok" ] || { log "ERROR: /health never returned 200"; cat /tmp/capture-smoke.log; exit 1; } +log " ok — /health 200" + +log "smoke 3/3: agent capture-start is gate-denied (403)" +code="$(curl -s -o /dev/null -w '%{http_code}' -XPOST "http://127.0.0.1:$PORT/capture/start" \ + -H 'content-type: application/json' -d '{"actor":"agent","userGesture":false}' 2>/dev/null)" +[ "$code" = "403" ] || { log "ERROR: agent capture-start returned $code, expected 403 (gate not enforcing)"; exit 1; } +log " ok — agent capture blocked (403)" + +log "DONE — $OUT/$BIN_NAME built + smoke-verified" diff --git a/scripts/tests/test_capture_injection_containment.py b/scripts/tests/test_capture_injection_containment.py new file mode 100644 index 0000000..781d413 --- /dev/null +++ b/scripts/tests/test_capture_injection_containment.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Capture-surface injection-containment proof — the network-visibility twin of +test_iot_injection_containment.py. + +The capture-sidecar exposes session packet capture, a live connection map, and a +per-domain firewall. The SAME enforcing bridge (agent-control-bridge.py) governs +it, via spec.captureActionContract (surface="capture"). Packet capture and pcap +export are the two EXFILTRATION vectors on this surface. This test proves, +WITHOUT any live capture engine, that: + + * read-only surfaces (status, connection list, firewall list) are permitted and + attest capture. events; + * stopping a capture and setting a firewall rule are gated (denied without a + per-action token, permitted with one); + * starting a whole-session packet capture and EXPORTING the pcap are PROHIBITED + — an agent/injected request is BLOCKED at decision time and attests a + capture.policy.violation, never merely logged after; + * capture/export are reclassified to gated ONLY on an explicit cockpit user + gesture (actor==user AND userGesture==true) — an agent cannot forge it; + * a permitted (post-gesture) capture attests capture.capture-start, NOT a + violation; + * an agent that FORGES userGesture but is not the user stays prohibited. + +Run: python3 scripts/tests/test_capture_injection_containment.py (exit 0 = PASS) +""" +import importlib.util +import pathlib +import sys + +_ROOT = pathlib.Path(__file__).resolve().parents[2] +_spec = importlib.util.spec_from_file_location( + "acb", _ROOT / "scripts" / "agent-control-bridge.py") +acb = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(acb) + + +def _eval(policy, action, params=None, token=None): + bridge = acb.ControlBridge(policy, emit=False) + return bridge.evaluate_action(action, params or {}, token) + + +# (name, action, params, token, expect_decision, expect_class, expect_event) +CASES = [ + # Read-only surfaces are always allowed. + ("allowed: status", "capture-status", {}, None, "permit", "allowed", "capture.capture-status"), + ("allowed: list-connections", "list-connections", {}, None, "permit", "allowed", "capture.list-connections"), + ("allowed: list-firewall", "list-firewall", {}, None, "permit", "allowed", "capture.list-firewall"), + # Reversible effects are gated: denied without a token, permitted with one. + ("gated: capture-stop no tok", "capture-stop", {}, None, "deny", "gated", "capture.capture-stop"), + ("gated: capture-stop w/ tok", "capture-stop", {}, "action:capture-stop", "permit", "gated", "capture.capture-stop"), + ("gated: firewall-set no tok", "firewall-set", {"domain": "ads.example", "decision": "block"}, None, "deny", "gated", "capture.firewall-set"), + ("gated: firewall-set w/ tok", "firewall-set", {"domain": "ads.example", "decision": "block"}, "action:firewall-set", "permit", "gated", "capture.firewall-set"), + # THE CONTAINMENT BAR: an injected/agent capture or export is blocked outright. + ("INJECTED agent capture-start", "capture-start", {}, None, "deny", "prohibited", "capture.policy.violation"), + ("INJECTED agent capture-export", "capture-export", {}, None, "deny", "prohibited", "capture.policy.violation"), + ("INJECTED start w/ stolen tok", "capture-start", {}, "action:capture-start", "deny", "prohibited", "capture.policy.violation"), + # An agent that FORGES the gesture but is not the user stays prohibited. + ("forged gesture wrong actor", "capture-start", {"actor": "agent", "userGesture": True}, "action:capture-start", "deny", "prohibited", "capture.policy.violation"), + # userGesture without actor==user: prohibited. + ("gesture without user actor", "capture-start", {"userGesture": True}, "action:capture-start", "deny", "prohibited", "capture.policy.violation"), + # Only an explicit cockpit user gesture reclassifies to gated; then it still + # needs a per-action token. Post-gesture permit attests the natural event. + ("user-gesture start no tok", "capture-start", {"actor": "user", "userGesture": True}, None, "deny", "gated", "capture.capture-start"), + ("user-gesture start w/ tok", "capture-start", {"actor": "user", "userGesture": True}, "action:capture-start", "permit", "gated", "capture.capture-start"), + ("user-gesture export w/ tok", "capture-export", {"actor": "user", "userGesture": True}, "action:capture-export", "permit", "gated", "capture.capture-export"), + # Unknown action fails closed (prohibited). + ("unknown action fails closed", "sniff-all", {}, None, "deny", "prohibited", "capture.policy.violation"), +] + + +def main() -> int: + policy = acb.load_policy("capture") + failures = [] + for name, action, params, token, exp_dec, exp_cls, exp_ev in CASES: + d = _eval(policy, action, params, token) + got_ev = d.event["eventType"] + ok = (d.decision == exp_dec and d.action_class == exp_cls and got_ev == exp_ev) + # No capture event may ever carry another surface's prefix (isolation). + if got_ev.startswith("browser.") or got_ev.startswith("iot.") or got_ev.startswith("agent."): + ok = False + mark = "PASS" if ok else "FAIL" + if not ok: + failures.append( + f"{name}: got ({d.decision},{d.action_class},{got_ev}) " + f"expected ({exp_dec},{exp_cls},{exp_ev})") + print(f" [{mark}] {name:34s} -> {d.decision:6s} {d.action_class:10s} {got_ev}") + + print("\n" + "=" * 70) + total = len(CASES) + print(f"PASSED {total - len(failures)} FAILED {len(failures)}") + if failures: + print("\nFAILURES:") + for f in failures: + print(" " + f) + print("\nRESULT: FAIL") + return 1 + print("\nRESULT: PASS — injected/agent packet capture + pcap export BLOCKED at " + "decision time + attested.\nContainment proven without a live capture engine " + "(enforce-only mode).") + return 0 + + +if __name__ == "__main__": + sys.exit(main())