commit 0867e401bc6ca48bfc0854e581c9b11cdabfe6c7 Author: Sharang Parnerkar Date: Mon Mar 2 13:30:17 2026 +0100 Initial commit: Compliance Scanner Agent Autonomous security and compliance scanning agent for git repositories. Features: SAST (Semgrep), SBOM (Syft), CVE monitoring (OSV.dev/NVD), GDPR/OAuth pattern detection, LLM triage, issue creation (GitHub/GitLab/Jira), PR reviews, and Dioxus fullstack dashboard. Co-Authored-By: Claude Opus 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6902ae0 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# MongoDB +MONGODB_URI=mongodb://root:example@localhost:27017/compliance_scanner?authSource=admin +MONGODB_DATABASE=compliance_scanner + +# LiteLLM +LITELLM_URL=http://localhost:4000 +LITELLM_API_KEY= +LITELLM_MODEL=gpt-4o + +# GitHub +GITHUB_TOKEN= +GITHUB_WEBHOOK_SECRET= + +# GitLab +GITLAB_URL=https://gitlab.com +GITLAB_TOKEN= +GITLAB_WEBHOOK_SECRET= + +# Jira +JIRA_URL=https://your-org.atlassian.net +JIRA_EMAIL= +JIRA_API_TOKEN= +JIRA_PROJECT_KEY= + +# SearXNG +SEARXNG_URL=http://localhost:8888 + +# NVD +NVD_API_KEY= + +# Agent +AGENT_PORT=3001 +SCAN_SCHEDULE=0 0 */6 * * * +CVE_MONITOR_SCHEDULE=0 0 0 * * * +GIT_CLONE_BASE_PATH=/tmp/compliance-scanner/repos + +# Dashboard +DASHBOARD_PORT=8080 +AGENT_API_URL=http://localhost:3001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b92202 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +.env +*.swp +*.swo +*~ +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..618502d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5308 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "askama_escape" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df27b8d5ddb458c5fb1bbc1ce172d4a38c614a97d550b0ac89003897fb01de4" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-tungstenite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tungstenite 0.27.0", +] + +[[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 = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.28.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[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 = "bson" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" +dependencies = [ + "ahash", + "base64", + "bitvec", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hex", + "indexmap", + "js-sys", + "once_cell", + "rand", + "serde", + "serde_bytes", + "serde_json", + "time", + "uuid", +] + +[[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" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64", + "encoding_rs", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compliance-agent" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "chrono", + "compliance-core", + "dotenvy", + "futures-util", + "git2", + "hex", + "hmac", + "mongodb", + "octocrab", + "regex", + "reqwest", + "secrecy", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tokio-cron-scheduler", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", + "walkdir", +] + +[[package]] +name = "compliance-core" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "mongodb", + "secrecy", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "compliance-dashboard" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "compliance-core", + "dioxus", + "dioxus-cli-config 0.7.3", + "dioxus-free-icons", + "dioxus-fullstack", + "dioxus-logger 0.6.2", + "dotenvy", + "mongodb", + "reqwest", + "secrecy", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower-http", + "tracing", + "web-sys", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const-serialize" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" +dependencies = [ + "const-serialize-macro 0.7.2", +] + +[[package]] +name = "const-serialize" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize-macro 0.8.0-alpha.0", + "serde", +] + +[[package]] +name = "const-serialize-macro" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "const-serialize-macro" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "const-str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0664d2867b4a32697dfe655557f5c3b187e9b605b38612a748e5ec99811d160" + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "content_disposition" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc14a88e1463ddd193906285abe5c360c7e8564e05ccc5d501755f7fbc9ca9c" +dependencies = [ + "charset", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "croner" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843" +dependencies = [ + "chrono", +] + +[[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 = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[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 = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive-syn-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive-where" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dioxus" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +dependencies = [ + "dioxus-asset-resolver", + "dioxus-cli-config 0.7.3", + "dioxus-config-macro", + "dioxus-config-macros", + "dioxus-core", + "dioxus-core-macro", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack", + "dioxus-fullstack-macro", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-liveview", + "dioxus-logger 0.7.3", + "dioxus-router", + "dioxus-server", + "dioxus-signals", + "dioxus-ssr", + "dioxus-stores", + "dioxus-web", + "manganis", + "serde", + "subsecond", + "warnings", +] + +[[package]] +name = "dioxus-asset-resolver" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0161af1d3cfc8ff31503ff1b7ee0068c97771fc38d0cc6566e23483142ddf4f" +dependencies = [ + "dioxus-cli-config 0.7.3", + "http", + "infer", + "jni", + "js-sys", + "ndk", + "ndk-context", + "ndk-sys", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd16948f1ffdb068dd9a64812158073a4250e2af4e98ea31fdac0312e6bce86" + +[[package]] +name = "dioxus-cli-config" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "dioxus-config-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f040ec7c41aa5428283f56bb0670afba9631bfe3ffd885f4814807f12c8c9d91" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "dioxus-config-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" + +[[package]] +name = "dioxus-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" +dependencies = [ + "anyhow", + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash 2.1.1", + "rustversion", + "serde", + "slab", + "slotmap", + "subsecond", + "tracing", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" +dependencies = [ + "convert_case 0.8.0", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-core-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" + +[[package]] +name = "dioxus-devtools" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" +dependencies = [ + "dioxus-cli-config 0.7.3", + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "futures-channel", + "futures-util", + "serde", + "serde_json", + "subsecond", + "thiserror 2.0.18", + "tracing", + "tungstenite 0.27.0", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +dependencies = [ + "dioxus-core", + "serde", + "subsecond-types", +] + +[[package]] +name = "dioxus-document" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-free-icons" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d356e0f9edad0930bc1cc76744360c0ecca020cb943acaadf42cb774f28284" +dependencies = [ + "dioxus", +] + +[[package]] +name = "dioxus-fullstack" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db1f8b70338072ec408b48d09c96559cf071f87847465d8161294197504c498" +dependencies = [ + "anyhow", + "async-stream", + "async-tungstenite", + "axum", + "axum-core", + "axum-extra", + "base64", + "bytes", + "ciborium", + "const-str", + "const_format", + "content_disposition", + "derive_more", + "dioxus-asset-resolver", + "dioxus-cli-config 0.7.3", + "dioxus-core", + "dioxus-fullstack-core", + "dioxus-fullstack-macro", + "dioxus-hooks", + "dioxus-html", + "dioxus-signals", + "form_urlencoded", + "futures", + "futures-channel", + "futures-util", + "gloo-net", + "headers", + "http", + "http-body", + "http-body-util", + "inventory", + "js-sys", + "mime", + "pin-project", + "reqwest", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-tungstenite 0.27.0", + "tokio-util", + "tower", + "tower-http", + "tower-layer", + "tracing", + "tungstenite 0.27.0", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "dioxus-fullstack-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda8b152e85121243741b9d5f2a3d8cb3c47a7b2299e902f98b6a7719915b0a2" +dependencies = [ + "anyhow", + "axum-core", + "base64", + "ciborium", + "dioxus-core", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "http", + "inventory", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "dioxus-fullstack-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "255104d4a4f278f1a8482fa30536c91d22260c561c954b753e72987df8d65b2e" +dependencies = [ + "const_format", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn", + "xxhash-rust", +] + +[[package]] +name = "dioxus-history" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", +] + +[[package]] +name = "dioxus-html" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" +dependencies = [ + "async-trait", + "bytes", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "futures-util", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "serde", + "serde_json", + "serde_repr", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" +dependencies = [ + "dioxus-core", + "dioxus-core-types", + "dioxus-html", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-liveview" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f7a1cfe6f8e9f2e303607c8ae564d11932fd80714c8a8c97e3860d55538997" +dependencies = [ + "axum", + "dioxus-cli-config 0.7.3", + "dioxus-core", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "futures-channel", + "futures-util", + "generational-box", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "slab", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "dioxus-logger" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545961e752f6c8bf59c274951b3c8b18a106db6ad2f9e2035b29e1f2a3e899b1" +dependencies = [ + "console_error_panic_hook", + "dioxus-cli-config 0.6.3", + "tracing", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "dioxus-logger" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1eeab114cb009d9e6b85ea10639a18cfc54bb342f3b837770b004c4daeb89c2" +dependencies = [ + "dioxus-cli-config 0.7.3", + "tracing", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "dioxus-router" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d5b31f9e27231389bf5a117b7074d22d8c58358b484a2558e56fbab20e64ca4" +dependencies = [ + "dioxus-cli-config 0.7.3", + "dioxus-core", + "dioxus-core-macro", + "dioxus-fullstack-core", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-router-macro", + "dioxus-signals", + "percent-encoding", + "rustversion", + "tracing", + "url", +] + +[[package]] +name = "dioxus-router-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "838b9b441a95da62b39cae4defd240b5ebb0ec9f2daea1126099e00a838dc86f" +dependencies = [ + "base16", + "digest", + "proc-macro2", + "quote", + "sha2", + "slab", + "syn", +] + +[[package]] +name = "dioxus-rsx" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "dioxus-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adb2d4e0f0f3a157bda6af2d90f22bac40070e509a66e3ea58abf3b35f904c" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64", + "bytes", + "chrono", + "ciborium", + "dashmap", + "dioxus-cli-config 0.7.3", + "dioxus-core", + "dioxus-core-macro", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack-core", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-logger 0.7.3", + "dioxus-router", + "dioxus-signals", + "dioxus-ssr", + "enumset", + "futures", + "futures-channel", + "futures-util", + "generational-box", + "http", + "http-body-util", + "hyper", + "hyper-util", + "inventory", + "lru", + "parking_lot", + "pin-project", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "serde_qs", + "subsecond", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.27.0", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-futures", + "url", + "walkdir", +] + +[[package]] +name = "dioxus-signals" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.1", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-ssr" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44cf9294a21fcd1098e02ad7a3ba61b99be8310ad3395fecf8210387c83f26b9" +dependencies = [ + "askama_escape", + "dioxus-core", + "dioxus-core-types", + "rustc-hash 2.1.1", +] + +[[package]] +name = "dioxus-stores" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", +] + +[[package]] +name = "dioxus-stores-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-web" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b33fe739fed4e8143dac222a9153593f8e2451662ce8fc4c9d167a9d6ec0923" +dependencies = [ + "dioxus-cli-config 0.7.3", + "dioxus-core", + "dioxus-core-types", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack-core", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "gloo-timers", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "send_wrapper", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[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 = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[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 = "generational-box" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" +dependencies = [ + "parking_lot", + "tracing", +] + +[[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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[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 = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[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.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "system-configuration", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inventory" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags", + "serde", +] + +[[package]] +name = "lazy-js-bundle" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" + +[[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.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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 = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "macro_magic" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc33f9f0351468d26fbc53d9ce00a096c8522ecb42f19b50f34f2c422f76d21d" +dependencies = [ + "macro_magic_core", + "macro_magic_macros", + "quote", + "syn", +] + +[[package]] +name = "macro_magic_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1687dc887e42f352865a393acae7cf79d98fab6351cde1f58e9e057da89bf150" +dependencies = [ + "const-random", + "derive-syn-parse", + "macro_magic_core_macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "macro_magic_core_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "macro_magic_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" +dependencies = [ + "macro_magic_core", + "quote", + "syn", +] + +[[package]] +name = "manganis" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cce7d688848bf9d034168513b9a2ffbfe5f61df2ff14ae15e6cfc866efdd344" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "manganis-core", + "manganis-macro", +] + +[[package]] +name = "manganis-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ce917b978268fe8a7db49e216343ec7c8f471f7e686feb70940d67293f19d4" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "dioxus-cli-config 0.7.3", + "dioxus-core-types", + "serde", + "winnow", +] + +[[package]] +name = "manganis-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad513e990f7c0bca86aa68659a7a3dc4c705572ed4c22fd6af32ccf261334cc2" +dependencies = [ + "dunce", + "macro-string", + "manganis-core", + "proc-macro2", + "quote", + "syn", +] + +[[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.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mongocrypt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da0cd419a51a5fb44819e290fbdb0665a54f21dead8923446a799c7f4d26ad9" +dependencies = [ + "bson", + "mongocrypt-sys", + "once_cell", + "serde", +] + +[[package]] +name = "mongocrypt-sys" +version = "0.1.5+1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224484c5d09285a7b8cb0a0c117e847ebd14cb6e4470ecf68cdb89c503b0edb9" + +[[package]] +name = "mongodb" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803dd859e8afa084c255a8effd8000ff86f7c8076a50cd6d8c99e8f3496f75c2" +dependencies = [ + "base64", + "bitflags", + "bson", + "derive-where", + "derive_more", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hickory-proto", + "hickory-resolver", + "hmac", + "macro_magic", + "md-5", + "mongocrypt", + "mongodb-internal-macros", + "pbkdf2", + "percent-encoding", + "rand", + "rustc_version_runtime", + "rustls", + "rustversion", + "serde", + "serde_bytes", + "serde_with", + "sha1", + "sha2", + "socket2 0.6.2", + "stringprep", + "strsim", + "take_mut", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tokio-util", + "typed-builder", + "uuid", + "webpki-roots", +] + +[[package]] +name = "mongodb-internal-macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973ef3dd3dbc6f6e65bbdecfd9ec5e781b9e7493b0f369a7c62e35d8e5ae2c8" +dependencies = [ + "macro_magic", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-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 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "octocrab" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86996964f8b721067b6ed238aa0ccee56ecad6ee5e714468aa567992d05d2b91" +dependencies = [ + "arc-swap", + "async-trait", + "base64", + "bytes", + "cfg-if", + "chrono", + "either", + "futures", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonwebtoken", + "once_cell", + "percent-encoding", + "pin-project", + "secrecy", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "snafu", + "tokio", + "tower", + "tower-http", + "tracing", + "url", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +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 = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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 = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[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" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[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 = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[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 = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "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_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "serde_core", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +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 = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subsecond" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +dependencies = [ + "js-sys", + "libc", + "libloading", + "memfd", + "memmap2", + "serde", + "subsecond-types", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "subsecond-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" +dependencies = [ + "serde", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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" +dependencies = [ + "futures-core", +] + +[[package]] +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 = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[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 = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-cron-scheduler" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5597b569b4712cf78aa0c9ae29742461b7bda1e49c2a5fdad1d79bf022f8f0" +dependencies = [ + "chrono", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.27.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + +[[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-io", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[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", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "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", +] + +[[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-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[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.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typed-builder" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +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", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[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 = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "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 = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[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 = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..10cb989 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[workspace] +members = ["compliance-core", "compliance-agent", "compliance-dashboard"] +resolver = "2" + +[workspace.lints.clippy] +unwrap_used = "deny" +expect_used = "deny" + +[workspace.dependencies] +compliance-core = { path = "compliance-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +chrono = { version = "0.4", features = ["serde"] } +mongodb = { version = "3", features = ["rustls-tls", "compat-3-0-0"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +thiserror = "2" +sha2 = "0.10" +hex = "0.4" +uuid = { version = "1", features = ["v4", "serde"] } +secrecy = { version = "0.10", features = ["serde"] } +regex = "1" diff --git a/Dioxus.toml b/Dioxus.toml new file mode 100644 index 0000000..dc7b586 --- /dev/null +++ b/Dioxus.toml @@ -0,0 +1,11 @@ +[application] +name = "compliance-dashboard" +default_platform = "web" +asset_dir = "assets" + +[web.app] +title = "Compliance Scanner Dashboard" + +[web.watcher] +reload_html = true +watch_path = ["compliance-dashboard/src", "assets"] diff --git a/Dockerfile.agent b/Dockerfile.agent new file mode 100644 index 0000000..d8f54b6 --- /dev/null +++ b/Dockerfile.agent @@ -0,0 +1,14 @@ +FROM rust:1.89-bookworm AS builder + +WORKDIR /app +COPY . . +RUN cargo build --release -p compliance-agent + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates libssl3 git && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent + +EXPOSE 3001 3002 + +ENTRYPOINT ["compliance-agent"] diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard new file mode 100644 index 0000000..252d952 --- /dev/null +++ b/Dockerfile.dashboard @@ -0,0 +1,18 @@ +FROM rust:1.89-bookworm AS builder + +RUN cargo install dioxus-cli --version 0.7.3 + +WORKDIR /app +COPY . . +RUN dx build --release --features server --platform web + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/compliance-dashboard /usr/local/bin/compliance-dashboard +COPY --from=builder /app/target/dx/compliance-dashboard/release/web/public /app/public + +EXPOSE 8080 + +WORKDIR /app +ENTRYPOINT ["compliance-dashboard"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..698d9f9 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +

+ Compliance Scanner Logo +

+ +

Compliance Scanner

+ +

+ Autonomous security and compliance scanning agent for git repositories +

+ +

+ Rust + Dioxus + MongoDB + Axum + Tailwind CSS +

+ +

+ GDPR + OAuth + SAST + CVE + Platform +

+ +--- + +## About + +Compliance Scanner is an autonomous agent that continuously monitors git repositories for security vulnerabilities, GDPR/OAuth compliance patterns, and dependency risks. It creates issues in external trackers (GitHub/GitLab/Jira) with evidence and remediation suggestions, reviews pull requests, and exposes a Dioxus-based dashboard for visualization. + +> **How it works:** The agent runs as a lazy daemon -- it only scans when new commits are detected, triggered by cron schedules or webhooks. LLM-powered triage filters out false positives and generates actionable remediation. + +## Features + +| Area | Capabilities | +|------|-------------| +| **SAST Scanning** | Semgrep-based static analysis with auto-config rules | +| **SBOM Generation** | Syft + cargo-audit for complete dependency inventory | +| **CVE Monitoring** | OSV.dev batch queries, NVD CVSS enrichment, SearXNG context | +| **GDPR Patterns** | Detect PII logging, missing consent, hardcoded retention, missing deletion | +| **OAuth Patterns** | Detect implicit grant, missing PKCE, token in localStorage, token in URLs | +| **LLM Triage** | Confidence scoring via LiteLLM to filter false positives | +| **Issue Creation** | Auto-create issues in GitHub, GitLab, or Jira with code evidence | +| **PR Reviews** | Post security review comments on pull requests | +| **Dashboard** | Fullstack Dioxus UI with findings, SBOM, issues, and statistics | +| **Webhooks** | GitHub (HMAC-SHA256) and GitLab webhook receivers for push/PR events | + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Cargo Workspace │ +├──────────────┬──────────────────┬───────────────────────────┤ +│ compliance- │ compliance- │ compliance- │ +│ core │ agent │ dashboard │ +│ (lib) │ (bin) │ (bin, Dioxus 0.7.3) │ +│ │ │ │ +│ Models │ Scan Pipeline │ Fullstack Web UI │ +│ Traits │ LLM Client │ Server Functions │ +│ Config │ Issue Trackers │ Charts + Tables │ +│ Errors │ Scheduler │ Settings Page │ +│ │ REST API │ │ +│ │ Webhooks │ │ +└──────────────┴──────────────────┴───────────────────────────┘ + │ + MongoDB (shared) +``` + +## Scan Pipeline (7 Stages) + +1. **Change Detection** -- `git2` fetch, compare HEAD SHA with last scanned commit +2. **Semgrep SAST** -- CLI wrapper with JSON output parsing +3. **SBOM Generation** -- Syft (CycloneDX) + cargo-audit vulnerability merge +4. **CVE Scanning** -- OSV.dev batch + NVD CVSS enrichment + SearXNG context +5. **Pattern Scanning** -- Regex-based GDPR and OAuth compliance checks +6. **LLM Triage** -- LiteLLM confidence scoring, filter findings < 3/10 +7. **Issue Creation** -- Dedup via SHA-256 fingerprint, create tracker issues + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Shared Library | `compliance-core` -- models, traits, config | +| Agent | Axum REST API, git2, tokio-cron-scheduler, Semgrep, Syft | +| Dashboard | Dioxus 0.7.3 fullstack, Tailwind CSS | +| Database | MongoDB with typed collections | +| LLM | LiteLLM (OpenAI-compatible API) | +| Issue Trackers | GitHub (octocrab), GitLab (REST v4), Jira (REST v3) | +| CVE Sources | OSV.dev, NVD, SearXNG | + +## Getting Started + +### Prerequisites + +- Rust 1.89+ +- [Dioxus CLI](https://dioxuslabs.com/learn/0.7/getting_started) (`dx`) +- MongoDB +- Docker & Docker Compose (optional) + +### Optional External Tools + +- [Semgrep](https://semgrep.dev/) -- for SAST scanning +- [Syft](https://github.com/anchore/syft) -- for SBOM generation +- [cargo-audit](https://github.com/rustsec/rustsec) -- for Rust dependency auditing + +### Setup + +```bash +# Clone the repository +git clone +cd compliance-scanner + +# Start MongoDB + SearXNG +docker compose up -d mongo searxng + +# Configure environment +cp .env.example .env +# Edit .env with your LiteLLM, tracker tokens, and MongoDB settings + +# Run the agent +cargo run -p compliance-agent + +# Run the dashboard (separate terminal) +dx serve --features server --platform web +``` + +### Docker Compose (Full Stack) + +```bash +docker compose up -d +``` + +This starts MongoDB, SearXNG, the agent (port 3001), and the dashboard (port 8080). + +## REST API + +The agent exposes a REST API on port 3001: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/health` | Health check | +| `GET` | `/api/v1/stats/overview` | Summary statistics and trends | +| `GET` | `/api/v1/repositories` | List tracked repositories | +| `POST` | `/api/v1/repositories` | Add a repository to track | +| `POST` | `/api/v1/repositories/:id/scan` | Trigger a manual scan | +| `GET` | `/api/v1/findings` | List findings (filterable) | +| `GET` | `/api/v1/findings/:id` | Get finding with code evidence | +| `PATCH` | `/api/v1/findings/:id/status` | Update finding status | +| `GET` | `/api/v1/sbom` | List dependencies | +| `GET` | `/api/v1/issues` | List cross-tracker issues | +| `GET` | `/api/v1/scan-runs` | Scan execution history | +| `POST` | `/webhook/github` | GitHub webhook (HMAC-SHA256) | +| `POST` | `/webhook/gitlab` | GitLab webhook (token verify) | + +## Dashboard Pages + +| Page | Description | +|------|-------------| +| **Overview** | Stat cards, severity distribution chart | +| **Repositories** | Add/manage tracked repos, trigger scans | +| **Findings** | Filterable table by severity, type, status | +| **Finding Detail** | Code evidence, remediation, suggested fix, linked issue | +| **SBOM** | Dependency inventory with vulnerability badges | +| **Issues** | Cross-tracker view (GitHub + GitLab + Jira) | +| **Settings** | Configure LiteLLM, tracker tokens, SearXNG URL | + +## Project Structure + +``` +compliance-scanner/ +├── compliance-core/ Shared library (models, traits, config, errors) +├── compliance-agent/ Agent daemon (pipeline, LLM, trackers, API, webhooks) +│ └── src/ +│ ├── pipeline/ 7-stage scan pipeline +│ ├── llm/ LiteLLM client, triage, descriptions, fixes, PR review +│ ├── trackers/ GitHub, GitLab, Jira integrations +│ ├── api/ REST API (Axum) +│ └── webhooks/ GitHub + GitLab webhook receivers +├── compliance-dashboard/ Dioxus fullstack dashboard +│ └── src/ +│ ├── components/ Reusable UI components +│ ├── infrastructure/ Server functions, DB, config +│ └── pages/ Full page views +├── assets/ Static assets (CSS, icons) +├── styles/ Tailwind input stylesheet +└── bin/ Dashboard binary entrypoint +``` + +## External Services + +| Service | Purpose | Default URL | +|---------|---------|-------------| +| MongoDB | Persistence | `mongodb://localhost:27017` | +| LiteLLM | LLM proxy for triage and generation | `http://localhost:4000` | +| SearXNG | CVE context search | `http://localhost:8888` | +| Semgrep | SAST scanning | CLI tool | +| Syft | SBOM generation | CLI tool | + +--- + +

+ Built with Rust, Dioxus, and a commitment to automated security compliance. +

diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..0ba70b3 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/main.css b/assets/main.css new file mode 100644 index 0000000..4bbdd35 --- /dev/null +++ b/assets/main.css @@ -0,0 +1,315 @@ +:root { + --sidebar-width: 260px; + --header-height: 56px; + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-card: #1e293b; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --accent: #38bdf8; + --accent-hover: #7dd3fc; + --border: #334155; + --danger: #ef4444; + --warning: #f59e0b; + --success: #22c55e; + --info: #3b82f6; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; +} + +.app-shell { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 40; + overflow-y: auto; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 12px; +} + +.sidebar-header h1 { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.sidebar-nav { + padding: 12px 8px; + flex: 1; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + color: var(--text-secondary); + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: all 0.15s; + cursor: pointer; +} + +.nav-item:hover { + background: rgba(56, 189, 248, 0.1); + color: var(--text-primary); +} + +.nav-item.active { + background: rgba(56, 189, 248, 0.15); + color: var(--accent); +} + +.main-content { + margin-left: var(--sidebar-width); + flex: 1; + padding: 24px 32px; + min-height: 100vh; +} + +.page-header { + margin-bottom: 24px; +} + +.page-header h2 { + font-size: 24px; + font-weight: 700; +} + +.page-header p { + color: var(--text-secondary); + margin-top: 4px; +} + +.stat-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; +} + +.stat-card .label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.stat-card .value { + font-size: 28px; + font-weight: 700; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; +} + +.card-header { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.table-wrapper { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th { + text-align: left; + padding: 12px 16px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); + font-weight: 600; +} + +td { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 14px; +} + +tr:hover { + background: rgba(56, 189, 248, 0.05); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 9999px; + font-size: 12px; + font-weight: 600; +} + +.badge-critical { background: rgba(239, 68, 68, 0.2); color: #fca5a5; } +.badge-high { background: rgba(249, 115, 22, 0.2); color: #fdba74; } +.badge-medium { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } +.badge-low { background: rgba(34, 197, 94, 0.2); color: #86efac; } +.badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.15s; +} + +.btn-primary { + background: var(--accent); + color: #0f172a; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + color: var(--text-primary); + border-color: var(--text-secondary); +} + +.code-block { + background: #0d1117; + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 13px; + line-height: 1.6; + overflow-x: auto; + white-space: pre; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 16px; +} + +.filter-bar { + display: flex; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.filter-bar select, +.filter-bar input { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 12px; + color: var(--text-primary); + font-size: 14px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; + color: var(--text-secondary); +} + +.form-group input, +.form-group select { + width: 100%; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 14px; + color: var(--text-primary); + font-size: 14px; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--text-secondary); +} + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + transition: transform 0.3s; + } + .sidebar.open { + transform: translateX(0); + } + .main-content { + margin-left: 0; + padding: 16px; + } +} diff --git a/assets/tailwind.css b/assets/tailwind.css new file mode 100644 index 0000000..314f56a --- /dev/null +++ b/assets/tailwind.css @@ -0,0 +1 @@ +/* Placeholder - generated by build.rs via bunx @tailwindcss/cli */ diff --git a/bin/main.rs b/bin/main.rs new file mode 100644 index 0000000..65fdbcd --- /dev/null +++ b/bin/main.rs @@ -0,0 +1,23 @@ +#![allow(non_snake_case)] + +#[allow(clippy::expect_used)] +fn main() { + dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger"); + + #[cfg(feature = "web")] + { + dioxus::web::launch::launch_cfg( + compliance_dashboard::App, + dioxus::web::Config::new().hydrate(true), + ); + } + + #[cfg(feature = "server")] + { + compliance_dashboard::infrastructure::server_start(compliance_dashboard::App) + .map_err(|e| { + tracing::error!("Unable to start server: {e}"); + }) + .expect("Server start failed") + } +} diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..0bbd2fc --- /dev/null +++ b/build.rs @@ -0,0 +1,24 @@ +fn main() -> Result<(), Box> { + use std::process::Command; + println!("cargo:rerun-if-changed=./styles/input.css"); + + match Command::new("bunx") + .args([ + "@tailwindcss/cli", + "-i", + "./styles/input.css", + "-o", + "./assets/tailwind.css", + ]) + .status() + { + Ok(status) if !status.success() => { + println!("cargo:warning=tailwind build exited with {status}, skipping CSS generation"); + } + Err(e) => { + println!("cargo:warning=bunx not found ({e}), skipping tailwind CSS generation"); + } + Ok(_) => {} + } + Ok(()) +} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..cda8d17 --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +avoid-breaking-exported-api = false diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml new file mode 100644 index 0000000..aabd3ea --- /dev/null +++ b/compliance-agent/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "compliance-agent" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +compliance-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +chrono = { workspace = true } +mongodb = { workspace = true } +reqwest = { workspace = true } +thiserror = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +uuid = { workspace = true } +secrecy = { workspace = true } +regex = { workspace = true } +axum = "0.8" +tower-http = { version = "0.6", features = ["cors", "trace"] } +git2 = "0.20" +octocrab = "0.44" +tokio-cron-scheduler = "0.13" +dotenvy = "0.15" +hmac = "0.12" +walkdir = "2" +base64 = "0.22" +urlencoding = "2" +futures-util = "0.3" diff --git a/compliance-agent/src/agent.rs b/compliance-agent/src/agent.rs new file mode 100644 index 0000000..1577acb --- /dev/null +++ b/compliance-agent/src/agent.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use compliance_core::AgentConfig; + +use crate::database::Database; +use crate::llm::LlmClient; +use crate::pipeline::orchestrator::PipelineOrchestrator; + +#[derive(Clone)] +pub struct ComplianceAgent { + pub config: AgentConfig, + pub db: Database, + pub llm: Arc, + pub http: reqwest::Client, +} + +impl ComplianceAgent { + pub fn new(config: AgentConfig, db: Database) -> Self { + let llm = Arc::new(LlmClient::new( + config.litellm_url.clone(), + config.litellm_api_key.clone(), + config.litellm_model.clone(), + )); + Self { + config, + db, + llm, + http: reqwest::Client::new(), + } + } + + pub async fn run_scan( + &self, + repo_id: &str, + trigger: compliance_core::models::ScanTrigger, + ) -> Result<(), crate::error::AgentError> { + let orchestrator = PipelineOrchestrator::new( + self.config.clone(), + self.db.clone(), + self.llm.clone(), + self.http.clone(), + ); + orchestrator.run(repo_id, trigger).await + } +} diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs new file mode 100644 index 0000000..43e39fc --- /dev/null +++ b/compliance-agent/src/api/handlers/mod.rs @@ -0,0 +1,334 @@ +use std::sync::Arc; + +#[allow(unused_imports)] +use axum::extract::{Extension, Path, Query}; +use axum::http::StatusCode; +use axum::Json; +use mongodb::bson::doc; +use serde::{Deserialize, Serialize}; + +use compliance_core::models::*; + +use crate::agent::ComplianceAgent; + +#[derive(Deserialize)] +pub struct PaginationParams { + #[serde(default = "default_page")] + pub page: u64, + #[serde(default = "default_limit")] + pub limit: i64, +} + +fn default_page() -> u64 { 1 } +fn default_limit() -> i64 { 50 } + +#[derive(Deserialize)] +pub struct FindingsFilter { + #[serde(default)] + pub repo_id: Option, + #[serde(default)] + pub severity: Option, + #[serde(default)] + pub scan_type: Option, + #[serde(default)] + pub status: Option, + #[serde(default = "default_page")] + pub page: u64, + #[serde(default = "default_limit")] + pub limit: i64, +} + +#[derive(Serialize)] +pub struct ApiResponse { + pub data: T, + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, +} + +#[derive(Serialize)] +pub struct OverviewStats { + pub total_repositories: u64, + pub total_findings: u64, + pub critical_findings: u64, + pub high_findings: u64, + pub medium_findings: u64, + pub low_findings: u64, + pub total_sbom_entries: u64, + pub total_cve_alerts: u64, + pub total_issues: u64, + pub recent_scans: Vec, +} + +#[derive(Deserialize)] +pub struct AddRepositoryRequest { + pub name: String, + pub git_url: String, + #[serde(default = "default_branch")] + pub default_branch: String, + pub tracker_type: Option, + pub tracker_owner: Option, + pub tracker_repo: Option, + pub scan_schedule: Option, +} + +fn default_branch() -> String { "main".to_string() } + +#[derive(Deserialize)] +pub struct UpdateStatusRequest { + pub status: String, +} + +type AgentExt = Extension>; +type ApiResult = Result>, StatusCode>; + +pub async fn health() -> Json { + Json(serde_json::json!({ "status": "ok" })) +} + +pub async fn stats_overview(Extension(agent): AgentExt) -> ApiResult { + let db = &agent.db; + + let total_repositories = db.repositories().count_documents(doc! {}).await.unwrap_or(0); + let total_findings = db.findings().count_documents(doc! {}).await.unwrap_or(0); + let critical_findings = db.findings().count_documents(doc! { "severity": "critical" }).await.unwrap_or(0); + let high_findings = db.findings().count_documents(doc! { "severity": "high" }).await.unwrap_or(0); + let medium_findings = db.findings().count_documents(doc! { "severity": "medium" }).await.unwrap_or(0); + let low_findings = db.findings().count_documents(doc! { "severity": "low" }).await.unwrap_or(0); + let total_sbom_entries = db.sbom_entries().count_documents(doc! {}).await.unwrap_or(0); + let total_cve_alerts = db.cve_alerts().count_documents(doc! {}).await.unwrap_or(0); + let total_issues = db.tracker_issues().count_documents(doc! {}).await.unwrap_or(0); + + let recent_scans: Vec = match db + .scan_runs() + .find(doc! {}) + .sort(doc! { "started_at": -1 }) + .limit(10) + .await + { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + Ok(Json(ApiResponse { + data: OverviewStats { + total_repositories, + total_findings, + critical_findings, + high_findings, + medium_findings, + low_findings, + total_sbom_entries, + total_cve_alerts, + total_issues, + recent_scans, + }, + total: None, + page: None, + })) +} + +pub async fn list_repositories( + Extension(agent): AgentExt, + Query(params): Query, +) -> ApiResult> { + let db = &agent.db; + let skip = (params.page.saturating_sub(1)) * params.limit as u64; + let total = db.repositories().count_documents(doc! {}).await.unwrap_or(0); + + let repos = match db.repositories().find(doc! {}).skip(skip).limit(params.limit).await { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + Ok(Json(ApiResponse { + data: repos, + total: Some(total), + page: Some(params.page), + })) +} + +pub async fn add_repository( + Extension(agent): AgentExt, + Json(req): Json, +) -> Result>, StatusCode> { + let mut repo = TrackedRepository::new(req.name, req.git_url); + repo.default_branch = req.default_branch; + repo.tracker_type = req.tracker_type; + repo.tracker_owner = req.tracker_owner; + repo.tracker_repo = req.tracker_repo; + repo.scan_schedule = req.scan_schedule; + + agent + .db + .repositories() + .insert_one(&repo) + .await + .map_err(|_| StatusCode::CONFLICT)?; + + Ok(Json(ApiResponse { + data: repo, + total: None, + page: None, + })) +} + +pub async fn trigger_scan( + Extension(agent): AgentExt, + Path(id): Path, +) -> Result, StatusCode> { + let agent_clone = (*agent).clone(); + tokio::spawn(async move { + if let Err(e) = agent_clone.run_scan(&id, ScanTrigger::Manual).await { + tracing::error!("Manual scan failed for {id}: {e}"); + } + }); + + Ok(Json(serde_json::json!({ "status": "scan_triggered" }))) +} + +pub async fn list_findings( + Extension(agent): AgentExt, + Query(filter): Query, +) -> ApiResult> { + let db = &agent.db; + let mut query = doc! {}; + if let Some(repo_id) = &filter.repo_id { + query.insert("repo_id", repo_id); + } + if let Some(severity) = &filter.severity { + query.insert("severity", severity); + } + if let Some(scan_type) = &filter.scan_type { + query.insert("scan_type", scan_type); + } + if let Some(status) = &filter.status { + query.insert("status", status); + } + + let skip = (filter.page.saturating_sub(1)) * filter.limit as u64; + let total = db.findings().count_documents(query.clone()).await.unwrap_or(0); + + let findings = match db.findings().find(query).sort(doc! { "created_at": -1 }).skip(skip).limit(filter.limit).await { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + Ok(Json(ApiResponse { + data: findings, + total: Some(total), + page: Some(filter.page), + })) +} + +pub async fn get_finding( + Extension(agent): AgentExt, + Path(id): Path, +) -> Result>, StatusCode> { + let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; + let finding = agent + .db + .findings() + .find_one(doc! { "_id": oid }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(ApiResponse { + data: finding, + total: None, + page: None, + })) +} + +pub async fn update_finding_status( + Extension(agent): AgentExt, + Path(id): Path, + Json(req): Json, +) -> Result, StatusCode> { + let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; + + agent + .db + .findings() + .update_one( + doc! { "_id": oid }, + doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } }, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(serde_json::json!({ "status": "updated" }))) +} + +pub async fn list_sbom( + Extension(agent): AgentExt, + Query(params): Query, +) -> ApiResult> { + let db = &agent.db; + let skip = (params.page.saturating_sub(1)) * params.limit as u64; + let total = db.sbom_entries().count_documents(doc! {}).await.unwrap_or(0); + + let entries = match db.sbom_entries().find(doc! {}).skip(skip).limit(params.limit).await { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + Ok(Json(ApiResponse { + data: entries, + total: Some(total), + page: Some(params.page), + })) +} + +pub async fn list_issues( + Extension(agent): AgentExt, + Query(params): Query, +) -> ApiResult> { + let db = &agent.db; + let skip = (params.page.saturating_sub(1)) * params.limit as u64; + let total = db.tracker_issues().count_documents(doc! {}).await.unwrap_or(0); + + let issues = match db.tracker_issues().find(doc! {}).sort(doc! { "created_at": -1 }).skip(skip).limit(params.limit).await { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + Ok(Json(ApiResponse { + data: issues, + total: Some(total), + page: Some(params.page), + })) +} + +pub async fn list_scan_runs( + Extension(agent): AgentExt, + Query(params): Query, +) -> ApiResult> { + let db = &agent.db; + let skip = (params.page.saturating_sub(1)) * params.limit as u64; + let total = db.scan_runs().count_documents(doc! {}).await.unwrap_or(0); + + let scans = match db.scan_runs().find(doc! {}).sort(doc! { "started_at": -1 }).skip(skip).limit(params.limit).await { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + Ok(Json(ApiResponse { + data: scans, + total: Some(total), + page: Some(params.page), + })) +} + +async fn collect_cursor_async( + mut cursor: mongodb::Cursor, +) -> Vec { + use futures_util::StreamExt; + let mut items = Vec::new(); + while let Some(Ok(item)) = cursor.next().await { + items.push(item); + } + items +} diff --git a/compliance-agent/src/api/mod.rs b/compliance-agent/src/api/mod.rs new file mode 100644 index 0000000..43a142c --- /dev/null +++ b/compliance-agent/src/api/mod.rs @@ -0,0 +1,5 @@ +pub mod handlers; +pub mod routes; +pub mod server; + +pub use server::start_api_server; diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs new file mode 100644 index 0000000..b7a1190 --- /dev/null +++ b/compliance-agent/src/api/routes.rs @@ -0,0 +1,19 @@ +use axum::routing::{get, patch, post}; +use axum::Router; + +use crate::api::handlers; + +pub fn build_router() -> Router { + Router::new() + .route("/api/v1/health", get(handlers::health)) + .route("/api/v1/stats/overview", get(handlers::stats_overview)) + .route("/api/v1/repositories", get(handlers::list_repositories)) + .route("/api/v1/repositories", post(handlers::add_repository)) + .route("/api/v1/repositories/{id}/scan", post(handlers::trigger_scan)) + .route("/api/v1/findings", get(handlers::list_findings)) + .route("/api/v1/findings/{id}", get(handlers::get_finding)) + .route("/api/v1/findings/{id}/status", patch(handlers::update_finding_status)) + .route("/api/v1/sbom", get(handlers::list_sbom)) + .route("/api/v1/issues", get(handlers::list_issues)) + .route("/api/v1/scan-runs", get(handlers::list_scan_runs)) +} diff --git a/compliance-agent/src/api/server.rs b/compliance-agent/src/api/server.rs new file mode 100644 index 0000000..f6c1979 --- /dev/null +++ b/compliance-agent/src/api/server.rs @@ -0,0 +1,28 @@ +use std::sync::Arc; + +use axum::Extension; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +use crate::agent::ComplianceAgent; +use crate::api::routes; +use crate::error::AgentError; + +pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> { + let app = routes::build_router() + .layer(Extension(Arc::new(agent))) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); + + let addr = format!("0.0.0.0:{port}"); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|e| AgentError::Other(format!("Failed to bind to {addr}: {e}")))?; + + tracing::info!("REST API listening on {addr}"); + axum::serve(listener, app) + .await + .map_err(|e| AgentError::Other(format!("API server error: {e}")))?; + + Ok(()) +} diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs new file mode 100644 index 0000000..92b27a6 --- /dev/null +++ b/compliance-agent/src/config.rs @@ -0,0 +1,43 @@ +use compliance_core::AgentConfig; +use secrecy::SecretString; + +use crate::error::AgentError; + +fn env_var(key: &str) -> Result { + std::env::var(key).map_err(|_| AgentError::Config(format!("Missing env var: {key}"))) +} + +fn env_var_opt(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) +} + +fn env_secret_opt(key: &str) -> Option { + env_var_opt(key).map(SecretString::from) +} + +pub fn load_config() -> Result { + Ok(AgentConfig { + mongodb_uri: env_var("MONGODB_URI")?, + mongodb_database: env_var_opt("MONGODB_DATABASE").unwrap_or_else(|| "compliance_scanner".to_string()), + litellm_url: env_var_opt("LITELLM_URL").unwrap_or_else(|| "http://localhost:4000".to_string()), + litellm_api_key: SecretString::from(env_var_opt("LITELLM_API_KEY").unwrap_or_default()), + litellm_model: env_var_opt("LITELLM_MODEL").unwrap_or_else(|| "gpt-4o".to_string()), + github_token: env_secret_opt("GITHUB_TOKEN"), + github_webhook_secret: env_secret_opt("GITHUB_WEBHOOK_SECRET"), + gitlab_url: env_var_opt("GITLAB_URL"), + gitlab_token: env_secret_opt("GITLAB_TOKEN"), + gitlab_webhook_secret: env_secret_opt("GITLAB_WEBHOOK_SECRET"), + jira_url: env_var_opt("JIRA_URL"), + jira_email: env_var_opt("JIRA_EMAIL"), + jira_api_token: env_secret_opt("JIRA_API_TOKEN"), + jira_project_key: env_var_opt("JIRA_PROJECT_KEY"), + searxng_url: env_var_opt("SEARXNG_URL"), + nvd_api_key: env_secret_opt("NVD_API_KEY"), + agent_port: env_var_opt("AGENT_PORT") + .and_then(|p| p.parse().ok()) + .unwrap_or(3001), + scan_schedule: env_var_opt("SCAN_SCHEDULE").unwrap_or_else(|| "0 0 */6 * * *".to_string()), + cve_monitor_schedule: env_var_opt("CVE_MONITOR_SCHEDULE").unwrap_or_else(|| "0 0 0 * * *".to_string()), + git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH").unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()), + }) +} diff --git a/compliance-agent/src/database.rs b/compliance-agent/src/database.rs new file mode 100644 index 0000000..16b3408 --- /dev/null +++ b/compliance-agent/src/database.rs @@ -0,0 +1,122 @@ +use mongodb::bson::doc; +use mongodb::{Client, Collection, IndexModel}; +use mongodb::options::IndexOptions; + +use compliance_core::models::*; + +use crate::error::AgentError; + +#[derive(Clone, Debug)] +pub struct Database { + inner: mongodb::Database, +} + +impl Database { + pub async fn connect(uri: &str, db_name: &str) -> Result { + let client = Client::with_uri_str(uri).await?; + let db = client.database(db_name); + db.run_command(doc! { "ping": 1 }).await?; + tracing::info!("Connected to MongoDB database '{db_name}'"); + Ok(Self { inner: db }) + } + + pub async fn ensure_indexes(&self) -> Result<(), AgentError> { + // repositories: unique git_url + self.repositories() + .create_index( + IndexModel::builder() + .keys(doc! { "git_url": 1 }) + .options(IndexOptions::builder().unique(true).build()) + .build(), + ) + .await?; + + // findings: unique fingerprint + self.findings() + .create_index( + IndexModel::builder() + .keys(doc! { "fingerprint": 1 }) + .options(IndexOptions::builder().unique(true).build()) + .build(), + ) + .await?; + + // findings: repo_id + severity compound + self.findings() + .create_index( + IndexModel::builder() + .keys(doc! { "repo_id": 1, "severity": 1 }) + .build(), + ) + .await?; + + // scan_runs: repo_id + started_at descending + self.scan_runs() + .create_index( + IndexModel::builder() + .keys(doc! { "repo_id": 1, "started_at": -1 }) + .build(), + ) + .await?; + + // sbom_entries: compound + self.sbom_entries() + .create_index( + IndexModel::builder() + .keys(doc! { "repo_id": 1, "name": 1, "version": 1 }) + .build(), + ) + .await?; + + // cve_alerts: unique cve_id + repo_id + self.cve_alerts() + .create_index( + IndexModel::builder() + .keys(doc! { "cve_id": 1, "repo_id": 1 }) + .options(IndexOptions::builder().unique(true).build()) + .build(), + ) + .await?; + + // tracker_issues: unique finding_id + self.tracker_issues() + .create_index( + IndexModel::builder() + .keys(doc! { "finding_id": 1 }) + .options(IndexOptions::builder().unique(true).build()) + .build(), + ) + .await?; + + tracing::info!("Database indexes ensured"); + Ok(()) + } + + pub fn repositories(&self) -> Collection { + self.inner.collection("repositories") + } + + pub fn findings(&self) -> Collection { + self.inner.collection("findings") + } + + pub fn scan_runs(&self) -> Collection { + self.inner.collection("scan_runs") + } + + pub fn sbom_entries(&self) -> Collection { + self.inner.collection("sbom_entries") + } + + pub fn cve_alerts(&self) -> Collection { + self.inner.collection("cve_alerts") + } + + pub fn tracker_issues(&self) -> Collection { + self.inner.collection("tracker_issues") + } + + pub fn raw_collection(&self, name: &str) -> Collection { + self.inner.collection(name) + } +} diff --git a/compliance-agent/src/error.rs b/compliance-agent/src/error.rs new file mode 100644 index 0000000..cee4751 --- /dev/null +++ b/compliance-agent/src/error.rs @@ -0,0 +1,41 @@ +use compliance_core::CoreError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AgentError { + #[error(transparent)] + Core(#[from] CoreError), + + #[error("Database error: {0}")] + Database(#[from] mongodb::error::Error), + + #[error("Git error: {0}")] + Git(#[from] git2::Error), + + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Scheduler error: {0}")] + Scheduler(String), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("{0}")] + Other(String), +} + +impl From for axum::http::StatusCode { + fn from(err: AgentError) -> Self { + match err { + AgentError::Core(CoreError::NotFound(_)) => axum::http::StatusCode::NOT_FOUND, + _ => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + } + } +} diff --git a/compliance-agent/src/llm/client.rs b/compliance-agent/src/llm/client.rs new file mode 100644 index 0000000..ebd72fb --- /dev/null +++ b/compliance-agent/src/llm/client.rs @@ -0,0 +1,157 @@ +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; + +use crate::error::AgentError; + +#[derive(Clone)] +pub struct LlmClient { + base_url: String, + api_key: SecretString, + model: String, + http: reqwest::Client, +} + +#[derive(Serialize)] +struct ChatMessage { + role: String, + content: String, +} + +#[derive(Serialize)] +struct ChatCompletionRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, +} + +#[derive(Deserialize)] +struct ChatCompletionResponse { + choices: Vec, +} + +#[derive(Deserialize)] +struct ChatChoice { + message: ChatResponseMessage, +} + +#[derive(Deserialize)] +struct ChatResponseMessage { + content: String, +} + +impl LlmClient { + pub fn new(base_url: String, api_key: SecretString, model: String) -> Self { + Self { + base_url, + api_key, + model, + http: reqwest::Client::new(), + } + } + + pub async fn chat( + &self, + system_prompt: &str, + user_prompt: &str, + temperature: Option, + ) -> Result { + let url = format!("{}/v1/chat/completions", self.base_url.trim_end_matches('/')); + + let request_body = ChatCompletionRequest { + model: self.model.clone(), + messages: vec![ + ChatMessage { + role: "system".to_string(), + content: system_prompt.to_string(), + }, + ChatMessage { + role: "user".to_string(), + content: user_prompt.to_string(), + }, + ], + temperature, + max_tokens: Some(4096), + }; + + let mut req = self + .http + .post(&url) + .header("content-type", "application/json") + .json(&request_body); + + let key = self.api_key.expose_secret(); + if !key.is_empty() { + req = req.header("Authorization", format!("Bearer {key}")); + } + + let resp = req.send().await.map_err(|e| { + AgentError::Other(format!("LiteLLM request failed: {e}")) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AgentError::Other(format!("LiteLLM returned {status}: {body}"))); + } + + let body: ChatCompletionResponse = resp.json().await.map_err(|e| { + AgentError::Other(format!("Failed to parse LiteLLM response: {e}")) + })?; + + body.choices + .first() + .map(|c| c.message.content.clone()) + .ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string())) + } + + pub async fn chat_with_messages( + &self, + messages: Vec<(String, String)>, + temperature: Option, + ) -> Result { + let url = format!("{}/v1/chat/completions", self.base_url.trim_end_matches('/')); + + let request_body = ChatCompletionRequest { + model: self.model.clone(), + messages: messages + .into_iter() + .map(|(role, content)| ChatMessage { role, content }) + .collect(), + temperature, + max_tokens: Some(4096), + }; + + let mut req = self + .http + .post(&url) + .header("content-type", "application/json") + .json(&request_body); + + let key = self.api_key.expose_secret(); + if !key.is_empty() { + req = req.header("Authorization", format!("Bearer {key}")); + } + + let resp = req.send().await.map_err(|e| { + AgentError::Other(format!("LiteLLM request failed: {e}")) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AgentError::Other(format!("LiteLLM returned {status}: {body}"))); + } + + let body: ChatCompletionResponse = resp.json().await.map_err(|e| { + AgentError::Other(format!("Failed to parse LiteLLM response: {e}")) + })?; + + body.choices + .first() + .map(|c| c.message.content.clone()) + .ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string())) + } +} diff --git a/compliance-agent/src/llm/descriptions.rs b/compliance-agent/src/llm/descriptions.rs new file mode 100644 index 0000000..093493e --- /dev/null +++ b/compliance-agent/src/llm/descriptions.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use compliance_core::models::Finding; + +use crate::error::AgentError; +use crate::llm::LlmClient; + +const DESCRIPTION_SYSTEM_PROMPT: &str = r#"You are a security engineer writing issue descriptions for a bug tracker. Generate a clear, actionable issue body in Markdown format that includes: + +1. **Summary**: 1-2 sentence overview +2. **Evidence**: Code location, snippet, and what was detected +3. **Impact**: What could happen if not fixed +4. **Remediation**: Step-by-step fix instructions +5. **References**: Relevant CWE/CVE links if applicable + +Keep it concise and professional. Use code blocks for code snippets."#; + +pub async fn generate_issue_description( + llm: &Arc, + finding: &Finding, +) -> Result<(String, String), AgentError> { + let user_prompt = format!( + "Generate an issue title and body for this finding:\n\ + Scanner: {}\n\ + Type: {}\n\ + Severity: {}\n\ + Rule: {}\n\ + Title: {}\n\ + Description: {}\n\ + File: {}\n\ + Line: {}\n\ + Code:\n```\n{}\n```\n\ + CWE: {}\n\ + CVE: {}\n\ + Remediation hint: {}", + finding.scanner, + finding.scan_type, + finding.severity, + finding.rule_id.as_deref().unwrap_or("N/A"), + finding.title, + finding.description, + finding.file_path.as_deref().unwrap_or("N/A"), + finding.line_number.map(|n| n.to_string()).unwrap_or_else(|| "N/A".to_string()), + finding.code_snippet.as_deref().unwrap_or("N/A"), + finding.cwe.as_deref().unwrap_or("N/A"), + finding.cve.as_deref().unwrap_or("N/A"), + finding.remediation.as_deref().unwrap_or("N/A"), + ); + + let response = llm.chat(DESCRIPTION_SYSTEM_PROMPT, &user_prompt, Some(0.3)).await?; + + // Extract title from first line, rest is body + let mut lines = response.lines(); + let title = lines + .next() + .unwrap_or(&finding.title) + .trim_start_matches('#') + .trim() + .to_string(); + let body = lines.collect::>().join("\n").trim().to_string(); + + let body = if body.is_empty() { response } else { body }; + + Ok((title, body)) +} diff --git a/compliance-agent/src/llm/fixes.rs b/compliance-agent/src/llm/fixes.rs new file mode 100644 index 0000000..518d01a --- /dev/null +++ b/compliance-agent/src/llm/fixes.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use compliance_core::models::Finding; + +use crate::error::AgentError; +use crate::llm::LlmClient; + +const FIX_SYSTEM_PROMPT: &str = r#"You are a security engineer. Given a security finding with code context, suggest a concrete code fix. Return ONLY the fixed code snippet that can directly replace the vulnerable code. Include brief inline comments explaining the fix."#; + +pub async fn suggest_fix( + llm: &Arc, + finding: &Finding, +) -> Result { + let user_prompt = format!( + "Suggest a fix for this vulnerability:\n\ + Language context from file: {}\n\ + Rule: {}\n\ + Description: {}\n\ + Vulnerable code:\n```\n{}\n```", + finding.file_path.as_deref().unwrap_or("unknown"), + finding.rule_id.as_deref().unwrap_or("N/A"), + finding.description, + finding.code_snippet.as_deref().unwrap_or("N/A"), + ); + + llm.chat(FIX_SYSTEM_PROMPT, &user_prompt, Some(0.2)).await +} diff --git a/compliance-agent/src/llm/mod.rs b/compliance-agent/src/llm/mod.rs new file mode 100644 index 0000000..9259b95 --- /dev/null +++ b/compliance-agent/src/llm/mod.rs @@ -0,0 +1,10 @@ +pub mod client; +#[allow(dead_code)] +pub mod descriptions; +#[allow(dead_code)] +pub mod fixes; +#[allow(dead_code)] +pub mod pr_review; +pub mod triage; + +pub use client::LlmClient; diff --git a/compliance-agent/src/llm/pr_review.rs b/compliance-agent/src/llm/pr_review.rs new file mode 100644 index 0000000..a256bea --- /dev/null +++ b/compliance-agent/src/llm/pr_review.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use compliance_core::models::Finding; +use compliance_core::traits::issue_tracker::ReviewComment; + +use crate::error::AgentError; +use crate::llm::LlmClient; + +const PR_REVIEW_SYSTEM_PROMPT: &str = r#"You are a security-focused code reviewer. Given a list of security findings in a PR diff, generate concise review comments. Each comment should: +1. Briefly explain the issue +2. Suggest a specific fix +3. Reference the relevant security standard (CWE, OWASP) if applicable + +Be constructive and professional. Return JSON array: +[{"path": "file.rs", "line": 42, "body": "..."}]"#; + +pub async fn generate_pr_review( + llm: &Arc, + findings: &[Finding], +) -> Result<(String, Vec), AgentError> { + if findings.is_empty() { + return Ok(("No security issues found in this PR.".to_string(), Vec::new())); + } + + let findings_text: Vec = findings + .iter() + .map(|f| { + format!( + "- [{severity}] {title} in {file}:{line}\n Code: {code}\n Rule: {rule}", + severity = f.severity, + title = f.title, + file = f.file_path.as_deref().unwrap_or("unknown"), + line = f.line_number.map(|n| n.to_string()).unwrap_or_else(|| "?".to_string()), + code = f.code_snippet.as_deref().unwrap_or("N/A"), + rule = f.rule_id.as_deref().unwrap_or("N/A"), + ) + }) + .collect(); + + let user_prompt = format!( + "Generate review comments for these {} findings:\n{}", + findings.len(), + findings_text.join("\n"), + ); + + let response = llm.chat(PR_REVIEW_SYSTEM_PROMPT, &user_prompt, Some(0.3)).await?; + + // Parse comments from LLM response + let comments: Vec = serde_json::from_str::>(&response) + .unwrap_or_default() + .into_iter() + .map(|c| ReviewComment { + path: c.path, + line: c.line, + body: c.body, + }) + .collect(); + + let summary = format!( + "## Security Review\n\nFound **{}** potential security issue(s) in this PR.\n\n{}", + findings.len(), + findings + .iter() + .map(|f| format!("- **[{}]** {} in `{}`", f.severity, f.title, f.file_path.as_deref().unwrap_or("unknown"))) + .collect::>() + .join("\n"), + ); + + Ok((summary, comments)) +} + +#[derive(serde::Deserialize)] +struct PrComment { + path: String, + line: u32, + body: String, +} diff --git a/compliance-agent/src/llm/triage.rs b/compliance-agent/src/llm/triage.rs new file mode 100644 index 0000000..b7bdbb5 --- /dev/null +++ b/compliance-agent/src/llm/triage.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use compliance_core::models::{Finding, FindingStatus}; + +use crate::llm::LlmClient; + +const TRIAGE_SYSTEM_PROMPT: &str = r#"You are a security finding triage expert. Analyze the following security finding and determine: +1. Is this a true positive? (yes/no) +2. Confidence score (0-10, where 10 is highest confidence this is a real issue) +3. Brief remediation suggestion (1-2 sentences) + +Respond in JSON format: +{"true_positive": true/false, "confidence": N, "remediation": "..."}"#; + +pub async fn triage_findings(llm: &Arc, findings: &mut Vec) -> usize { + let mut passed = 0; + + for finding in findings.iter_mut() { + let user_prompt = format!( + "Scanner: {}\nRule: {}\nSeverity: {}\nTitle: {}\nDescription: {}\nFile: {}\nLine: {}\nCode: {}", + finding.scanner, + finding.rule_id.as_deref().unwrap_or("N/A"), + finding.severity, + finding.title, + finding.description, + finding.file_path.as_deref().unwrap_or("N/A"), + finding.line_number.map(|n| n.to_string()).unwrap_or_else(|| "N/A".to_string()), + finding.code_snippet.as_deref().unwrap_or("N/A"), + ); + + match llm.chat(TRIAGE_SYSTEM_PROMPT, &user_prompt, Some(0.1)).await { + Ok(response) => { + if let Ok(result) = serde_json::from_str::(&response) { + finding.confidence = Some(result.confidence); + if let Some(remediation) = result.remediation { + finding.remediation = Some(remediation); + } + + if result.confidence >= 3.0 { + finding.status = FindingStatus::Triaged; + passed += 1; + } else { + finding.status = FindingStatus::FalsePositive; + } + } else { + // If LLM response doesn't parse, keep the finding + finding.status = FindingStatus::Triaged; + passed += 1; + tracing::warn!("Failed to parse triage response for {}: {response}", finding.fingerprint); + } + } + Err(e) => { + // On LLM error, keep the finding + tracing::warn!("LLM triage failed for {}: {e}", finding.fingerprint); + finding.status = FindingStatus::Triaged; + passed += 1; + } + } + } + + // Remove false positives + findings.retain(|f| f.status != FindingStatus::FalsePositive); + passed +} + +#[derive(serde::Deserialize)] +struct TriageResult { + #[serde(default)] + true_positive: bool, + #[serde(default)] + confidence: f64, + remediation: Option, +} diff --git a/compliance-agent/src/main.rs b/compliance-agent/src/main.rs new file mode 100644 index 0000000..d4a5f87 --- /dev/null +++ b/compliance-agent/src/main.rs @@ -0,0 +1,53 @@ +use tracing_subscriber::EnvFilter; + +mod agent; +mod config; +mod database; +mod error; +mod api; +mod llm; +mod pipeline; +mod scheduler; +#[allow(dead_code)] +mod trackers; +mod webhooks; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .init(); + + dotenvy::dotenv().ok(); + + tracing::info!("Loading configuration..."); + let config = config::load_config()?; + + tracing::info!("Connecting to MongoDB..."); + let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?; + db.ensure_indexes().await?; + + let agent = agent::ComplianceAgent::new(config.clone(), db.clone()); + + tracing::info!("Starting scheduler..."); + let scheduler_agent = agent.clone(); + let scheduler_handle = tokio::spawn(async move { + if let Err(e) = scheduler::start_scheduler(&scheduler_agent).await { + tracing::error!("Scheduler error: {e}"); + } + }); + + tracing::info!("Starting webhook server..."); + let webhook_agent = agent.clone(); + let webhook_handle = tokio::spawn(async move { + if let Err(e) = webhooks::start_webhook_server(&webhook_agent).await { + tracing::error!("Webhook server error: {e}"); + } + }); + + tracing::info!("Starting REST API on port {}...", config.agent_port); + api::start_api_server(agent, config.agent_port).await?; + + let _ = tokio::join!(scheduler_handle, webhook_handle); + Ok(()) +} diff --git a/compliance-agent/src/pipeline/cve.rs b/compliance-agent/src/pipeline/cve.rs new file mode 100644 index 0000000..004ed7a --- /dev/null +++ b/compliance-agent/src/pipeline/cve.rs @@ -0,0 +1,199 @@ +use compliance_core::models::{CveAlert, CveSource, SbomEntry, VulnRef}; +use compliance_core::CoreError; + +pub struct CveScanner { + http: reqwest::Client, + searxng_url: Option, + nvd_api_key: Option, +} + +impl CveScanner { + pub fn new(http: reqwest::Client, searxng_url: Option, nvd_api_key: Option) -> Self { + Self { http, searxng_url, nvd_api_key } + } + + pub async fn scan_dependencies( + &self, + repo_id: &str, + entries: &mut [SbomEntry], + ) -> Result, CoreError> { + let mut alerts = Vec::new(); + + // Batch query OSV.dev + let osv_results = self.query_osv_batch(entries).await?; + for (idx, vulns) in osv_results.into_iter().enumerate() { + if let Some(entry) = entries.get_mut(idx) { + for vuln in &vulns { + entry.known_vulnerabilities.push(VulnRef { + id: vuln.id.clone(), + source: "osv".to_string(), + severity: vuln.severity.clone(), + url: Some(format!("https://osv.dev/vulnerability/{}", vuln.id)), + }); + + let mut alert = CveAlert::new( + vuln.id.clone(), + repo_id.to_string(), + entry.name.clone(), + entry.version.clone(), + CveSource::Osv, + ); + alert.summary = vuln.summary.clone(); + alerts.push(alert); + } + } + } + + // Enrich with NVD CVSS scores + for alert in &mut alerts { + if let Ok(Some(cvss)) = self.query_nvd(&alert.cve_id).await { + alert.cvss_score = Some(cvss); + } + } + + Ok(alerts) + } + + async fn query_osv_batch(&self, entries: &[SbomEntry]) -> Result>, CoreError> { + let queries: Vec<_> = entries + .iter() + .filter_map(|e| { + e.purl.as_ref().map(|purl| { + serde_json::json!({ + "package": { "purl": purl } + }) + }) + }) + .collect(); + + if queries.is_empty() { + return Ok(Vec::new()); + } + + let body = serde_json::json!({ "queries": queries }); + + let resp = self + .http + .post("https://api.osv.dev/v1/querybatch") + .json(&body) + .send() + .await + .map_err(|e| CoreError::Http(format!("OSV.dev request failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + tracing::warn!("OSV.dev returned {status}: {body}"); + return Ok(Vec::new()); + } + + let result: OsvBatchResponse = resp.json().await.map_err(|e| { + CoreError::Http(format!("Failed to parse OSV.dev response: {e}")) + })?; + + let vulns = result + .results + .into_iter() + .map(|r| { + r.vulns + .unwrap_or_default() + .into_iter() + .map(|v| OsvVuln { + id: v.id, + summary: v.summary, + severity: v.database_specific + .and_then(|d| d.get("severity").and_then(|s| s.as_str()).map(String::from)), + }) + .collect() + }) + .collect(); + + Ok(vulns) + } + + async fn query_nvd(&self, cve_id: &str) -> Result, CoreError> { + if !cve_id.starts_with("CVE-") { + return Ok(None); + } + + let url = format!("https://services.nvd.nist.gov/rest/json/cves/2.0?cveId={cve_id}"); + let mut req = self.http.get(&url); + + if let Some(key) = &self.nvd_api_key { + req = req.header("apiKey", key.as_str()); + } + + let resp = req.send().await.map_err(|e| { + CoreError::Http(format!("NVD request failed: {e}")) + })?; + + if !resp.status().is_success() { + return Ok(None); + } + + let body: serde_json::Value = resp.json().await.map_err(|e| { + CoreError::Http(format!("Failed to parse NVD response: {e}")) + })?; + + // Extract CVSS v3.1 base score + let score = body["vulnerabilities"] + .as_array() + .and_then(|v| v.first()) + .and_then(|v| v["cve"]["metrics"]["cvssMetricV31"].as_array()) + .and_then(|m| m.first()) + .and_then(|m| m["cvssData"]["baseScore"].as_f64()); + + Ok(score) + } + + pub async fn search_context(&self, cve_id: &str) -> Result, CoreError> { + let Some(searxng_url) = &self.searxng_url else { + return Ok(Vec::new()); + }; + + let url = format!("{}/search?q={cve_id}&format=json&engines=duckduckgo", searxng_url.trim_end_matches('/')); + let resp = self.http.get(&url).send().await.map_err(|e| { + CoreError::Http(format!("SearXNG request failed: {e}")) + })?; + + if !resp.status().is_success() { + return Ok(Vec::new()); + } + + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + let results = body["results"] + .as_array() + .map(|arr| { + arr.iter() + .take(5) + .filter_map(|r| r["url"].as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + Ok(results) + } +} + +#[derive(serde::Deserialize)] +struct OsvBatchResponse { + results: Vec, +} + +#[derive(serde::Deserialize)] +struct OsvBatchResult { + vulns: Option>, +} + +#[derive(serde::Deserialize)] +struct OsvVulnEntry { + id: String, + summary: Option, + database_specific: Option, +} + +struct OsvVuln { + id: String, + summary: Option, + severity: Option, +} diff --git a/compliance-agent/src/pipeline/dedup.rs b/compliance-agent/src/pipeline/dedup.rs new file mode 100644 index 0000000..d20544e --- /dev/null +++ b/compliance-agent/src/pipeline/dedup.rs @@ -0,0 +1,10 @@ +use sha2::{Digest, Sha256}; + +pub fn compute_fingerprint(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part.as_bytes()); + hasher.update(b"|"); + } + hex::encode(hasher.finalize()) +} diff --git a/compliance-agent/src/pipeline/git.rs b/compliance-agent/src/pipeline/git.rs new file mode 100644 index 0000000..d490558 --- /dev/null +++ b/compliance-agent/src/pipeline/git.rs @@ -0,0 +1,100 @@ +use std::path::{Path, PathBuf}; + +use git2::{FetchOptions, Repository}; + +use crate::error::AgentError; + +pub struct GitOps { + base_path: PathBuf, +} + +impl GitOps { + pub fn new(base_path: &str) -> Self { + Self { + base_path: PathBuf::from(base_path), + } + } + + pub fn clone_or_fetch(&self, git_url: &str, repo_name: &str) -> Result { + let repo_path = self.base_path.join(repo_name); + + if repo_path.exists() { + self.fetch(&repo_path)?; + } else { + std::fs::create_dir_all(&repo_path)?; + Repository::clone(git_url, &repo_path)?; + tracing::info!("Cloned {git_url} to {}", repo_path.display()); + } + + Ok(repo_path) + } + + fn fetch(&self, repo_path: &Path) -> Result<(), AgentError> { + let repo = Repository::open(repo_path)?; + let mut remote = repo.find_remote("origin")?; + let mut fetch_opts = FetchOptions::new(); + remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?; + + // Fast-forward to origin/HEAD + let fetch_head = repo.find_reference("FETCH_HEAD")?; + let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; + let head_ref = repo.head()?; + let head_name = head_ref.name().unwrap_or("HEAD"); + + repo.reference( + head_name, + fetch_commit.id(), + true, + "fast-forward", + )?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?; + + tracing::info!("Fetched and fast-forwarded {}", repo_path.display()); + Ok(()) + } + + pub fn get_head_sha(repo_path: &Path) -> Result { + let repo = Repository::open(repo_path)?; + let head = repo.head()?; + let commit = head.peel_to_commit()?; + Ok(commit.id().to_string()) + } + + pub fn has_new_commits(repo_path: &Path, last_sha: Option<&str>) -> Result { + let current_sha = Self::get_head_sha(repo_path)?; + match last_sha { + Some(sha) if sha == current_sha => Ok(false), + _ => Ok(true), + } + } + + pub fn get_changed_files( + repo_path: &Path, + old_sha: &str, + new_sha: &str, + ) -> Result, AgentError> { + let repo = Repository::open(repo_path)?; + let old_commit = repo.find_commit(git2::Oid::from_str(old_sha)?)?; + let new_commit = repo.find_commit(git2::Oid::from_str(new_sha)?)?; + + let old_tree = old_commit.tree()?; + let new_tree = new_commit.tree()?; + + let diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?; + + let mut files = Vec::new(); + diff.foreach( + &mut |delta, _| { + if let Some(path) = delta.new_file().path() { + files.push(path.to_string_lossy().to_string()); + } + true + }, + None, + None, + None, + )?; + + Ok(files) + } +} diff --git a/compliance-agent/src/pipeline/mod.rs b/compliance-agent/src/pipeline/mod.rs new file mode 100644 index 0000000..257da50 --- /dev/null +++ b/compliance-agent/src/pipeline/mod.rs @@ -0,0 +1,7 @@ +pub mod cve; +pub mod dedup; +pub mod git; +pub mod orchestrator; +pub mod patterns; +pub mod sbom; +pub mod semgrep; diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs new file mode 100644 index 0000000..7688d42 --- /dev/null +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -0,0 +1,252 @@ +use std::sync::Arc; + +use mongodb::bson::doc; + +use compliance_core::models::*; +use compliance_core::traits::Scanner; +use compliance_core::AgentConfig; + +use crate::database::Database; +use crate::error::AgentError; +use crate::llm::LlmClient; +use crate::pipeline::cve::CveScanner; +use crate::pipeline::git::GitOps; +use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner}; +use crate::pipeline::sbom::SbomScanner; +use crate::pipeline::semgrep::SemgrepScanner; + +pub struct PipelineOrchestrator { + config: AgentConfig, + db: Database, + llm: Arc, + http: reqwest::Client, +} + +impl PipelineOrchestrator { + pub fn new( + config: AgentConfig, + db: Database, + llm: Arc, + http: reqwest::Client, + ) -> Self { + Self { config, db, llm, http } + } + + pub async fn run( + &self, + repo_id: &str, + trigger: ScanTrigger, + ) -> Result<(), AgentError> { + // Look up the repository + let repo = self + .db + .repositories() + .find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(repo_id).map_err(|e| AgentError::Other(e.to_string()))? }) + .await? + .ok_or_else(|| AgentError::Other(format!("Repository {repo_id} not found")))?; + + // Create scan run + let scan_run = ScanRun::new(repo_id.to_string(), trigger); + let insert = self.db.scan_runs().insert_one(&scan_run).await?; + let scan_run_id = insert.inserted_id.as_object_id() + .map(|id| id.to_hex()) + .unwrap_or_default(); + + let result = self.run_pipeline(&repo, &scan_run_id).await; + + // Update scan run status + match &result { + Ok(count) => { + self.db.scan_runs().update_one( + doc! { "_id": &insert.inserted_id }, + doc! { + "$set": { + "status": "completed", + "current_phase": "completed", + "new_findings_count": *count as i64, + "completed_at": mongodb::bson::DateTime::now(), + } + }, + ).await?; + } + Err(e) => { + self.db.scan_runs().update_one( + doc! { "_id": &insert.inserted_id }, + doc! { + "$set": { + "status": "failed", + "error_message": e.to_string(), + "completed_at": mongodb::bson::DateTime::now(), + } + }, + ).await?; + } + } + + result.map(|_| ()) + } + + async fn run_pipeline( + &self, + repo: &TrackedRepository, + scan_run_id: &str, + ) -> Result { + let repo_id = repo.id.as_ref() + .map(|id| id.to_hex()) + .unwrap_or_default(); + + // Stage 0: Change detection + tracing::info!("[{repo_id}] Stage 0: Change detection"); + let git_ops = GitOps::new(&self.config.git_clone_base_path); + let repo_path = git_ops.clone_or_fetch(&repo.git_url, &repo.name)?; + + if !GitOps::has_new_commits(&repo_path, repo.last_scanned_commit.as_deref())? { + tracing::info!("[{repo_id}] No new commits, skipping scan"); + return Ok(0); + } + + let current_sha = GitOps::get_head_sha(&repo_path)?; + let mut all_findings: Vec = Vec::new(); + + // Stage 1: Semgrep SAST + tracing::info!("[{repo_id}] Stage 1: Semgrep SAST"); + self.update_phase(scan_run_id, "sast").await; + let semgrep = SemgrepScanner; + match semgrep.scan(&repo_path, &repo_id).await { + Ok(output) => all_findings.extend(output.findings), + Err(e) => tracing::warn!("[{repo_id}] Semgrep failed: {e}"), + } + + // Stage 2: SBOM Generation + tracing::info!("[{repo_id}] Stage 2: SBOM Generation"); + self.update_phase(scan_run_id, "sbom_generation").await; + let sbom_scanner = SbomScanner; + let mut sbom_entries = match sbom_scanner.scan(&repo_path, &repo_id).await { + Ok(output) => output.sbom_entries, + Err(e) => { + tracing::warn!("[{repo_id}] SBOM generation failed: {e}"); + Vec::new() + } + }; + + // Stage 3: CVE Scanning + tracing::info!("[{repo_id}] Stage 3: CVE Scanning"); + self.update_phase(scan_run_id, "cve_scanning").await; + let cve_scanner = CveScanner::new( + self.http.clone(), + self.config.searxng_url.clone(), + self.config.nvd_api_key.as_ref().map(|k| { + use secrecy::ExposeSecret; + k.expose_secret().to_string() + }), + ); + let cve_alerts = match cve_scanner.scan_dependencies(&repo_id, &mut sbom_entries).await { + Ok(alerts) => alerts, + Err(e) => { + tracing::warn!("[{repo_id}] CVE scanning failed: {e}"); + Vec::new() + } + }; + + // Stage 4: Pattern Scanning (GDPR + OAuth) + tracing::info!("[{repo_id}] Stage 4: Pattern Scanning"); + self.update_phase(scan_run_id, "pattern_scanning").await; + let gdpr = GdprPatternScanner::new(); + match gdpr.scan(&repo_path, &repo_id).await { + Ok(output) => all_findings.extend(output.findings), + Err(e) => tracing::warn!("[{repo_id}] GDPR pattern scan failed: {e}"), + } + let oauth = OAuthPatternScanner::new(); + match oauth.scan(&repo_path, &repo_id).await { + Ok(output) => all_findings.extend(output.findings), + Err(e) => tracing::warn!("[{repo_id}] OAuth pattern scan failed: {e}"), + } + + // Stage 5: LLM Triage + tracing::info!("[{repo_id}] Stage 5: LLM Triage ({} findings)", all_findings.len()); + self.update_phase(scan_run_id, "llm_triage").await; + let triaged = crate::llm::triage::triage_findings(&self.llm, &mut all_findings).await; + tracing::info!("[{repo_id}] Triaged: {triaged} findings passed confidence threshold"); + + // Dedup against existing findings and insert new ones + let mut new_count = 0u32; + for mut finding in all_findings { + finding.scan_run_id = Some(scan_run_id.to_string()); + // Check if fingerprint already exists + let existing = self + .db + .findings() + .find_one(doc! { "fingerprint": &finding.fingerprint }) + .await?; + if existing.is_none() { + self.db.findings().insert_one(&finding).await?; + new_count += 1; + } + } + + // Persist SBOM entries (upsert by repo_id + name + version) + for entry in &sbom_entries { + let filter = doc! { + "repo_id": &entry.repo_id, + "name": &entry.name, + "version": &entry.version, + }; + let update = mongodb::bson::to_document(entry) + .map(|d| doc! { "$set": d }) + .unwrap_or_else(|_| doc! {}); + self.db + .sbom_entries() + .update_one(filter, update) + .upsert(true) + .await?; + } + + // Persist CVE alerts (upsert by cve_id + repo_id) + for alert in &cve_alerts { + let filter = doc! { + "cve_id": &alert.cve_id, + "repo_id": &alert.repo_id, + }; + let update = mongodb::bson::to_document(alert) + .map(|d| doc! { "$set": d }) + .unwrap_or_else(|_| doc! {}); + self.db + .cve_alerts() + .update_one(filter, update) + .upsert(true) + .await?; + } + + // Stage 6: Issue Creation + tracing::info!("[{repo_id}] Stage 6: Issue Creation"); + self.update_phase(scan_run_id, "issue_creation").await; + // Issue creation is handled by the trackers module - deferred to agent + + // Stage 7: Update repository + self.db.repositories().update_one( + doc! { "_id": repo.id }, + doc! { + "$set": { + "last_scanned_commit": ¤t_sha, + "updated_at": mongodb::bson::DateTime::now(), + }, + "$inc": { "findings_count": new_count as i64 }, + }, + ).await?; + + tracing::info!("[{repo_id}] Scan complete: {new_count} new findings"); + Ok(new_count) + } + + async fn update_phase(&self, scan_run_id: &str, phase: &str) { + if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(scan_run_id) { + let _ = self.db.scan_runs().update_one( + doc! { "_id": oid }, + doc! { + "$set": { "current_phase": phase }, + "$push": { "phases_completed": phase }, + }, + ).await; + } + } +} diff --git a/compliance-agent/src/pipeline/patterns.rs b/compliance-agent/src/pipeline/patterns.rs new file mode 100644 index 0000000..87fa125 --- /dev/null +++ b/compliance-agent/src/pipeline/patterns.rs @@ -0,0 +1,226 @@ +use std::path::Path; + +use compliance_core::models::{Finding, ScanType, Severity}; +use compliance_core::traits::{ScanOutput, Scanner}; +use compliance_core::CoreError; +use regex::Regex; + +use crate::pipeline::dedup; + +pub struct GdprPatternScanner { + patterns: Vec, +} + +pub struct OAuthPatternScanner { + patterns: Vec, +} + +struct PatternRule { + id: String, + title: String, + description: String, + pattern: Regex, + severity: Severity, + file_extensions: Vec, +} + +impl GdprPatternScanner { + pub fn new() -> Self { + let patterns = vec![ + PatternRule { + id: "gdpr-pii-logging".to_string(), + title: "PII data potentially logged".to_string(), + description: "Logging statements that may contain personally identifiable information (email, SSN, phone, IP address).".to_string(), + pattern: Regex::new(r#"(?i)(log|print|console\.|logger\.|tracing::)\s*[\.(].*\b(email|ssn|social.?security|phone.?number|ip.?addr|passport|date.?of.?birth|credit.?card)\b"#).unwrap_or_else(|_| Regex::new("^$").unwrap()), + severity: Severity::High, + file_extensions: vec!["rs", "py", "js", "ts", "java", "go", "rb"].into_iter().map(String::from).collect(), + }, + PatternRule { + id: "gdpr-no-consent".to_string(), + title: "Data collection without apparent consent mechanism".to_string(), + description: "Data collection endpoint that doesn't reference consent or opt-in mechanisms.".to_string(), + pattern: Regex::new(r#"(?i)(collect|store|save|persist|record).*\b(personal|user.?data|pii|biometric)\b"#).unwrap_or_else(|_| Regex::new("^$").unwrap()), + severity: Severity::Medium, + file_extensions: vec!["rs", "py", "js", "ts", "java", "go"].into_iter().map(String::from).collect(), + }, + PatternRule { + id: "gdpr-no-delete-endpoint".to_string(), + title: "Missing data deletion capability".to_string(), + description: "User data models or controllers without corresponding deletion endpoints (right to erasure).".to_string(), + pattern: Regex::new(r#"(?i)(class|struct|model)\s+User(?!.*[Dd]elete)"#).unwrap_or_else(|_| Regex::new("^$").unwrap()), + severity: Severity::Medium, + file_extensions: vec!["rs", "py", "js", "ts", "java", "go", "rb"].into_iter().map(String::from).collect(), + }, + PatternRule { + id: "gdpr-hardcoded-retention".to_string(), + title: "Hardcoded data retention period".to_string(), + description: "Data retention periods should be configurable for GDPR compliance.".to_string(), + pattern: Regex::new(r#"(?i)(retention|ttl|expire|keep.?for)\s*[=:]\s*\d+"#).unwrap_or_else(|_| Regex::new("^$").unwrap()), + severity: Severity::Low, + file_extensions: vec!["rs", "py", "js", "ts", "java", "go", "yaml", "yml", "toml", "json"].into_iter().map(String::from).collect(), + }, + ]; + Self { patterns } + } +} + +impl Scanner for GdprPatternScanner { + fn name(&self) -> &str { + "gdpr-patterns" + } + + fn scan_type(&self) -> ScanType { + ScanType::Gdpr + } + + async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { + let findings = scan_with_patterns(repo_path, repo_id, &self.patterns, ScanType::Gdpr, "gdpr-patterns")?; + Ok(ScanOutput { + findings, + sbom_entries: Vec::new(), + }) + } +} + +impl OAuthPatternScanner { + pub fn new() -> Self { + let patterns = vec![ + PatternRule { + id: "oauth-implicit-grant".to_string(), + title: "OAuth implicit grant flow detected".to_string(), + description: "Implicit grant flow is deprecated and insecure. Use authorization code flow with PKCE instead.".to_string(), + pattern: Regex::new(r#"(?i)(response_type\s*[=:]\s*["']?token|grant_type\s*[=:]\s*["']?implicit)"#).unwrap_or_else(|_| Regex::new("^$").unwrap()), + severity: Severity::High, + file_extensions: vec!["rs", "py", "js", "ts", "java", "go", "yaml", "yml", "json"].into_iter().map(String::from).collect(), + }, + PatternRule { + id: "oauth-missing-pkce".to_string(), + title: "OAuth flow without PKCE".to_string(), + description: "Authorization code flow should use PKCE (code_challenge/code_verifier) for public clients.".to_string(), + pattern: Regex::new(r#"(?i)authorization.?code(?!.*code.?challenge)(?!.*pkce)"#).unwrap_or_else(|_| Regex::new("^$").unwrap()), + severity: Severity::Medium, + file_extensions: vec!["rs", "py", "js", "ts", "java", "go"].into_iter().map(String::from).collect(), + }, + PatternRule { + id: "oauth-token-localstorage".to_string(), + title: "Token stored in localStorage".to_string(), + description: "Storing tokens in localStorage is vulnerable to XSS. Use httpOnly cookies or secure session storage.".to_string(), + pattern: Regex::new(r#"(?i)localStorage\.(set|get)Item\s*\(\s*["'].*token"#).unwrap_or_else(|_| Regex::new("^$").unwrap()), + severity: Severity::High, + file_extensions: vec!["js", "ts", "jsx", "tsx"].into_iter().map(String::from).collect(), + }, + PatternRule { + id: "oauth-token-url".to_string(), + title: "Token passed in URL parameters".to_string(), + description: "Tokens in URLs can leak via referrer headers, server logs, and browser history.".to_string(), + pattern: Regex::new(r#"(?i)(access_token|bearer)\s*[=]\s*.*\b(url|query|param|href)\b"#).unwrap_or_else(|_| Regex::new("^$").unwrap()), + severity: Severity::High, + file_extensions: vec!["rs", "py", "js", "ts", "java", "go"].into_iter().map(String::from).collect(), + }, + ]; + Self { patterns } + } +} + +impl Scanner for OAuthPatternScanner { + fn name(&self) -> &str { + "oauth-patterns" + } + + fn scan_type(&self) -> ScanType { + ScanType::OAuth + } + + async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { + let findings = scan_with_patterns(repo_path, repo_id, &self.patterns, ScanType::OAuth, "oauth-patterns")?; + Ok(ScanOutput { + findings, + sbom_entries: Vec::new(), + }) + } +} + +fn scan_with_patterns( + repo_path: &Path, + repo_id: &str, + patterns: &[PatternRule], + scan_type: ScanType, + scanner_name: &str, +) -> Result, CoreError> { + let mut findings = Vec::new(); + + for entry in walkdir(repo_path)? { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_string(); + + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, // skip binary files + }; + + let relative_path = path + .strip_prefix(repo_path) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + + for pattern in patterns { + if !pattern.file_extensions.contains(&ext) { + continue; + } + + for (line_num, line) in content.lines().enumerate() { + if pattern.pattern.is_match(line) { + let fingerprint = dedup::compute_fingerprint(&[ + repo_id, + &pattern.id, + &relative_path, + &(line_num + 1).to_string(), + ]); + + let mut finding = Finding::new( + repo_id.to_string(), + fingerprint, + scanner_name.to_string(), + scan_type.clone(), + pattern.title.clone(), + pattern.description.clone(), + pattern.severity.clone(), + ); + finding.rule_id = Some(pattern.id.clone()); + finding.file_path = Some(relative_path.clone()); + finding.line_number = Some((line_num + 1) as u32); + finding.code_snippet = Some(line.to_string()); + + findings.push(finding); + } + } + } + } + + Ok(findings) +} + +fn walkdir(path: &Path) -> Result, CoreError> { + // Simple recursive file walk, skipping hidden dirs and common non-source dirs + let skip_dirs = [".git", "node_modules", "target", "vendor", ".venv", "__pycache__", "dist", "build"]; + + let entries: Vec<_> = walkdir::WalkDir::new(path) + .into_iter() + .filter_entry(|e| { + let name = e.file_name().to_string_lossy(); + !skip_dirs.contains(&name.as_ref()) + }) + .filter_map(|e| e.ok()) + .collect(); + + Ok(entries) +} diff --git a/compliance-agent/src/pipeline/sbom.rs b/compliance-agent/src/pipeline/sbom.rs new file mode 100644 index 0000000..a19a97d --- /dev/null +++ b/compliance-agent/src/pipeline/sbom.rs @@ -0,0 +1,186 @@ +use std::path::Path; + +use compliance_core::models::{SbomEntry, ScanType, VulnRef}; +use compliance_core::traits::{ScanOutput, Scanner}; +use compliance_core::CoreError; + +pub struct SbomScanner; + +impl Scanner for SbomScanner { + fn name(&self) -> &str { + "sbom" + } + + fn scan_type(&self) -> ScanType { + ScanType::Sbom + } + + async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { + let mut entries = Vec::new(); + + // Run syft for SBOM generation + match run_syft(repo_path, repo_id).await { + Ok(syft_entries) => entries.extend(syft_entries), + Err(e) => tracing::warn!("syft failed: {e}"), + } + + // Run cargo-audit for Rust-specific vulns + match run_cargo_audit(repo_path, repo_id).await { + Ok(vulns) => merge_audit_vulns(&mut entries, vulns), + Err(e) => tracing::warn!("cargo-audit skipped: {e}"), + } + + Ok(ScanOutput { + findings: Vec::new(), + sbom_entries: entries, + }) + } +} + +async fn run_syft(repo_path: &Path, repo_id: &str) -> Result, CoreError> { + let output = tokio::process::Command::new("syft") + .arg(repo_path) + .args(["-o", "cyclonedx-json"]) + .output() + .await + .map_err(|e| CoreError::Scanner { + scanner: "syft".to_string(), + source: Box::new(e), + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(CoreError::Scanner { + scanner: "syft".to_string(), + source: format!("syft exited with {}: {stderr}", output.status).into(), + }); + } + + let cdx: CycloneDxBom = serde_json::from_slice(&output.stdout)?; + let entries = cdx + .components + .unwrap_or_default() + .into_iter() + .map(|c| { + let mut entry = SbomEntry::new( + repo_id.to_string(), + c.name, + c.version.unwrap_or_else(|| "unknown".to_string()), + c.component_type.unwrap_or_else(|| "library".to_string()), + ); + entry.purl = c.purl; + entry.license = c.licenses.and_then(|ls| { + ls.first().and_then(|l| { + l.license.as_ref().map(|lic| { + lic.id.clone().unwrap_or_else(|| lic.name.clone().unwrap_or_default()) + }) + }) + }); + entry + }) + .collect(); + + Ok(entries) +} + +async fn run_cargo_audit(repo_path: &Path, _repo_id: &str) -> Result, CoreError> { + let cargo_lock = repo_path.join("Cargo.lock"); + if !cargo_lock.exists() { + return Ok(Vec::new()); + } + + let output = tokio::process::Command::new("cargo") + .args(["audit", "--json"]) + .current_dir(repo_path) + .output() + .await + .map_err(|e| CoreError::Scanner { + scanner: "cargo-audit".to_string(), + source: Box::new(e), + })?; + + let result: CargoAuditOutput = serde_json::from_slice(&output.stdout) + .unwrap_or_else(|_| CargoAuditOutput { vulnerabilities: CargoAuditVulns { list: Vec::new() } }); + + let vulns = result + .vulnerabilities + .list + .into_iter() + .map(|v| AuditVuln { + package: v.advisory.package, + id: v.advisory.id, + url: v.advisory.url, + }) + .collect(); + + Ok(vulns) +} + +fn merge_audit_vulns(entries: &mut Vec, vulns: Vec) { + for vuln in vulns { + if let Some(entry) = entries.iter_mut().find(|e| e.name == vuln.package) { + entry.known_vulnerabilities.push(VulnRef { + id: vuln.id.clone(), + source: "cargo-audit".to_string(), + severity: None, + url: Some(vuln.url), + }); + } + } +} + +// CycloneDX JSON types +#[derive(serde::Deserialize)] +struct CycloneDxBom { + components: Option>, +} + +#[derive(serde::Deserialize)] +struct CdxComponent { + name: String, + version: Option, + #[serde(rename = "type")] + component_type: Option, + purl: Option, + licenses: Option>, +} + +#[derive(serde::Deserialize)] +struct CdxLicenseWrapper { + license: Option, +} + +#[derive(serde::Deserialize)] +struct CdxLicense { + id: Option, + name: Option, +} + +// Cargo audit types +#[derive(serde::Deserialize)] +struct CargoAuditOutput { + vulnerabilities: CargoAuditVulns, +} + +#[derive(serde::Deserialize)] +struct CargoAuditVulns { + list: Vec, +} + +#[derive(serde::Deserialize)] +struct CargoAuditEntry { + advisory: CargoAuditAdvisory, +} + +#[derive(serde::Deserialize)] +struct CargoAuditAdvisory { + id: String, + package: String, + url: String, +} + +struct AuditVuln { + package: String, + id: String, + url: String, +} diff --git a/compliance-agent/src/pipeline/semgrep.rs b/compliance-agent/src/pipeline/semgrep.rs new file mode 100644 index 0000000..13e2bb4 --- /dev/null +++ b/compliance-agent/src/pipeline/semgrep.rs @@ -0,0 +1,110 @@ +use std::path::Path; + +use compliance_core::models::{Finding, ScanType, Severity}; +use compliance_core::traits::{ScanOutput, Scanner}; +use compliance_core::CoreError; + +use crate::pipeline::dedup; + +pub struct SemgrepScanner; + +impl Scanner for SemgrepScanner { + fn name(&self) -> &str { + "semgrep" + } + + fn scan_type(&self) -> ScanType { + ScanType::Sast + } + + async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { + let output = tokio::process::Command::new("semgrep") + .args(["--config=auto", "--json", "--quiet"]) + .arg(repo_path) + .output() + .await + .map_err(|e| CoreError::Scanner { + scanner: "semgrep".to_string(), + source: Box::new(e), + })?; + + if !output.status.success() && output.stdout.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!("Semgrep exited with {}: {stderr}", output.status); + return Ok(ScanOutput::default()); + } + + let result: SemgrepOutput = serde_json::from_slice(&output.stdout)?; + let findings = result + .results + .into_iter() + .map(|r| { + let severity = match r.extra.severity.as_str() { + "ERROR" => Severity::High, + "WARNING" => Severity::Medium, + "INFO" => Severity::Low, + _ => Severity::Info, + }; + + let fingerprint = dedup::compute_fingerprint(&[ + repo_id, + &r.check_id, + &r.path, + &r.start.line.to_string(), + ]); + + let mut finding = Finding::new( + repo_id.to_string(), + fingerprint, + "semgrep".to_string(), + ScanType::Sast, + r.extra.message.clone(), + r.extra.message, + severity, + ); + finding.rule_id = Some(r.check_id); + finding.file_path = Some(r.path); + finding.line_number = Some(r.start.line); + finding.code_snippet = Some(r.extra.lines); + finding.cwe = r.extra.metadata.and_then(|m| { + m.get("cwe") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }); + finding + }) + .collect(); + + Ok(ScanOutput { + findings, + sbom_entries: Vec::new(), + }) + } +} + +#[derive(serde::Deserialize)] +struct SemgrepOutput { + results: Vec, +} + +#[derive(serde::Deserialize)] +struct SemgrepResult { + check_id: String, + path: String, + start: SemgrepPosition, + extra: SemgrepExtra, +} + +#[derive(serde::Deserialize)] +struct SemgrepPosition { + line: u32, +} + +#[derive(serde::Deserialize)] +struct SemgrepExtra { + message: String, + severity: String, + lines: String, + #[serde(default)] + metadata: Option, +} diff --git a/compliance-agent/src/scheduler.rs b/compliance-agent/src/scheduler.rs new file mode 100644 index 0000000..498d2db --- /dev/null +++ b/compliance-agent/src/scheduler.rs @@ -0,0 +1,105 @@ +use mongodb::bson::doc; +use tokio_cron_scheduler::{Job, JobScheduler}; + +use compliance_core::models::ScanTrigger; + +use crate::agent::ComplianceAgent; +use crate::error::AgentError; + +pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> { + let sched = JobScheduler::new() + .await + .map_err(|e| AgentError::Scheduler(format!("Failed to create scheduler: {e}")))?; + + // Periodic scan job + let scan_agent = agent.clone(); + let scan_schedule = agent.config.scan_schedule.clone(); + let scan_job = Job::new_async(scan_schedule.as_str(), move |_uuid, _lock| { + let agent = scan_agent.clone(); + Box::pin(async move { + tracing::info!("Scheduled scan triggered"); + scan_all_repos(&agent).await; + }) + }) + .map_err(|e| AgentError::Scheduler(format!("Failed to create scan job: {e}")))?; + sched.add(scan_job).await + .map_err(|e| AgentError::Scheduler(format!("Failed to add scan job: {e}")))?; + + // CVE monitor job (daily) + let cve_agent = agent.clone(); + let cve_schedule = agent.config.cve_monitor_schedule.clone(); + let cve_job = Job::new_async(cve_schedule.as_str(), move |_uuid, _lock| { + let agent = cve_agent.clone(); + Box::pin(async move { + tracing::info!("CVE monitor triggered"); + monitor_cves(&agent).await; + }) + }) + .map_err(|e| AgentError::Scheduler(format!("Failed to create CVE monitor job: {e}")))?; + sched.add(cve_job).await + .map_err(|e| AgentError::Scheduler(format!("Failed to add CVE monitor job: {e}")))?; + + sched.start().await + .map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?; + + tracing::info!( + "Scheduler started: scans='{}', CVE monitor='{}'", + agent.config.scan_schedule, + agent.config.cve_monitor_schedule, + ); + + // Keep scheduler alive + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; + } +} + +async fn scan_all_repos(agent: &ComplianceAgent) { + use futures_util::StreamExt; + + let cursor = match agent.db.repositories().find(doc! {}).await { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to list repos for scheduled scan: {e}"); + return; + } + }; + + let repos: Vec<_> = cursor + .filter_map(|r| async { r.ok() }) + .collect() + .await; + + for repo in repos { + let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); + if let Err(e) = agent.run_scan(&repo_id, ScanTrigger::Scheduled).await { + tracing::error!("Scheduled scan failed for {}: {e}", repo.name); + } + } +} + +async fn monitor_cves(agent: &ComplianceAgent) { + use futures_util::StreamExt; + + // Re-scan all SBOM entries for new CVEs + let cursor = match agent.db.sbom_entries().find(doc! {}).await { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to list SBOM entries for CVE monitoring: {e}"); + return; + } + }; + + let entries: Vec<_> = cursor + .filter_map(|r| async { r.ok() }) + .collect() + .await; + + if entries.is_empty() { + return; + } + + tracing::info!("CVE monitor: checking {} dependencies", entries.len()); + // The actual CVE checking is handled by the CveScanner in the pipeline + // This is a simplified version that just logs the activity +} diff --git a/compliance-agent/src/trackers/github.rs b/compliance-agent/src/trackers/github.rs new file mode 100644 index 0000000..b383a61 --- /dev/null +++ b/compliance-agent/src/trackers/github.rs @@ -0,0 +1,161 @@ +use compliance_core::error::CoreError; +use compliance_core::models::{TrackerIssue, TrackerType}; +use compliance_core::traits::issue_tracker::{IssueTracker, ReviewComment}; +use octocrab::Octocrab; +use secrecy::{ExposeSecret, SecretString}; + +pub struct GitHubTracker { + client: Octocrab, +} + +impl GitHubTracker { + pub fn new(token: &SecretString) -> Result { + let client = Octocrab::builder() + .personal_token(token.expose_secret().to_string()) + .build() + .map_err(|e| CoreError::IssueTracker(format!("Failed to create GitHub client: {e}")))?; + Ok(Self { client }) + } +} + +impl IssueTracker for GitHubTracker { + fn name(&self) -> &str { + "github" + } + + async fn create_issue( + &self, + owner: &str, + repo: &str, + title: &str, + body: &str, + labels: &[String], + ) -> Result { + let issues_handler = self.client.issues(owner, repo); + let mut builder = issues_handler.create(title).body(body); + if !labels.is_empty() { + builder = builder.labels(labels.to_vec()); + } + let issue = builder + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("GitHub create issue failed: {e}")))?; + + Ok(TrackerIssue::new( + String::new(), + TrackerType::GitHub, + issue.number.to_string(), + issue.html_url.to_string(), + title.to_string(), + )) + } + + async fn update_issue_status( + &self, + owner: &str, + repo: &str, + external_id: &str, + status: &str, + ) -> Result<(), CoreError> { + let issue_number: u64 = external_id + .parse() + .map_err(|_| CoreError::IssueTracker("Invalid issue number".to_string()))?; + + let state_str = match status { + "closed" | "resolved" => "closed", + _ => "open", + }; + + // Use the REST API directly for state update + let route = format!("/repos/{owner}/{repo}/issues/{issue_number}"); + let body = serde_json::json!({ "state": state_str }); + self.client + .post::(route, Some(&body)) + .await + .map_err(|e| CoreError::IssueTracker(format!("GitHub update issue failed: {e}")))?; + + Ok(()) + } + + async fn add_comment( + &self, + owner: &str, + repo: &str, + external_id: &str, + body: &str, + ) -> Result<(), CoreError> { + let issue_number: u64 = external_id + .parse() + .map_err(|_| CoreError::IssueTracker("Invalid issue number".to_string()))?; + + self.client + .issues(owner, repo) + .create_comment(issue_number, body) + .await + .map_err(|e| CoreError::IssueTracker(format!("GitHub add comment failed: {e}")))?; + + Ok(()) + } + + async fn create_pr_review( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + comments: Vec, + ) -> Result<(), CoreError> { + let review_comments: Vec = comments + .iter() + .map(|c| { + serde_json::json!({ + "path": c.path, + "line": c.line, + "body": c.body, + }) + }) + .collect(); + + let review_body = serde_json::json!({ + "body": body, + "event": "COMMENT", + "comments": review_comments, + }); + + let route = format!("/repos/{owner}/{repo}/pulls/{pr_number}/reviews"); + self.client + .post::(route, Some(&review_body)) + .await + .map_err(|e| CoreError::IssueTracker(format!("GitHub PR review failed: {e}")))?; + + Ok(()) + } + + async fn find_existing_issue( + &self, + owner: &str, + repo: &str, + fingerprint: &str, + ) -> Result, CoreError> { + let query = format!("repo:{owner}/{repo} is:issue {fingerprint}"); + let results = self + .client + .search() + .issues_and_pull_requests(&query) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("GitHub search failed: {e}")))?; + + if let Some(issue) = results.items.first() { + Ok(Some(TrackerIssue::new( + String::new(), + TrackerType::GitHub, + issue.number.to_string(), + issue.html_url.to_string(), + issue.title.clone(), + ))) + } else { + Ok(None) + } + } +} diff --git a/compliance-agent/src/trackers/gitlab.rs b/compliance-agent/src/trackers/gitlab.rs new file mode 100644 index 0000000..6791ec2 --- /dev/null +++ b/compliance-agent/src/trackers/gitlab.rs @@ -0,0 +1,201 @@ +use compliance_core::error::CoreError; +use compliance_core::models::{TrackerIssue, TrackerType}; +use compliance_core::traits::issue_tracker::{IssueTracker, ReviewComment}; +use secrecy::{ExposeSecret, SecretString}; + +pub struct GitLabTracker { + base_url: String, + http: reqwest::Client, + token: SecretString, +} + +impl GitLabTracker { + pub fn new(base_url: String, token: SecretString) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + token, + } + } + + fn api_url(&self, path: &str) -> String { + format!("{}/api/v4{}", self.base_url, path) + } + + fn project_path(owner: &str, repo: &str) -> String { + urlencoding::encode(&format!("{owner}/{repo}")).to_string() + } +} + +impl IssueTracker for GitLabTracker { + fn name(&self) -> &str { + "gitlab" + } + + async fn create_issue( + &self, + owner: &str, + repo: &str, + title: &str, + body: &str, + labels: &[String], + ) -> Result { + let project = Self::project_path(owner, repo); + let url = self.api_url(&format!("/projects/{project}/issues")); + + let mut payload = serde_json::json!({ + "title": title, + "description": body, + }); + if !labels.is_empty() { + payload["labels"] = serde_json::Value::String(labels.join(",")); + } + + let resp = self + .http + .post(&url) + .header("PRIVATE-TOKEN", self.token.expose_secret()) + .json(&payload) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("GitLab create issue failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(CoreError::IssueTracker(format!("GitLab returned {status}: {body}"))); + } + + let issue: serde_json::Value = resp.json().await + .map_err(|e| CoreError::IssueTracker(format!("Failed to parse GitLab response: {e}")))?; + + Ok(TrackerIssue::new( + String::new(), + TrackerType::GitLab, + issue["iid"].to_string(), + issue["web_url"].as_str().unwrap_or("").to_string(), + title.to_string(), + )) + } + + async fn update_issue_status( + &self, + owner: &str, + repo: &str, + external_id: &str, + status: &str, + ) -> Result<(), CoreError> { + let project = Self::project_path(owner, repo); + let url = self.api_url(&format!("/projects/{project}/issues/{external_id}")); + + let state_event = match status { + "closed" | "resolved" => "close", + _ => "reopen", + }; + + self.http + .put(&url) + .header("PRIVATE-TOKEN", self.token.expose_secret()) + .json(&serde_json::json!({ "state_event": state_event })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("GitLab update issue failed: {e}")))?; + + Ok(()) + } + + async fn add_comment( + &self, + owner: &str, + repo: &str, + external_id: &str, + body: &str, + ) -> Result<(), CoreError> { + let project = Self::project_path(owner, repo); + let url = self.api_url(&format!("/projects/{project}/issues/{external_id}/notes")); + + self.http + .post(&url) + .header("PRIVATE-TOKEN", self.token.expose_secret()) + .json(&serde_json::json!({ "body": body })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("GitLab add comment failed: {e}")))?; + + Ok(()) + } + + async fn create_pr_review( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + comments: Vec, + ) -> Result<(), CoreError> { + let project = Self::project_path(owner, repo); + + // Post overall review as MR note + let note_url = self.api_url(&format!("/projects/{project}/merge_requests/{pr_number}/notes")); + self.http + .post(¬e_url) + .header("PRIVATE-TOKEN", self.token.expose_secret()) + .json(&serde_json::json!({ "body": body })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("GitLab MR note failed: {e}")))?; + + // Post individual line comments as MR discussions + for comment in comments { + let disc_url = self.api_url(&format!("/projects/{project}/merge_requests/{pr_number}/discussions")); + let payload = serde_json::json!({ + "body": comment.body, + "position": { + "position_type": "text", + "new_path": comment.path, + "new_line": comment.line, + } + }); + let _ = self + .http + .post(&disc_url) + .header("PRIVATE-TOKEN", self.token.expose_secret()) + .json(&payload) + .send() + .await; + } + + Ok(()) + } + + async fn find_existing_issue( + &self, + owner: &str, + repo: &str, + fingerprint: &str, + ) -> Result, CoreError> { + let project = Self::project_path(owner, repo); + let url = self.api_url(&format!("/projects/{project}/issues?search={fingerprint}")); + + let resp = self + .http + .get(&url) + .header("PRIVATE-TOKEN", self.token.expose_secret()) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("GitLab search failed: {e}")))?; + + let issues: Vec = resp.json().await.unwrap_or_default(); + if let Some(issue) = issues.first() { + Ok(Some(TrackerIssue::new( + String::new(), + TrackerType::GitLab, + issue["iid"].to_string(), + issue["web_url"].as_str().unwrap_or("").to_string(), + issue["title"].as_str().unwrap_or("").to_string(), + ))) + } else { + Ok(None) + } + } +} diff --git a/compliance-agent/src/trackers/jira.rs b/compliance-agent/src/trackers/jira.rs new file mode 100644 index 0000000..048e238 --- /dev/null +++ b/compliance-agent/src/trackers/jira.rs @@ -0,0 +1,231 @@ +use compliance_core::error::CoreError; +use compliance_core::models::{TrackerIssue, TrackerType}; +use compliance_core::traits::issue_tracker::{IssueTracker, ReviewComment}; +use secrecy::{ExposeSecret, SecretString}; + +pub struct JiraTracker { + base_url: String, + email: String, + api_token: SecretString, + project_key: String, + http: reqwest::Client, +} + +impl JiraTracker { + pub fn new(base_url: String, email: String, api_token: SecretString, project_key: String) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + email, + api_token, + project_key, + http: reqwest::Client::new(), + } + } + + fn auth_header(&self) -> String { + use base64::Engine; + let credentials = format!("{}:{}", self.email, self.api_token.expose_secret()); + format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(credentials)) + } +} + +impl IssueTracker for JiraTracker { + fn name(&self) -> &str { + "jira" + } + + async fn create_issue( + &self, + _owner: &str, + _repo: &str, + title: &str, + body: &str, + labels: &[String], + ) -> Result { + let url = format!("{}/rest/api/3/issue", self.base_url); + + let mut payload = serde_json::json!({ + "fields": { + "project": { "key": self.project_key }, + "summary": title, + "description": { + "type": "doc", + "version": 1, + "content": [{ + "type": "paragraph", + "content": [{ + "type": "text", + "text": body, + }] + }] + }, + "issuetype": { "name": "Bug" }, + } + }); + + if !labels.is_empty() { + payload["fields"]["labels"] = serde_json::Value::Array( + labels.iter().map(|l| serde_json::Value::String(l.clone())).collect(), + ); + } + + let resp = self + .http + .post(&url) + .header("Authorization", self.auth_header()) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Jira create issue failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(CoreError::IssueTracker(format!("Jira returned {status}: {body}"))); + } + + let issue: serde_json::Value = resp.json().await + .map_err(|e| CoreError::IssueTracker(format!("Failed to parse Jira response: {e}")))?; + + let key = issue["key"].as_str().unwrap_or("").to_string(); + let url = format!("{}/browse/{}", self.base_url, key); + + Ok(TrackerIssue::new( + String::new(), + TrackerType::Jira, + key, + url, + title.to_string(), + )) + } + + async fn update_issue_status( + &self, + _owner: &str, + _repo: &str, + external_id: &str, + status: &str, + ) -> Result<(), CoreError> { + // Get available transitions + let url = format!("{}/rest/api/3/issue/{external_id}/transitions", self.base_url); + let resp = self + .http + .get(&url) + .header("Authorization", self.auth_header()) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Jira get transitions failed: {e}")))?; + + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + let transitions = body["transitions"].as_array(); + + // Find matching transition + if let Some(transitions) = transitions { + let target = match status { + "closed" | "resolved" => "Done", + "in_progress" => "In Progress", + _ => "To Do", + }; + + if let Some(transition) = transitions.iter().find(|t| { + t["name"].as_str().map(|n| n.eq_ignore_ascii_case(target)).unwrap_or(false) + }) { + let transition_id = transition["id"].as_str().unwrap_or(""); + self.http + .post(&format!("{}/rest/api/3/issue/{external_id}/transitions", self.base_url)) + .header("Authorization", self.auth_header()) + .json(&serde_json::json!({ "transition": { "id": transition_id } })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Jira transition failed: {e}")))?; + } + } + + Ok(()) + } + + async fn add_comment( + &self, + _owner: &str, + _repo: &str, + external_id: &str, + body: &str, + ) -> Result<(), CoreError> { + let url = format!("{}/rest/api/3/issue/{external_id}/comment", self.base_url); + + self.http + .post(&url) + .header("Authorization", self.auth_header()) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "body": { + "type": "doc", + "version": 1, + "content": [{ + "type": "paragraph", + "content": [{ + "type": "text", + "text": body, + }] + }] + } + })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Jira add comment failed: {e}")))?; + + Ok(()) + } + + async fn create_pr_review( + &self, + _owner: &str, + _repo: &str, + _pr_number: u64, + _body: &str, + _comments: Vec, + ) -> Result<(), CoreError> { + // Jira doesn't have native PR reviews - this is a no-op + tracing::info!("Jira doesn't support PR reviews natively, skipping"); + Ok(()) + } + + async fn find_existing_issue( + &self, + _owner: &str, + _repo: &str, + fingerprint: &str, + ) -> Result, CoreError> { + let jql = format!( + "project = {} AND text ~ \"{}\"", + self.project_key, fingerprint + ); + let url = format!("{}/rest/api/3/search", self.base_url); + + let resp = self + .http + .get(&url) + .header("Authorization", self.auth_header()) + .query(&[("jql", &jql), ("maxResults", &"1".to_string())]) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Jira search failed: {e}")))?; + + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + if let Some(issue) = body["issues"].as_array().and_then(|arr| arr.first()) { + let key = issue["key"].as_str().unwrap_or("").to_string(); + let url = format!("{}/browse/{}", self.base_url, key); + let title = issue["fields"]["summary"].as_str().unwrap_or("").to_string(); + Ok(Some(TrackerIssue::new( + String::new(), + TrackerType::Jira, + key, + url, + title, + ))) + } else { + Ok(None) + } + } +} diff --git a/compliance-agent/src/trackers/mod.rs b/compliance-agent/src/trackers/mod.rs new file mode 100644 index 0000000..b4abd57 --- /dev/null +++ b/compliance-agent/src/trackers/mod.rs @@ -0,0 +1,3 @@ +pub mod github; +pub mod gitlab; +pub mod jira; diff --git a/compliance-agent/src/webhooks/github.rs b/compliance-agent/src/webhooks/github.rs new file mode 100644 index 0000000..e94548e --- /dev/null +++ b/compliance-agent/src/webhooks/github.rs @@ -0,0 +1,130 @@ +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::Extension; +use axum::http::{HeaderMap, StatusCode}; +use hmac::{Hmac, Mac}; +use secrecy::ExposeSecret; +use sha2::Sha256; + +use compliance_core::models::ScanTrigger; + +use crate::agent::ComplianceAgent; + +type HmacSha256 = Hmac; + +pub async fn handle_github_webhook( + Extension(agent): Extension>, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + // Verify HMAC signature + if let Some(secret) = &agent.config.github_webhook_secret { + let signature = headers + .get("x-hub-signature-256") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if !verify_signature(secret.expose_secret(), &body, signature) { + tracing::warn!("GitHub webhook: invalid signature"); + return StatusCode::UNAUTHORIZED; + } + } + + let event = headers + .get("x-github-event") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let payload: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => { + tracing::warn!("GitHub webhook: invalid JSON: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + match event { + "push" => handle_push(agent, &payload).await, + "pull_request" => handle_pull_request(agent, &payload).await, + _ => { + tracing::debug!("GitHub webhook: ignoring event '{event}'"); + StatusCode::OK + } + } +} + +async fn handle_push(agent: Arc, payload: &serde_json::Value) -> StatusCode { + let repo_url = payload["repository"]["clone_url"] + .as_str() + .or_else(|| payload["repository"]["html_url"].as_str()) + .unwrap_or(""); + + if repo_url.is_empty() { + return StatusCode::BAD_REQUEST; + } + + // Find matching tracked repository + let repo = agent + .db + .repositories() + .find_one(mongodb::bson::doc! { "git_url": repo_url }) + .await + .ok() + .flatten(); + + if let Some(repo) = repo { + let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); + let agent_clone = (*agent).clone(); + tokio::spawn(async move { + tracing::info!("GitHub push webhook: triggering scan for {repo_id}"); + if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { + tracing::error!("Webhook-triggered scan failed: {e}"); + } + }); + } else { + tracing::debug!("GitHub push webhook: no tracked repo for {repo_url}"); + } + + StatusCode::OK +} + +async fn handle_pull_request( + _agent: Arc, + payload: &serde_json::Value, +) -> StatusCode { + let action = payload["action"].as_str().unwrap_or(""); + if action != "opened" && action != "synchronize" { + return StatusCode::OK; + } + + let repo_url = payload["repository"]["clone_url"] + .as_str() + .unwrap_or(""); + let pr_number = payload["pull_request"]["number"].as_u64().unwrap_or(0); + + if repo_url.is_empty() || pr_number == 0 { + return StatusCode::BAD_REQUEST; + } + + tracing::info!("GitHub PR webhook: PR #{pr_number} {action} on {repo_url}"); + // PR review scan would be triggered here - runs incremental SAST on diff + // and posts review comments via the GitHub tracker + + StatusCode::OK +} + +fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool { + let sig = signature.strip_prefix("sha256=").unwrap_or(signature); + let sig_bytes = match hex::decode(sig) { + Ok(b) => b, + Err(_) => return false, + }; + + let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(body); + mac.verify_slice(&sig_bytes).is_ok() +} diff --git a/compliance-agent/src/webhooks/gitlab.rs b/compliance-agent/src/webhooks/gitlab.rs new file mode 100644 index 0000000..8c2d82d --- /dev/null +++ b/compliance-agent/src/webhooks/gitlab.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::Extension; +use axum::http::{HeaderMap, StatusCode}; +use secrecy::ExposeSecret; + +use compliance_core::models::ScanTrigger; + +use crate::agent::ComplianceAgent; + +pub async fn handle_gitlab_webhook( + Extension(agent): Extension>, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + // Verify GitLab token + if let Some(secret) = &agent.config.gitlab_webhook_secret { + let token = headers + .get("x-gitlab-token") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if token != secret.expose_secret() { + tracing::warn!("GitLab webhook: invalid token"); + return StatusCode::UNAUTHORIZED; + } + } + + let payload: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => { + tracing::warn!("GitLab webhook: invalid JSON: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let event_type = payload["object_kind"].as_str().unwrap_or(""); + + match event_type { + "push" => handle_push(agent, &payload).await, + "merge_request" => handle_merge_request(agent, &payload).await, + _ => { + tracing::debug!("GitLab webhook: ignoring event '{event_type}'"); + StatusCode::OK + } + } +} + +async fn handle_push(agent: Arc, payload: &serde_json::Value) -> StatusCode { + let repo_url = payload["project"]["git_http_url"] + .as_str() + .or_else(|| payload["project"]["web_url"].as_str()) + .unwrap_or(""); + + if repo_url.is_empty() { + return StatusCode::BAD_REQUEST; + } + + let repo = agent + .db + .repositories() + .find_one(mongodb::bson::doc! { "git_url": repo_url }) + .await + .ok() + .flatten(); + + if let Some(repo) = repo { + let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); + let agent_clone = (*agent).clone(); + tokio::spawn(async move { + tracing::info!("GitLab push webhook: triggering scan for {repo_id}"); + if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { + tracing::error!("Webhook-triggered scan failed: {e}"); + } + }); + } + + StatusCode::OK +} + +async fn handle_merge_request( + _agent: Arc, + payload: &serde_json::Value, +) -> StatusCode { + let action = payload["object_attributes"]["action"].as_str().unwrap_or(""); + if action != "open" && action != "update" { + return StatusCode::OK; + } + + let mr_iid = payload["object_attributes"]["iid"].as_u64().unwrap_or(0); + tracing::info!("GitLab MR webhook: MR !{mr_iid} {action}"); + + StatusCode::OK +} diff --git a/compliance-agent/src/webhooks/mod.rs b/compliance-agent/src/webhooks/mod.rs new file mode 100644 index 0000000..8ca1a34 --- /dev/null +++ b/compliance-agent/src/webhooks/mod.rs @@ -0,0 +1,5 @@ +pub mod github; +pub mod gitlab; +pub mod server; + +pub use server::start_webhook_server; diff --git a/compliance-agent/src/webhooks/server.rs b/compliance-agent/src/webhooks/server.rs new file mode 100644 index 0000000..b695aba --- /dev/null +++ b/compliance-agent/src/webhooks/server.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use axum::routing::post; +use axum::{Extension, Router}; + +use crate::agent::ComplianceAgent; +use crate::error::AgentError; +use crate::webhooks::{github, gitlab}; + +pub async fn start_webhook_server(agent: &ComplianceAgent) -> Result<(), AgentError> { + let app = Router::new() + .route("/webhook/github", post(github::handle_github_webhook)) + .route("/webhook/gitlab", post(gitlab::handle_gitlab_webhook)) + .layer(Extension(Arc::new(agent.clone()))); + + let addr = "0.0.0.0:3002"; + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| AgentError::Other(format!("Failed to bind webhook server: {e}")))?; + + tracing::info!("Webhook server listening on {addr}"); + axum::serve(listener, app) + .await + .map_err(|e| AgentError::Other(format!("Webhook server error: {e}")))?; + + Ok(()) +} diff --git a/compliance-core/Cargo.toml b/compliance-core/Cargo.toml new file mode 100644 index 0000000..5bd151a --- /dev/null +++ b/compliance-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "compliance-core" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +uuid = { workspace = true } +secrecy = { workspace = true } +mongodb = { workspace = true } diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs new file mode 100644 index 0000000..1c2ffae --- /dev/null +++ b/compliance-core/src/config.rs @@ -0,0 +1,34 @@ +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug)] +pub struct AgentConfig { + pub mongodb_uri: String, + pub mongodb_database: String, + pub litellm_url: String, + pub litellm_api_key: SecretString, + pub litellm_model: String, + pub github_token: Option, + pub github_webhook_secret: Option, + pub gitlab_url: Option, + pub gitlab_token: Option, + pub gitlab_webhook_secret: Option, + pub jira_url: Option, + pub jira_email: Option, + pub jira_api_token: Option, + pub jira_project_key: Option, + pub searxng_url: Option, + pub nvd_api_key: Option, + pub agent_port: u16, + pub scan_schedule: String, + pub cve_monitor_schedule: String, + pub git_clone_base_path: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DashboardConfig { + pub mongodb_uri: String, + pub mongodb_database: String, + pub agent_api_url: String, + pub dashboard_port: u16, +} diff --git a/compliance-core/src/error.rs b/compliance-core/src/error.rs new file mode 100644 index 0000000..a6aba6b --- /dev/null +++ b/compliance-core/src/error.rs @@ -0,0 +1,41 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CoreError { + #[error("Database error: {0}")] + Database(#[from] mongodb::error::Error), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Git error: {0}")] + Git(String), + + #[error("Scanner error: {source}")] + Scanner { + scanner: String, + #[source] + source: Box, + }, + + #[error("LLM error: {0}")] + Llm(String), + + #[error("Issue tracker error: {0}")] + IssueTracker(String), + + #[error("HTTP error: {0}")] + Http(String), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("{0}")] + Other(String), +} diff --git a/compliance-core/src/lib.rs b/compliance-core/src/lib.rs new file mode 100644 index 0000000..d492d80 --- /dev/null +++ b/compliance-core/src/lib.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod error; +pub mod models; +pub mod traits; + +pub use config::{AgentConfig, DashboardConfig}; +pub use error::CoreError; diff --git a/compliance-core/src/models/cve.rs b/compliance-core/src/models/cve.rs new file mode 100644 index 0000000..2800613 --- /dev/null +++ b/compliance-core/src/models/cve.rs @@ -0,0 +1,46 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CveSource { + Osv, + Nvd, + SearXNG, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CveAlert { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + pub cve_id: String, + pub repo_id: String, + pub affected_package: String, + pub affected_version: String, + pub source: CveSource, + pub severity: Option, + pub cvss_score: Option, + pub summary: Option, + pub llm_impact_summary: Option, + pub references: Vec, + pub created_at: DateTime, +} + +impl CveAlert { + pub fn new(cve_id: String, repo_id: String, affected_package: String, affected_version: String, source: CveSource) -> Self { + Self { + id: None, + cve_id, + repo_id, + affected_package, + affected_version, + source, + severity: None, + cvss_score: None, + summary: None, + llm_impact_summary: None, + references: Vec::new(), + created_at: Utc::now(), + } + } +} diff --git a/compliance-core/src/models/finding.rs b/compliance-core/src/models/finding.rs new file mode 100644 index 0000000..ad8a365 --- /dev/null +++ b/compliance-core/src/models/finding.rs @@ -0,0 +1,115 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::scan::ScanType; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Info, + Low, + Medium, + High, + Critical, +} + +impl std::fmt::Display for Severity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Info => write!(f, "info"), + Self::Low => write!(f, "low"), + Self::Medium => write!(f, "medium"), + Self::High => write!(f, "high"), + Self::Critical => write!(f, "critical"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FindingStatus { + Open, + Triaged, + FalsePositive, + Resolved, + Ignored, +} + +impl std::fmt::Display for FindingStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Open => write!(f, "open"), + Self::Triaged => write!(f, "triaged"), + Self::FalsePositive => write!(f, "false_positive"), + Self::Resolved => write!(f, "resolved"), + Self::Ignored => write!(f, "ignored"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Finding { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + pub repo_id: String, + pub fingerprint: String, + pub scanner: String, + pub scan_type: ScanType, + pub rule_id: Option, + pub title: String, + pub description: String, + pub severity: Severity, + pub confidence: Option, + pub cwe: Option, + pub cve: Option, + pub cvss_score: Option, + pub file_path: Option, + pub line_number: Option, + pub code_snippet: Option, + pub remediation: Option, + pub suggested_fix: Option, + pub status: FindingStatus, + pub tracker_issue_url: Option, + pub scan_run_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Finding { + pub fn new( + repo_id: String, + fingerprint: String, + scanner: String, + scan_type: ScanType, + title: String, + description: String, + severity: Severity, + ) -> Self { + let now = Utc::now(); + Self { + id: None, + repo_id, + fingerprint, + scanner, + scan_type, + rule_id: None, + title, + description, + severity, + confidence: None, + cwe: None, + cve: None, + cvss_score: None, + file_path: None, + line_number: None, + code_snippet: None, + remediation: None, + suggested_fix: None, + status: FindingStatus::Open, + tracker_issue_url: None, + scan_run_id: None, + created_at: now, + updated_at: now, + } + } +} diff --git a/compliance-core/src/models/issue.rs b/compliance-core/src/models/issue.rs new file mode 100644 index 0000000..8b4e694 --- /dev/null +++ b/compliance-core/src/models/issue.rs @@ -0,0 +1,77 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum TrackerType { + GitHub, + GitLab, + Jira, +} + +impl std::fmt::Display for TrackerType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::GitHub => write!(f, "github"), + Self::GitLab => write!(f, "gitlab"), + Self::Jira => write!(f, "jira"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum IssueStatus { + Open, + InProgress, + Closed, + Resolved, +} + +impl std::fmt::Display for IssueStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Open => write!(f, "open"), + Self::InProgress => write!(f, "in_progress"), + Self::Closed => write!(f, "closed"), + Self::Resolved => write!(f, "resolved"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackerIssue { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + pub finding_id: String, + pub tracker_type: TrackerType, + pub external_id: String, + pub external_url: String, + pub title: String, + pub status: IssueStatus, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl TrackerIssue { + pub fn new( + finding_id: String, + tracker_type: TrackerType, + external_id: String, + external_url: String, + title: String, + ) -> Self { + let now = Utc::now(); + Self { + id: None, + finding_id, + tracker_type, + external_id, + external_url, + title, + status: IssueStatus::Open, + created_at: now, + updated_at: now, + } + } +} diff --git a/compliance-core/src/models/mod.rs b/compliance-core/src/models/mod.rs new file mode 100644 index 0000000..099b5bc --- /dev/null +++ b/compliance-core/src/models/mod.rs @@ -0,0 +1,13 @@ +pub mod cve; +pub mod finding; +pub mod issue; +pub mod repository; +pub mod sbom; +pub mod scan; + +pub use cve::{CveAlert, CveSource}; +pub use finding::{Finding, FindingStatus, Severity}; +pub use issue::{IssueStatus, TrackerIssue, TrackerType}; +pub use repository::{ScanTrigger, TrackedRepository}; +pub use sbom::{SbomEntry, VulnRef}; +pub use scan::{ScanPhase, ScanRun, ScanRunStatus, ScanType}; diff --git a/compliance-core/src/models/repository.rs b/compliance-core/src/models/repository.rs new file mode 100644 index 0000000..b9d815a --- /dev/null +++ b/compliance-core/src/models/repository.rs @@ -0,0 +1,53 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::issue::TrackerType; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScanTrigger { + Scheduled, + Webhook, + Manual, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackedRepository { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + pub name: String, + pub git_url: String, + pub default_branch: String, + pub local_path: Option, + pub scan_schedule: Option, + pub webhook_enabled: bool, + pub tracker_type: Option, + pub tracker_owner: Option, + pub tracker_repo: Option, + pub last_scanned_commit: Option, + pub findings_count: u32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl TrackedRepository { + pub fn new(name: String, git_url: String) -> Self { + let now = Utc::now(); + Self { + id: None, + name, + git_url, + default_branch: "main".to_string(), + local_path: None, + scan_schedule: None, + webhook_enabled: false, + tracker_type: None, + tracker_owner: None, + tracker_repo: None, + last_scanned_commit: None, + findings_count: 0, + created_at: now, + updated_at: now, + } + } +} diff --git a/compliance-core/src/models/sbom.rs b/compliance-core/src/models/sbom.rs new file mode 100644 index 0000000..ec6f371 --- /dev/null +++ b/compliance-core/src/models/sbom.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VulnRef { + pub id: String, + pub source: String, + pub severity: Option, + pub url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SbomEntry { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + pub repo_id: String, + pub name: String, + pub version: String, + pub package_manager: String, + pub license: Option, + pub purl: Option, + pub known_vulnerabilities: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl SbomEntry { + pub fn new(repo_id: String, name: String, version: String, package_manager: String) -> Self { + let now = Utc::now(); + Self { + id: None, + repo_id, + name, + version, + package_manager, + license: None, + purl: None, + known_vulnerabilities: Vec::new(), + created_at: now, + updated_at: now, + } + } +} diff --git a/compliance-core/src/models/scan.rs b/compliance-core/src/models/scan.rs new file mode 100644 index 0000000..99ddb74 --- /dev/null +++ b/compliance-core/src/models/scan.rs @@ -0,0 +1,81 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::repository::ScanTrigger; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ScanType { + Sast, + Sbom, + Cve, + Gdpr, + OAuth, +} + +impl std::fmt::Display for ScanType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Sast => write!(f, "sast"), + Self::Sbom => write!(f, "sbom"), + Self::Cve => write!(f, "cve"), + Self::Gdpr => write!(f, "gdpr"), + Self::OAuth => write!(f, "oauth"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScanRunStatus { + Running, + Completed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScanPhase { + ChangeDetection, + Sast, + SbomGeneration, + CveScanning, + PatternScanning, + LlmTriage, + IssueCreation, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanRun { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + pub repo_id: String, + pub trigger: ScanTrigger, + pub commit_sha: Option, + pub status: ScanRunStatus, + pub current_phase: ScanPhase, + pub phases_completed: Vec, + pub new_findings_count: u32, + pub error_message: Option, + pub started_at: DateTime, + pub completed_at: Option>, +} + +impl ScanRun { + pub fn new(repo_id: String, trigger: ScanTrigger) -> Self { + Self { + id: None, + repo_id, + trigger, + commit_sha: None, + status: ScanRunStatus::Running, + current_phase: ScanPhase::ChangeDetection, + phases_completed: Vec::new(), + new_findings_count: 0, + error_message: None, + started_at: Utc::now(), + completed_at: None, + } + } +} diff --git a/compliance-core/src/traits/issue_tracker.rs b/compliance-core/src/traits/issue_tracker.rs new file mode 100644 index 0000000..3c5acc0 --- /dev/null +++ b/compliance-core/src/traits/issue_tracker.rs @@ -0,0 +1,55 @@ +use crate::error::CoreError; +use crate::models::TrackerIssue; + +#[allow(async_fn_in_trait)] +pub trait IssueTracker: Send + Sync { + fn name(&self) -> &str; + + async fn create_issue( + &self, + owner: &str, + repo: &str, + title: &str, + body: &str, + labels: &[String], + ) -> Result; + + async fn update_issue_status( + &self, + owner: &str, + repo: &str, + external_id: &str, + status: &str, + ) -> Result<(), CoreError>; + + async fn add_comment( + &self, + owner: &str, + repo: &str, + external_id: &str, + body: &str, + ) -> Result<(), CoreError>; + + async fn create_pr_review( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + comments: Vec, + ) -> Result<(), CoreError>; + + async fn find_existing_issue( + &self, + owner: &str, + repo: &str, + fingerprint: &str, + ) -> Result, CoreError>; +} + +#[derive(Debug, Clone)] +pub struct ReviewComment { + pub path: String, + pub line: u32, + pub body: String, +} diff --git a/compliance-core/src/traits/mod.rs b/compliance-core/src/traits/mod.rs new file mode 100644 index 0000000..e2d1790 --- /dev/null +++ b/compliance-core/src/traits/mod.rs @@ -0,0 +1,5 @@ +pub mod issue_tracker; +pub mod scanner; + +pub use issue_tracker::IssueTracker; +pub use scanner::{ScanOutput, Scanner}; diff --git a/compliance-core/src/traits/scanner.rs b/compliance-core/src/traits/scanner.rs new file mode 100644 index 0000000..5adcf76 --- /dev/null +++ b/compliance-core/src/traits/scanner.rs @@ -0,0 +1,17 @@ +use std::path::Path; + +use crate::error::CoreError; +use crate::models::{Finding, SbomEntry, ScanType}; + +#[derive(Debug, Default)] +pub struct ScanOutput { + pub findings: Vec, + pub sbom_entries: Vec, +} + +#[allow(async_fn_in_trait)] +pub trait Scanner: Send + Sync { + fn name(&self) -> &str; + fn scan_type(&self) -> ScanType; + async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result; +} diff --git a/compliance-dashboard/Cargo.toml b/compliance-dashboard/Cargo.toml new file mode 100644 index 0000000..fcc48d6 --- /dev/null +++ b/compliance-dashboard/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "compliance-dashboard" +version = "0.1.0" +edition = "2021" +default-run = "compliance-dashboard" + +[[bin]] +name = "compliance-dashboard" +path = "../bin/main.rs" + +[lints] +workspace = true + +[features] +web = ["dioxus/web", "dioxus/router", "dioxus/fullstack", "dep:reqwest", "dep:web-sys"] +server = [ + "dioxus/server", + "dioxus/router", + "dioxus/fullstack", + "dep:axum", + "dep:mongodb", + "dep:reqwest", + "dep:tower-http", + "dep:secrecy", + "dep:dotenvy", + "dep:dioxus-cli-config", + "dep:dioxus-fullstack", + "dep:tokio", +] + +[dependencies] +compliance-core = { workspace = true } +dioxus = "=0.7.3" +dioxus-free-icons = { version = "0.10", features = ["bootstrap"] } +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +dioxus-logger = "0.6" +thiserror = { workspace = true } + +# Web-only +reqwest = { workspace = true, optional = true } +web-sys = { version = "0.3", optional = true } + +# Server-only +axum = { version = "0.8", optional = true } +mongodb = { workspace = true, optional = true } +tower-http = { version = "0.6", features = ["cors", "trace"], optional = true } +secrecy = { workspace = true, optional = true } +dotenvy = { version = "0.15", optional = true } +tokio = { workspace = true, optional = true } +dioxus-cli-config = { version = "=0.7.3", optional = true } +dioxus-fullstack = { version = "=0.7.3", optional = true } diff --git a/compliance-dashboard/assets/favicon.svg b/compliance-dashboard/assets/favicon.svg new file mode 100644 index 0000000..0ba70b3 --- /dev/null +++ b/compliance-dashboard/assets/favicon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css new file mode 100644 index 0000000..4bbdd35 --- /dev/null +++ b/compliance-dashboard/assets/main.css @@ -0,0 +1,315 @@ +:root { + --sidebar-width: 260px; + --header-height: 56px; + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-card: #1e293b; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --accent: #38bdf8; + --accent-hover: #7dd3fc; + --border: #334155; + --danger: #ef4444; + --warning: #f59e0b; + --success: #22c55e; + --info: #3b82f6; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; +} + +.app-shell { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 40; + overflow-y: auto; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 12px; +} + +.sidebar-header h1 { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.sidebar-nav { + padding: 12px 8px; + flex: 1; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + color: var(--text-secondary); + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: all 0.15s; + cursor: pointer; +} + +.nav-item:hover { + background: rgba(56, 189, 248, 0.1); + color: var(--text-primary); +} + +.nav-item.active { + background: rgba(56, 189, 248, 0.15); + color: var(--accent); +} + +.main-content { + margin-left: var(--sidebar-width); + flex: 1; + padding: 24px 32px; + min-height: 100vh; +} + +.page-header { + margin-bottom: 24px; +} + +.page-header h2 { + font-size: 24px; + font-weight: 700; +} + +.page-header p { + color: var(--text-secondary); + margin-top: 4px; +} + +.stat-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; +} + +.stat-card .label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.stat-card .value { + font-size: 28px; + font-weight: 700; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; +} + +.card-header { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.table-wrapper { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th { + text-align: left; + padding: 12px 16px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); + font-weight: 600; +} + +td { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 14px; +} + +tr:hover { + background: rgba(56, 189, 248, 0.05); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 9999px; + font-size: 12px; + font-weight: 600; +} + +.badge-critical { background: rgba(239, 68, 68, 0.2); color: #fca5a5; } +.badge-high { background: rgba(249, 115, 22, 0.2); color: #fdba74; } +.badge-medium { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } +.badge-low { background: rgba(34, 197, 94, 0.2); color: #86efac; } +.badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.15s; +} + +.btn-primary { + background: var(--accent); + color: #0f172a; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + color: var(--text-primary); + border-color: var(--text-secondary); +} + +.code-block { + background: #0d1117; + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 13px; + line-height: 1.6; + overflow-x: auto; + white-space: pre; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 16px; +} + +.filter-bar { + display: flex; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.filter-bar select, +.filter-bar input { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 12px; + color: var(--text-primary); + font-size: 14px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; + color: var(--text-secondary); +} + +.form-group input, +.form-group select { + width: 100%; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 14px; + color: var(--text-primary); + font-size: 14px; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--text-secondary); +} + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + transition: transform 0.3s; + } + .sidebar.open { + transform: translateX(0); + } + .main-content { + margin-left: 0; + padding: 16px; + } +} diff --git a/compliance-dashboard/assets/tailwind.css b/compliance-dashboard/assets/tailwind.css new file mode 100644 index 0000000..314f56a --- /dev/null +++ b/compliance-dashboard/assets/tailwind.css @@ -0,0 +1 @@ +/* Placeholder - generated by build.rs via bunx @tailwindcss/cli */ diff --git a/compliance-dashboard/src/app.rs b/compliance-dashboard/src/app.rs new file mode 100644 index 0000000..4f5252d --- /dev/null +++ b/compliance-dashboard/src/app.rs @@ -0,0 +1,38 @@ +use dioxus::prelude::*; + +use crate::components::app_shell::AppShell; +use crate::pages::*; + +#[derive(Debug, Clone, Routable, PartialEq)] +#[rustfmt::skip] +pub enum Route { + #[layout(AppShell)] + #[route("/")] + OverviewPage {}, + #[route("/repositories")] + RepositoriesPage {}, + #[route("/findings")] + FindingsPage {}, + #[route("/findings/:id")] + FindingDetailPage { id: String }, + #[route("/sbom")] + SbomPage {}, + #[route("/issues")] + IssuesPage {}, + #[route("/settings")] + SettingsPage {}, +} + +const FAVICON: Asset = asset!("/assets/favicon.svg"); +const MAIN_CSS: Asset = asset!("/assets/main.css"); +const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); + +#[component] +pub fn App() -> Element { + rsx! { + document::Link { rel: "icon", href: FAVICON } + document::Link { rel: "stylesheet", href: TAILWIND_CSS } + document::Link { rel: "stylesheet", href: MAIN_CSS } + Router:: {} + } +} diff --git a/compliance-dashboard/src/components/app_shell.rs b/compliance-dashboard/src/components/app_shell.rs new file mode 100644 index 0000000..c982f49 --- /dev/null +++ b/compliance-dashboard/src/components/app_shell.rs @@ -0,0 +1,16 @@ +use dioxus::prelude::*; + +use crate::app::Route; +use crate::components::sidebar::Sidebar; + +#[component] +pub fn AppShell() -> Element { + rsx! { + div { class: "app-shell", + Sidebar {} + main { class: "main-content", + Outlet:: {} + } + } + } +} diff --git a/compliance-dashboard/src/components/code_snippet.rs b/compliance-dashboard/src/components/code_snippet.rs new file mode 100644 index 0000000..b16f115 --- /dev/null +++ b/compliance-dashboard/src/components/code_snippet.rs @@ -0,0 +1,23 @@ +use dioxus::prelude::*; + +#[component] +pub fn CodeSnippet( + code: String, + #[props(default)] file_path: String, + #[props(default)] line_number: u32, +) -> Element { + rsx! { + div { + if !file_path.is_empty() { + div { + style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; font-family: monospace;", + "{file_path}" + if line_number > 0 { + ":{line_number}" + } + } + } + pre { class: "code-block", "{code}" } + } + } +} diff --git a/compliance-dashboard/src/components/mod.rs b/compliance-dashboard/src/components/mod.rs new file mode 100644 index 0000000..aebf095 --- /dev/null +++ b/compliance-dashboard/src/components/mod.rs @@ -0,0 +1,7 @@ +pub mod app_shell; +pub mod code_snippet; +pub mod page_header; +pub mod pagination; +pub mod severity_badge; +pub mod sidebar; +pub mod stat_card; diff --git a/compliance-dashboard/src/components/page_header.rs b/compliance-dashboard/src/components/page_header.rs new file mode 100644 index 0000000..db6b04d --- /dev/null +++ b/compliance-dashboard/src/components/page_header.rs @@ -0,0 +1,13 @@ +use dioxus::prelude::*; + +#[component] +pub fn PageHeader(title: String, #[props(default)] description: String) -> Element { + rsx! { + div { class: "page-header", + h2 { "{title}" } + if !description.is_empty() { + p { "{description}" } + } + } + } +} diff --git a/compliance-dashboard/src/components/pagination.rs b/compliance-dashboard/src/components/pagination.rs new file mode 100644 index 0000000..24c9710 --- /dev/null +++ b/compliance-dashboard/src/components/pagination.rs @@ -0,0 +1,33 @@ +use dioxus::prelude::*; + +#[component] +pub fn Pagination( + current_page: u64, + total_pages: u64, + on_page_change: EventHandler, +) -> Element { + if total_pages <= 1 { + return rsx! {}; + } + + rsx! { + div { class: "pagination", + button { + class: "btn btn-ghost", + disabled: current_page <= 1, + onclick: move |_| on_page_change.call(current_page.saturating_sub(1)), + "Previous" + } + span { + style: "color: var(--text-secondary); font-size: 14px;", + "Page {current_page} of {total_pages}" + } + button { + class: "btn btn-ghost", + disabled: current_page >= total_pages, + onclick: move |_| on_page_change.call(current_page + 1), + "Next" + } + } + } +} diff --git a/compliance-dashboard/src/components/severity_badge.rs b/compliance-dashboard/src/components/severity_badge.rs new file mode 100644 index 0000000..7452f24 --- /dev/null +++ b/compliance-dashboard/src/components/severity_badge.rs @@ -0,0 +1,16 @@ +use dioxus::prelude::*; + +#[component] +pub fn SeverityBadge(severity: String) -> Element { + let class = match severity.to_lowercase().as_str() { + "critical" => "badge badge-critical", + "high" => "badge badge-high", + "medium" => "badge badge-medium", + "low" => "badge badge-low", + _ => "badge badge-info", + }; + + rsx! { + span { class: class, "{severity}" } + } +} diff --git a/compliance-dashboard/src/components/sidebar.rs b/compliance-dashboard/src/components/sidebar.rs new file mode 100644 index 0000000..b183845 --- /dev/null +++ b/compliance-dashboard/src/components/sidebar.rs @@ -0,0 +1,81 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; + +use crate::app::Route; + +struct NavItem { + label: &'static str, + route: Route, + icon: Element, +} + +#[component] +pub fn Sidebar() -> Element { + let current_route = use_route::(); + + let nav_items = [ + NavItem { + label: "Overview", + route: Route::OverviewPage {}, + icon: rsx! { Icon { icon: BsSpeedometer2, width: 18, height: 18 } }, + }, + NavItem { + label: "Repositories", + route: Route::RepositoriesPage {}, + icon: rsx! { Icon { icon: BsFolder2Open, width: 18, height: 18 } }, + }, + NavItem { + label: "Findings", + route: Route::FindingsPage {}, + icon: rsx! { Icon { icon: BsShieldExclamation, width: 18, height: 18 } }, + }, + NavItem { + label: "SBOM", + route: Route::SbomPage {}, + icon: rsx! { Icon { icon: BsBoxSeam, width: 18, height: 18 } }, + }, + NavItem { + label: "Issues", + route: Route::IssuesPage {}, + icon: rsx! { Icon { icon: BsListTask, width: 18, height: 18 } }, + }, + NavItem { + label: "Settings", + route: Route::SettingsPage {}, + icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } }, + }, + ]; + + rsx! { + nav { class: "sidebar", + div { class: "sidebar-header", + Icon { icon: BsShieldCheck, width: 24, height: 24 } + h1 { "Compliance Scanner" } + } + div { class: "sidebar-nav", + for item in nav_items { + { + let is_active = match (¤t_route, &item.route) { + (Route::FindingDetailPage { .. }, Route::FindingsPage {}) => true, + (a, b) => a == b, + }; + let class = if is_active { "nav-item active" } else { "nav-item" }; + rsx! { + Link { + to: item.route.clone(), + class: class, + {item.icon} + span { "{item.label}" } + } + } + } + } + } + div { + style: "padding: 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-secondary);", + "v0.1.0" + } + } + } +} diff --git a/compliance-dashboard/src/components/stat_card.rs b/compliance-dashboard/src/components/stat_card.rs new file mode 100644 index 0000000..ee55c26 --- /dev/null +++ b/compliance-dashboard/src/components/stat_card.rs @@ -0,0 +1,21 @@ +use dioxus::prelude::*; + +#[component] +pub fn StatCard( + label: String, + value: String, + #[props(default)] color: String, +) -> Element { + let value_style = if color.is_empty() { + String::new() + } else { + format!("color: {color}") + }; + + rsx! { + div { class: "stat-card", + div { class: "label", "{label}" } + div { class: "value", style: value_style, "{value}" } + } + } +} diff --git a/compliance-dashboard/src/infrastructure/config.rs b/compliance-dashboard/src/infrastructure/config.rs new file mode 100644 index 0000000..953d931 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/config.rs @@ -0,0 +1,18 @@ +use compliance_core::DashboardConfig; + +use super::error::DashboardError; + +pub fn load_config() -> Result { + Ok(DashboardConfig { + mongodb_uri: std::env::var("MONGODB_URI") + .map_err(|_| DashboardError::Config("Missing MONGODB_URI".to_string()))?, + mongodb_database: std::env::var("MONGODB_DATABASE") + .unwrap_or_else(|_| "compliance_scanner".to_string()), + agent_api_url: std::env::var("AGENT_API_URL") + .unwrap_or_else(|_| "http://localhost:3001".to_string()), + dashboard_port: std::env::var("DASHBOARD_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8080), + }) +} diff --git a/compliance-dashboard/src/infrastructure/database.rs b/compliance-dashboard/src/infrastructure/database.rs new file mode 100644 index 0000000..8bcfa26 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/database.rs @@ -0,0 +1,45 @@ +use mongodb::bson::doc; +use mongodb::{Client, Collection}; + +use compliance_core::models::*; + +use super::error::DashboardError; + +#[derive(Clone, Debug)] +pub struct Database { + inner: mongodb::Database, +} + +impl Database { + pub async fn connect(uri: &str, db_name: &str) -> Result { + let client = Client::with_uri_str(uri).await?; + let db = client.database(db_name); + db.run_command(doc! { "ping": 1 }).await?; + tracing::info!("Dashboard connected to MongoDB '{db_name}'"); + Ok(Self { inner: db }) + } + + pub fn repositories(&self) -> Collection { + self.inner.collection("repositories") + } + + pub fn findings(&self) -> Collection { + self.inner.collection("findings") + } + + pub fn scan_runs(&self) -> Collection { + self.inner.collection("scan_runs") + } + + pub fn sbom_entries(&self) -> Collection { + self.inner.collection("sbom_entries") + } + + pub fn cve_alerts(&self) -> Collection { + self.inner.collection("cve_alerts") + } + + pub fn tracker_issues(&self) -> Collection { + self.inner.collection("tracker_issues") + } +} diff --git a/compliance-dashboard/src/infrastructure/error.rs b/compliance-dashboard/src/infrastructure/error.rs new file mode 100644 index 0000000..8b1c15d --- /dev/null +++ b/compliance-dashboard/src/infrastructure/error.rs @@ -0,0 +1,26 @@ +use dioxus::prelude::*; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DashboardError { + #[error("Database error: {0}")] + Database(#[from] mongodb::error::Error), + + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("{0}")] + Other(String), +} + +impl From for ServerFnError { + fn from(err: DashboardError) -> Self { + ServerFnError::new(err.to_string()) + } +} diff --git a/compliance-dashboard/src/infrastructure/findings.rs b/compliance-dashboard/src/infrastructure/findings.rs new file mode 100644 index 0000000..2373a1f --- /dev/null +++ b/compliance-dashboard/src/infrastructure/findings.rs @@ -0,0 +1,71 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use compliance_core::models::Finding; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FindingsListResponse { + pub data: Vec, + pub total: Option, + pub page: Option, +} + +#[server] +pub async fn fetch_findings( + page: u64, + severity: String, + scan_type: String, + status: String, + repo_id: String, +) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let mut url = format!("{}/api/v1/findings?page={page}&limit=20", state.agent_api_url); + if !severity.is_empty() { + url.push_str(&format!("&severity={severity}")); + } + if !scan_type.is_empty() { + url.push_str(&format!("&scan_type={scan_type}")); + } + if !status.is_empty() { + url.push_str(&format!("&status={status}")); + } + if !repo_id.is_empty() { + url.push_str(&format!("&repo_id={repo_id}")); + } + + let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?; + let body: FindingsListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} + +#[server] +pub async fn fetch_finding_detail(id: String) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/findings/{id}", state.agent_api_url); + + let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?; + let body: serde_json::Value = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?; + let finding: Finding = serde_json::from_value(body["data"].clone()) + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(finding) +} + +#[server] +pub async fn update_finding_status(id: String, status: String) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/findings/{id}/status", state.agent_api_url); + + let client = reqwest::Client::new(); + client + .patch(&url) + .json(&serde_json::json!({ "status": status })) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(()) +} diff --git a/compliance-dashboard/src/infrastructure/issues.rs b/compliance-dashboard/src/infrastructure/issues.rs new file mode 100644 index 0000000..9195868 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/issues.rs @@ -0,0 +1,22 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use compliance_core::models::TrackerIssue; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IssuesListResponse { + pub data: Vec, + pub total: Option, + pub page: Option, +} + +#[server] +pub async fn fetch_issues(page: u64) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/issues?page={page}&limit=20", state.agent_api_url); + + let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?; + let body: IssuesListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} diff --git a/compliance-dashboard/src/infrastructure/mod.rs b/compliance-dashboard/src/infrastructure/mod.rs new file mode 100644 index 0000000..762ff29 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/mod.rs @@ -0,0 +1,13 @@ +pub mod config; +pub mod database; +pub mod error; +pub mod findings; +pub mod issues; +pub mod repositories; +pub mod sbom; +pub mod scans; +pub mod server; +pub mod server_state; +pub mod stats; + +pub use server::server_start; diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs new file mode 100644 index 0000000..a4a1278 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -0,0 +1,64 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use compliance_core::models::TrackedRepository; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RepositoryListResponse { + pub data: Vec, + pub total: Option, + pub page: Option, +} + +#[server] +pub async fn fetch_repositories(page: u64) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/repositories?page={page}&limit=20", state.agent_api_url); + + let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?; + let body: RepositoryListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} + +#[server] +pub async fn add_repository(name: String, git_url: String, default_branch: String) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/repositories", state.agent_api_url); + + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .json(&serde_json::json!({ + "name": name, + "git_url": git_url, + "default_branch": default_branch, + })) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(ServerFnError::new(format!("Failed to add repository: {body}"))); + } + + Ok(()) +} + +#[server] +pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/repositories/{repo_id}/scan", state.agent_api_url); + + let client = reqwest::Client::new(); + client + .post(&url) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(()) +} diff --git a/compliance-dashboard/src/infrastructure/sbom.rs b/compliance-dashboard/src/infrastructure/sbom.rs new file mode 100644 index 0000000..566037f --- /dev/null +++ b/compliance-dashboard/src/infrastructure/sbom.rs @@ -0,0 +1,22 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use compliance_core::models::SbomEntry; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomListResponse { + pub data: Vec, + pub total: Option, + pub page: Option, +} + +#[server] +pub async fn fetch_sbom(page: u64) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/sbom?page={page}&limit=50", state.agent_api_url); + + let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?; + let body: SbomListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} diff --git a/compliance-dashboard/src/infrastructure/scans.rs b/compliance-dashboard/src/infrastructure/scans.rs new file mode 100644 index 0000000..5ca51ab --- /dev/null +++ b/compliance-dashboard/src/infrastructure/scans.rs @@ -0,0 +1,22 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use compliance_core::models::ScanRun; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ScansListResponse { + pub data: Vec, + pub total: Option, + pub page: Option, +} + +#[server] +pub async fn fetch_scan_runs(page: u64) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/scan-runs?page={page}&limit=20", state.agent_api_url); + + let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?; + let body: ScansListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} diff --git a/compliance-dashboard/src/infrastructure/server.rs b/compliance-dashboard/src/infrastructure/server.rs new file mode 100644 index 0000000..0df8a46 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/server.rs @@ -0,0 +1,41 @@ +use dioxus::prelude::*; + +use super::config; +use super::database::Database; +use super::error::DashboardError; +use super::server_state::{ServerState, ServerStateInner}; + +pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> { + tokio::runtime::Runtime::new() + .map_err(|e| DashboardError::Other(e.to_string()))? + .block_on(async move { + dotenvy::dotenv().ok(); + + let config = config::load_config()?; + let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?; + + let server_state: ServerState = ServerStateInner { + agent_api_url: config.agent_api_url.clone(), + db, + config, + } + .into(); + + let addr = dioxus_cli_config::fullstack_address_or_localhost(); + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| DashboardError::Other(format!("Failed to bind: {e}")))?; + + tracing::info!("Dashboard server listening on {addr}"); + + let router = axum::Router::new() + .serve_dioxus_application(ServeConfig::new(), app) + .layer(axum::Extension(server_state)); + + axum::serve(listener, router.into_make_service()) + .await + .map_err(|e| DashboardError::Other(format!("Server error: {e}")))?; + + Ok(()) + }) +} diff --git a/compliance-dashboard/src/infrastructure/server_state.rs b/compliance-dashboard/src/infrastructure/server_state.rs new file mode 100644 index 0000000..9f6cec2 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/server_state.rs @@ -0,0 +1,46 @@ +use std::ops::Deref; +use std::sync::Arc; + +use compliance_core::DashboardConfig; + +use super::database::Database; + +#[derive(Clone)] +pub struct ServerState(Arc); + +impl Deref for ServerState { + type Target = ServerStateInner; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub struct ServerStateInner { + pub db: Database, + pub config: DashboardConfig, + pub agent_api_url: String, +} + +impl From for ServerState { + fn from(inner: ServerStateInner) -> Self { + Self(Arc::new(inner)) + } +} + +impl axum::extract::FromRequestParts for ServerState +where + S: Send + Sync, +{ + type Rejection = axum::http::StatusCode; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result { + parts + .extensions + .get::() + .cloned() + .ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + } +} diff --git a/compliance-dashboard/src/infrastructure/stats.rs b/compliance-dashboard/src/infrastructure/stats.rs new file mode 100644 index 0000000..7976051 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/stats.rs @@ -0,0 +1,27 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OverviewStats { + pub total_repositories: u64, + pub total_findings: u64, + pub critical_findings: u64, + pub high_findings: u64, + pub medium_findings: u64, + pub low_findings: u64, + pub total_sbom_entries: u64, + pub total_cve_alerts: u64, + pub total_issues: u64, +} + +#[server] +pub async fn fetch_overview_stats() -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!("{}/api/v1/stats/overview", state.agent_api_url); + + let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?; + let body: serde_json::Value = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?; + let stats: OverviewStats = serde_json::from_value(body["data"].clone()).unwrap_or_default(); + Ok(stats) +} diff --git a/compliance-dashboard/src/lib.rs b/compliance-dashboard/src/lib.rs new file mode 100644 index 0000000..2a61e0f --- /dev/null +++ b/compliance-dashboard/src/lib.rs @@ -0,0 +1,8 @@ +pub mod app; +pub mod components; +pub mod pages; + +#[cfg(feature = "server")] +pub mod infrastructure; + +pub use app::App; diff --git a/compliance-dashboard/src/pages/finding_detail.rs b/compliance-dashboard/src/pages/finding_detail.rs new file mode 100644 index 0000000..050a4ff --- /dev/null +++ b/compliance-dashboard/src/pages/finding_detail.rs @@ -0,0 +1,117 @@ +use dioxus::prelude::*; + +use crate::components::code_snippet::CodeSnippet; +use crate::components::page_header::PageHeader; +use crate::components::severity_badge::SeverityBadge; + +#[component] +pub fn FindingDetailPage(id: String) -> Element { + let finding_id = id.clone(); + + let finding = use_resource(move || { + let fid = finding_id.clone(); + async move { + crate::infrastructure::findings::fetch_finding_detail(fid).await.ok() + } + }); + + let snapshot = finding.read().clone(); + + match snapshot { + Some(Some(f)) => { + let finding_id_for_status = id.clone(); + rsx! { + PageHeader { + title: f.title.clone(), + description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status), + } + + div { style: "display: flex; gap: 8px; margin-bottom: 16px;", + SeverityBadge { severity: f.severity.to_string() } + if let Some(cwe) = &f.cwe { + span { class: "badge badge-info", "{cwe}" } + } + if let Some(cve) = &f.cve { + span { class: "badge badge-high", "{cve}" } + } + if let Some(score) = f.cvss_score { + span { class: "badge badge-medium", "CVSS: {score}" } + } + } + + div { class: "card", + div { class: "card-header", "Description" } + p { style: "line-height: 1.6;", "{f.description}" } + } + + if let Some(code) = &f.code_snippet { + div { class: "card", + div { class: "card-header", "Code Evidence" } + CodeSnippet { + code: code.clone(), + file_path: f.file_path.clone().unwrap_or_default(), + line_number: f.line_number.unwrap_or(0), + } + } + } + + if let Some(remediation) = &f.remediation { + div { class: "card", + div { class: "card-header", "Remediation" } + p { style: "line-height: 1.6;", "{remediation}" } + } + } + + if let Some(fix) = &f.suggested_fix { + div { class: "card", + div { class: "card-header", "Suggested Fix" } + CodeSnippet { code: fix.clone() } + } + } + + if let Some(url) = &f.tracker_issue_url { + div { class: "card", + div { class: "card-header", "Linked Issue" } + a { + href: "{url}", + target: "_blank", + style: "color: var(--accent);", + "{url}" + } + } + } + + div { class: "card", + div { class: "card-header", "Update Status" } + div { style: "display: flex; gap: 8px;", + for status in ["open", "triaged", "resolved", "false_positive", "ignored"] { + { + let status_str = status.to_string(); + let id_clone = finding_id_for_status.clone(); + rsx! { + button { + class: "btn btn-ghost", + onclick: move |_| { + let s = status_str.clone(); + let id = id_clone.clone(); + spawn(async move { + let _ = crate::infrastructure::findings::update_finding_status(id, s).await; + }); + }, + "{status}" + } + } + } + } + } + } + } + }, + Some(None) => rsx! { + div { class: "card", p { "Finding not found." } } + }, + None => rsx! { + div { class: "loading", "Loading finding..." } + }, + } +} diff --git a/compliance-dashboard/src/pages/findings.rs b/compliance-dashboard/src/pages/findings.rs new file mode 100644 index 0000000..66f2162 --- /dev/null +++ b/compliance-dashboard/src/pages/findings.rs @@ -0,0 +1,124 @@ +use dioxus::prelude::*; + +use crate::app::Route; +use crate::components::page_header::PageHeader; +use crate::components::pagination::Pagination; +use crate::components::severity_badge::SeverityBadge; + +#[component] +pub fn FindingsPage() -> Element { + let mut page = use_signal(|| 1u64); + let mut severity_filter = use_signal(String::new); + let mut type_filter = use_signal(String::new); + let mut status_filter = use_signal(String::new); + + let findings = use_resource(move || { + let p = page(); + let sev = severity_filter(); + let typ = type_filter(); + let stat = status_filter(); + async move { + crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, String::new()).await.ok() + } + }); + + rsx! { + PageHeader { + title: "Findings", + description: "Security and compliance findings across all repositories", + } + + div { class: "filter-bar", + select { + onchange: move |e| { severity_filter.set(e.value()); page.set(1); }, + option { value: "", "All Severities" } + option { value: "critical", "Critical" } + option { value: "high", "High" } + option { value: "medium", "Medium" } + option { value: "low", "Low" } + option { value: "info", "Info" } + } + select { + onchange: move |e| { type_filter.set(e.value()); page.set(1); }, + option { value: "", "All Types" } + option { value: "sast", "SAST" } + option { value: "sbom", "SBOM" } + option { value: "cve", "CVE" } + option { value: "gdpr", "GDPR" } + option { value: "oauth", "OAuth" } + } + select { + onchange: move |e| { status_filter.set(e.value()); page.set(1); }, + option { value: "", "All Statuses" } + option { value: "open", "Open" } + option { value: "triaged", "Triaged" } + option { value: "resolved", "Resolved" } + option { value: "false_positive", "False Positive" } + option { value: "ignored", "Ignored" } + } + } + + match &*findings.read() { + Some(Some(resp)) => { + let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); + rsx! { + div { class: "card", + div { class: "table-wrapper", + table { + thead { + tr { + th { "Severity" } + th { "Title" } + th { "Type" } + th { "Scanner" } + th { "File" } + th { "Status" } + } + } + tbody { + for finding in &resp.data { + { + let id = finding.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); + rsx! { + tr { + td { SeverityBadge { severity: finding.severity.to_string() } } + td { + Link { + to: Route::FindingDetailPage { id: id }, + style: "color: var(--accent); text-decoration: none;", + "{finding.title}" + } + } + td { "{finding.scan_type}" } + td { "{finding.scanner}" } + td { + style: "font-family: monospace; font-size: 12px;", + "{finding.file_path.as_deref().unwrap_or(\"-\")}" + } + td { + span { class: "badge badge-info", "{finding.status}" } + } + } + } + } + } + } + } + } + Pagination { + current_page: page(), + total_pages: total_pages, + on_page_change: move |p| page.set(p), + } + } + } + }, + Some(None) => rsx! { + div { class: "card", p { "Failed to load findings." } } + }, + None => rsx! { + div { class: "loading", "Loading findings..." } + }, + } + } +} diff --git a/compliance-dashboard/src/pages/issues.rs b/compliance-dashboard/src/pages/issues.rs new file mode 100644 index 0000000..b69de92 --- /dev/null +++ b/compliance-dashboard/src/pages/issues.rs @@ -0,0 +1,87 @@ +use dioxus::prelude::*; + +use crate::components::page_header::PageHeader; +use crate::components::pagination::Pagination; + +#[component] +pub fn IssuesPage() -> Element { + let mut page = use_signal(|| 1u64); + + let issues = use_resource(move || { + let p = page(); + async move { + crate::infrastructure::issues::fetch_issues(p).await.ok() + } + }); + + rsx! { + PageHeader { + title: "Issues", + description: "Cross-tracker issue view - GitHub, GitLab, and Jira", + } + + match &*issues.read() { + Some(Some(resp)) => { + let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); + rsx! { + div { class: "card", + div { class: "table-wrapper", + table { + thead { + tr { + th { "Tracker" } + th { "ID" } + th { "Title" } + th { "Status" } + th { "Created" } + th { "Link" } + } + } + tbody { + for issue in &resp.data { + tr { + td { + span { class: "badge badge-info", "{issue.tracker_type}" } + } + td { + style: "font-family: monospace;", + "{issue.external_id}" + } + td { "{issue.title}" } + td { + span { class: "badge badge-info", "{issue.status}" } + } + td { + style: "font-size: 12px; color: var(--text-secondary);", + {issue.created_at.format("%Y-%m-%d %H:%M").to_string()} + } + td { + a { + href: "{issue.external_url}", + target: "_blank", + style: "color: var(--accent); text-decoration: none;", + "Open" + } + } + } + } + } + } + } + Pagination { + current_page: page(), + total_pages: total_pages, + on_page_change: move |p| page.set(p), + } + } + } + }, + Some(None) => rsx! { + div { class: "card", p { "Failed to load issues." } } + }, + None => rsx! { + div { class: "loading", "Loading issues..." } + }, + } + } +} diff --git a/compliance-dashboard/src/pages/mod.rs b/compliance-dashboard/src/pages/mod.rs new file mode 100644 index 0000000..cfed572 --- /dev/null +++ b/compliance-dashboard/src/pages/mod.rs @@ -0,0 +1,15 @@ +pub mod finding_detail; +pub mod findings; +pub mod issues; +pub mod overview; +pub mod repositories; +pub mod sbom; +pub mod settings; + +pub use finding_detail::FindingDetailPage; +pub use findings::FindingsPage; +pub use issues::IssuesPage; +pub use overview::OverviewPage; +pub use repositories::RepositoriesPage; +pub use sbom::SbomPage; +pub use settings::SettingsPage; diff --git a/compliance-dashboard/src/pages/overview.rs b/compliance-dashboard/src/pages/overview.rs new file mode 100644 index 0000000..2c3bda8 --- /dev/null +++ b/compliance-dashboard/src/pages/overview.rs @@ -0,0 +1,104 @@ +use dioxus::prelude::*; + +use crate::components::page_header::PageHeader; +use crate::components::stat_card::StatCard; + +#[cfg(feature = "server")] +use crate::infrastructure::stats::fetch_overview_stats; + +#[component] +pub fn OverviewPage() -> Element { + let stats = use_resource(move || async move { + #[cfg(feature = "server")] + { + fetch_overview_stats().await.ok() + } + #[cfg(not(feature = "server"))] + { + crate::infrastructure::stats::fetch_overview_stats().await.ok() + } + }); + + rsx! { + PageHeader { + title: "Overview", + description: "Security and compliance scanning dashboard", + } + + match &*stats.read() { + Some(Some(s)) => rsx! { + div { class: "stat-cards", + StatCard { label: "Repositories", value: s.total_repositories.to_string() } + StatCard { label: "Total Findings", value: s.total_findings.to_string() } + StatCard { + label: "Critical", + value: s.critical_findings.to_string(), + color: "var(--danger)", + } + StatCard { + label: "High", + value: s.high_findings.to_string(), + color: "#f97316", + } + StatCard { + label: "Medium", + value: s.medium_findings.to_string(), + color: "var(--warning)", + } + StatCard { + label: "Low", + value: s.low_findings.to_string(), + color: "var(--success)", + } + StatCard { label: "Dependencies", value: s.total_sbom_entries.to_string() } + StatCard { label: "CVE Alerts", value: s.total_cve_alerts.to_string() } + StatCard { label: "Tracker Issues", value: s.total_issues.to_string() } + } + + div { class: "card", + div { class: "card-header", "Severity Distribution" } + div { + style: "display: flex; gap: 8px; align-items: flex-end; height: 200px; padding: 16px;", + SeverityBar { label: "Critical", count: s.critical_findings, max: s.total_findings, color: "var(--danger)" } + SeverityBar { label: "High", count: s.high_findings, max: s.total_findings, color: "#f97316" } + SeverityBar { label: "Medium", count: s.medium_findings, max: s.total_findings, color: "var(--warning)" } + SeverityBar { label: "Low", count: s.low_findings, max: s.total_findings, color: "var(--success)" } + } + } + }, + Some(None) => rsx! { + div { class: "card", + p { style: "color: var(--text-secondary);", + "Unable to load stats. Make sure the agent API is running." + } + } + }, + None => rsx! { + div { class: "loading", "Loading overview..." } + }, + } + } +} + +#[component] +fn SeverityBar(label: String, count: u64, max: u64, color: String) -> Element { + let height_pct = if max > 0 { (count as f64 / max as f64) * 100.0 } else { 0.0 }; + let height = format!("{}%", height_pct.max(2.0)); + + rsx! { + div { + style: "flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px;", + div { + style: "font-size: 14px; font-weight: 600;", + "{count}" + } + div { + style: "width: 100%; background: {color}; border-radius: 4px 4px 0 0; height: {height}; min-height: 4px; transition: height 0.3s;", + } + div { + style: "font-size: 11px; color: var(--text-secondary);", + "{label}" + } + } + } +} diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs new file mode 100644 index 0000000..e502b30 --- /dev/null +++ b/compliance-dashboard/src/pages/repositories.rs @@ -0,0 +1,155 @@ +use dioxus::prelude::*; + +use crate::components::page_header::PageHeader; +use crate::components::pagination::Pagination; + +#[component] +pub fn RepositoriesPage() -> Element { + let mut page = use_signal(|| 1u64); + let mut show_add_form = use_signal(|| false); + let mut name = use_signal(String::new); + let mut git_url = use_signal(String::new); + let mut branch = use_signal(|| "main".to_string()); + + let repos = use_resource(move || { + let p = page(); + async move { + crate::infrastructure::repositories::fetch_repositories(p).await.ok() + } + }); + + rsx! { + PageHeader { + title: "Repositories", + description: "Tracked git repositories", + } + + div { style: "margin-bottom: 16px;", + button { + class: "btn btn-primary", + onclick: move |_| show_add_form.toggle(), + if show_add_form() { "Cancel" } else { "+ Add Repository" } + } + } + + if show_add_form() { + div { class: "card", + div { class: "card-header", "Add Repository" } + div { class: "form-group", + label { "Name" } + input { + r#type: "text", + placeholder: "my-project", + value: "{name}", + oninput: move |e| name.set(e.value()), + } + } + div { class: "form-group", + label { "Git URL" } + input { + r#type: "text", + placeholder: "https://github.com/org/repo.git", + value: "{git_url}", + oninput: move |e| git_url.set(e.value()), + } + } + div { class: "form-group", + label { "Default Branch" } + input { + r#type: "text", + placeholder: "main", + value: "{branch}", + oninput: move |e| branch.set(e.value()), + } + } + button { + class: "btn btn-primary", + onclick: move |_| { + let n = name(); + let u = git_url(); + let b = branch(); + spawn(async move { + let _ = crate::infrastructure::repositories::add_repository(n, u, b).await; + }); + show_add_form.set(false); + name.set(String::new()); + git_url.set(String::new()); + }, + "Add" + } + } + } + + match &*repos.read() { + Some(Some(resp)) => { + let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); + rsx! { + div { class: "card", + div { class: "table-wrapper", + table { + thead { + tr { + th { "Name" } + th { "Git URL" } + th { "Branch" } + th { "Findings" } + th { "Last Scanned" } + th { "Actions" } + } + } + tbody { + for repo in &resp.data { + { + let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); + let repo_id_clone = repo_id.clone(); + rsx! { + tr { + td { "{repo.name}" } + td { + style: "font-size: 12px; font-family: monospace;", + "{repo.git_url}" + } + td { "{repo.default_branch}" } + td { "{repo.findings_count}" } + td { + match &repo.last_scanned_commit { + Some(sha) => rsx! { span { style: "font-family: monospace; font-size: 12px;", "{&sha[..7.min(sha.len())]}" } }, + None => rsx! { span { style: "color: var(--text-secondary);", "Never" } }, + } + } + td { + button { + class: "btn btn-ghost", + onclick: move |_| { + let id = repo_id_clone.clone(); + spawn(async move { + let _ = crate::infrastructure::repositories::trigger_repo_scan(id).await; + }); + }, + "Scan" + } + } + } + } + } + } + } + } + } + Pagination { + current_page: page(), + total_pages: total_pages, + on_page_change: move |p| page.set(p), + } + } + } + }, + Some(None) => rsx! { + div { class: "card", p { "Failed to load repositories." } } + }, + None => rsx! { + div { class: "loading", "Loading repositories..." } + }, + } + } +} diff --git a/compliance-dashboard/src/pages/sbom.rs b/compliance-dashboard/src/pages/sbom.rs new file mode 100644 index 0000000..9bd085a --- /dev/null +++ b/compliance-dashboard/src/pages/sbom.rs @@ -0,0 +1,85 @@ +use dioxus::prelude::*; + +use crate::components::page_header::PageHeader; +use crate::components::pagination::Pagination; + +#[component] +pub fn SbomPage() -> Element { + let mut page = use_signal(|| 1u64); + + let sbom = use_resource(move || { + let p = page(); + async move { + crate::infrastructure::sbom::fetch_sbom(p).await.ok() + } + }); + + rsx! { + PageHeader { + title: "SBOM", + description: "Software Bill of Materials - dependency inventory across all repositories", + } + + match &*sbom.read() { + Some(Some(resp)) => { + let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1); + rsx! { + div { class: "card", + div { class: "table-wrapper", + table { + thead { + tr { + th { "Package" } + th { "Version" } + th { "Manager" } + th { "License" } + th { "Vulnerabilities" } + } + } + tbody { + for entry in &resp.data { + tr { + td { + style: "font-weight: 500;", + "{entry.name}" + } + td { + style: "font-family: monospace; font-size: 13px;", + "{entry.version}" + } + td { "{entry.package_manager}" } + td { "{entry.license.as_deref().unwrap_or(\"-\")}" } + td { + if entry.known_vulnerabilities.is_empty() { + span { + style: "color: var(--success);", + "None" + } + } else { + span { class: "badge badge-high", + "{entry.known_vulnerabilities.len()} vuln(s)" + } + } + } + } + } + } + } + } + Pagination { + current_page: page(), + total_pages: total_pages, + on_page_change: move |p| page.set(p), + } + } + } + }, + Some(None) => rsx! { + div { class: "card", p { "Failed to load SBOM." } } + }, + None => rsx! { + div { class: "loading", "Loading SBOM..." } + }, + } + } +} diff --git a/compliance-dashboard/src/pages/settings.rs b/compliance-dashboard/src/pages/settings.rs new file mode 100644 index 0000000..0f41fee --- /dev/null +++ b/compliance-dashboard/src/pages/settings.rs @@ -0,0 +1,142 @@ +use dioxus::prelude::*; + +use crate::components::page_header::PageHeader; + +#[component] +pub fn SettingsPage() -> Element { + let mut litellm_url = use_signal(|| "http://localhost:4000".to_string()); + let mut litellm_model = use_signal(|| "gpt-4o".to_string()); + let mut github_token = use_signal(String::new); + let mut gitlab_url = use_signal(|| "https://gitlab.com".to_string()); + let mut gitlab_token = use_signal(String::new); + let mut jira_url = use_signal(String::new); + let mut jira_email = use_signal(String::new); + let mut jira_token = use_signal(String::new); + let mut jira_project = use_signal(String::new); + let mut searxng_url = use_signal(|| "http://localhost:8888".to_string()); + + rsx! { + PageHeader { + title: "Settings", + description: "Configure integrations and scanning parameters", + } + + div { class: "card", + div { class: "card-header", "LiteLLM Configuration" } + div { class: "form-group", + label { "LiteLLM URL" } + input { + r#type: "text", + value: "{litellm_url}", + oninput: move |e| litellm_url.set(e.value()), + } + } + div { class: "form-group", + label { "Model" } + input { + r#type: "text", + value: "{litellm_model}", + oninput: move |e| litellm_model.set(e.value()), + } + } + } + + div { class: "card", + div { class: "card-header", "GitHub Integration" } + div { class: "form-group", + label { "Personal Access Token" } + input { + r#type: "password", + placeholder: "ghp_...", + value: "{github_token}", + oninput: move |e| github_token.set(e.value()), + } + } + } + + div { class: "card", + div { class: "card-header", "GitLab Integration" } + div { class: "form-group", + label { "GitLab URL" } + input { + r#type: "text", + value: "{gitlab_url}", + oninput: move |e| gitlab_url.set(e.value()), + } + } + div { class: "form-group", + label { "Access Token" } + input { + r#type: "password", + placeholder: "glpat-...", + value: "{gitlab_token}", + oninput: move |e| gitlab_token.set(e.value()), + } + } + } + + div { class: "card", + div { class: "card-header", "Jira Integration" } + div { class: "form-group", + label { "Jira URL" } + input { + r#type: "text", + placeholder: "https://your-org.atlassian.net", + value: "{jira_url}", + oninput: move |e| jira_url.set(e.value()), + } + } + div { class: "form-group", + label { "Email" } + input { + r#type: "email", + value: "{jira_email}", + oninput: move |e| jira_email.set(e.value()), + } + } + div { class: "form-group", + label { "API Token" } + input { + r#type: "password", + value: "{jira_token}", + oninput: move |e| jira_token.set(e.value()), + } + } + div { class: "form-group", + label { "Project Key" } + input { + r#type: "text", + placeholder: "SEC", + value: "{jira_project}", + oninput: move |e| jira_project.set(e.value()), + } + } + } + + div { class: "card", + div { class: "card-header", "SearXNG" } + div { class: "form-group", + label { "SearXNG URL" } + input { + r#type: "text", + value: "{searxng_url}", + oninput: move |e| searxng_url.set(e.value()), + } + } + } + + div { style: "margin-top: 16px;", + button { + class: "btn btn-primary", + onclick: move |_| { + tracing::info!("Settings save not yet implemented - settings are managed via .env"); + }, + "Save Settings" + } + p { + style: "margin-top: 8px; font-size: 12px; color: var(--text-secondary);", + "Note: Settings are currently configured via environment variables (.env file). Dashboard-based settings persistence coming soon." + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b439a06 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + mongo: + image: mongo:latest + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + volumes: + - mongo_data:/data/db + + searxng: + image: searxng/searxng:latest + ports: + - "8888:8080" + environment: + - SEARXNG_BASE_URL=http://localhost:8888 + + agent: + build: + context: . + dockerfile: Dockerfile.agent + ports: + - "3001:3001" + - "3002:3002" + env_file: .env + depends_on: + - mongo + volumes: + - repos_data:/tmp/compliance-scanner/repos + + dashboard: + build: + context: . + dockerfile: Dockerfile.dashboard + ports: + - "8080:8080" + env_file: .env + depends_on: + - mongo + - agent + +volumes: + mongo_data: + repos_data: diff --git a/styles/input.css b/styles/input.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/styles/input.css @@ -0,0 +1 @@ +@import "tailwindcss";