From 5ca746e7d811ab3628e2cf8305797364053c2aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Vi=E1=BB=87t=20H=C6=B0ng?= Date: Wed, 1 Jul 2026 00:09:16 +0700 Subject: [PATCH 1/2] Rust SDK --- .github/workflows/rust.yml | 36 + README.md | 4 +- Rust/.env.sample | 2 + Rust/.gitignore | 2 + Rust/Cargo.lock | 2277 ++++++++++++++++++ Rust/Cargo.toml | 29 + Rust/README.md | 276 +++ Rust/changelog/0.1.0-CHANGELOG.md | 16 + Rust/intelx-cli/Cargo.toml | 29 + Rust/intelx-cli/src/cli.rs | 158 ++ Rust/intelx-cli/src/commands/capabilities.rs | 10 + Rust/intelx-cli/src/commands/download.rs | 23 + Rust/intelx-cli/src/commands/identity.rs | 154 ++ Rust/intelx-cli/src/commands/mod.rs | 4 + Rust/intelx-cli/src/commands/search.rs | 178 ++ Rust/intelx-cli/src/main.rs | 89 + Rust/intelx-cli/src/output.rs | 56 + Rust/intelx-cli/tests/cli.rs | 38 + Rust/intelx/Cargo.toml | 62 + Rust/intelx/examples/advanced_search.rs | 38 + Rust/intelx/examples/authenticate_info.rs | 15 + Rust/intelx/examples/download.rs | 28 + Rust/intelx/examples/file_preview.rs | 23 + Rust/intelx/examples/file_view.rs | 22 + Rust/intelx/examples/idsearch.rs | 29 + Rust/intelx/examples/reverse_domain.rs | 16 + Rust/intelx/examples/search.rs | 23 + Rust/intelx/examples/search_with_results.rs | 41 + Rust/intelx/examples/stats.rs | 18 + Rust/intelx/src/client.rs | 264 ++ Rust/intelx/src/error.rs | 116 + Rust/intelx/src/file.rs | 229 ++ Rust/intelx/src/identity.rs | 307 +++ Rust/intelx/src/lib.rs | 57 + Rust/intelx/src/models/capabilities.rs | 45 + Rust/intelx/src/models/identity.rs | 239 ++ Rust/intelx/src/models/item.rs | 136 ++ Rust/intelx/src/models/mod.rs | 29 + Rust/intelx/src/models/phonebook.rs | 177 ++ Rust/intelx/src/models/search.rs | 251 ++ Rust/intelx/src/models/search_result.rs | 90 + Rust/intelx/src/phonebook.rs | 94 + Rust/intelx/src/search.rs | 234 ++ Rust/intelx/src/util.rs | 108 + Rust/intelx/tests/live_smoke.rs | 36 + Rust/intelx/tests/search_integration.rs | 242 ++ Rust/rust-toolchain.toml | 3 + Rust/rustfmt.toml | 1 + 48 files changed, 6353 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/rust.yml create mode 100644 Rust/.env.sample create mode 100644 Rust/.gitignore create mode 100644 Rust/Cargo.lock create mode 100644 Rust/Cargo.toml create mode 100644 Rust/README.md create mode 100644 Rust/changelog/0.1.0-CHANGELOG.md create mode 100644 Rust/intelx-cli/Cargo.toml create mode 100644 Rust/intelx-cli/src/cli.rs create mode 100644 Rust/intelx-cli/src/commands/capabilities.rs create mode 100644 Rust/intelx-cli/src/commands/download.rs create mode 100644 Rust/intelx-cli/src/commands/identity.rs create mode 100644 Rust/intelx-cli/src/commands/mod.rs create mode 100644 Rust/intelx-cli/src/commands/search.rs create mode 100644 Rust/intelx-cli/src/main.rs create mode 100644 Rust/intelx-cli/src/output.rs create mode 100644 Rust/intelx-cli/tests/cli.rs create mode 100644 Rust/intelx/Cargo.toml create mode 100644 Rust/intelx/examples/advanced_search.rs create mode 100644 Rust/intelx/examples/authenticate_info.rs create mode 100644 Rust/intelx/examples/download.rs create mode 100644 Rust/intelx/examples/file_preview.rs create mode 100644 Rust/intelx/examples/file_view.rs create mode 100644 Rust/intelx/examples/idsearch.rs create mode 100644 Rust/intelx/examples/reverse_domain.rs create mode 100644 Rust/intelx/examples/search.rs create mode 100644 Rust/intelx/examples/search_with_results.rs create mode 100644 Rust/intelx/examples/stats.rs create mode 100644 Rust/intelx/src/client.rs create mode 100644 Rust/intelx/src/error.rs create mode 100644 Rust/intelx/src/file.rs create mode 100644 Rust/intelx/src/identity.rs create mode 100644 Rust/intelx/src/lib.rs create mode 100644 Rust/intelx/src/models/capabilities.rs create mode 100644 Rust/intelx/src/models/identity.rs create mode 100644 Rust/intelx/src/models/item.rs create mode 100644 Rust/intelx/src/models/mod.rs create mode 100644 Rust/intelx/src/models/phonebook.rs create mode 100644 Rust/intelx/src/models/search.rs create mode 100644 Rust/intelx/src/models/search_result.rs create mode 100644 Rust/intelx/src/phonebook.rs create mode 100644 Rust/intelx/src/search.rs create mode 100644 Rust/intelx/src/util.rs create mode 100644 Rust/intelx/tests/live_smoke.rs create mode 100644 Rust/intelx/tests/search_integration.rs create mode 100644 Rust/rust-toolchain.toml create mode 100644 Rust/rustfmt.toml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..ff2db6a --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,36 @@ +--- +name: Rust +"on": + push: + pull_request: +permissions: + contents: read +jobs: + build-test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.96.0 + with: + components: clippy, rustfmt + - name: Cache cargo registry and target dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: Rust -> target + - name: Build + run: | + cd ./Rust + cargo build --workspace --all-targets + - name: Test + run: | + cd ./Rust + cargo test --workspace + - name: Clippy + run: | + cd ./Rust + cargo clippy --workspace --all-targets -- -D warnings + - name: Format check + run: | + cd ./Rust + cargo fmt --all -- --check diff --git a/README.md b/README.md index 7f0d545..3a835a3 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,11 @@ The SDK contains these parts: 3. [PHP code example](PHP/index.php) 4. [Python code examples](Python/examples) 5. [Go package and code](Go/ixapi/README.md) -6. [Maltego Transform](Maltego%20Transform/README.md) +6. [Rust crate and CLI](Rust/README.md) +7. [Maltego Transform](Maltego%20Transform/README.md) Latest updates: +* 30.06.2026 - New Rust SDK and command-line client * 30.11.2024 - 0.6.2 installable from [Python Package Index](https://pypi.org/project/intelx/) * 06.06.2024 - Python supports "Export Leaked Accounts" of identity.intelx.io * 02.02.2024 - Python Package moved to [Python Repository](https://github.com/IntelligenceX/Python) diff --git a/Rust/.env.sample b/Rust/.env.sample new file mode 100644 index 0000000..c4559dc --- /dev/null +++ b/Rust/.env.sample @@ -0,0 +1,2 @@ +INTELX_KEY="00000000-0000-0000-0000-000000000000" +INTELX_BASE_URL="https://2.intelx.io" diff --git a/Rust/.gitignore b/Rust/.gitignore new file mode 100644 index 0000000..fedaa2b --- /dev/null +++ b/Rust/.gitignore @@ -0,0 +1,2 @@ +/target +.env diff --git a/Rust/Cargo.lock b/Rust/Cargo.lock new file mode 100644 index 0000000..3091ba8 --- /dev/null +++ b/Rust/Cargo.lock @@ -0,0 +1,2277 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[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 = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +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 = "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 = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "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 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[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", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "intelx" +version = "0.1.0" +dependencies = [ + "bytes", + "dotenvy", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "wiremock", +] + +[[package]] +name = "intelx-cli" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "colored", + "comfy-table", + "dotenvy", + "intelx", + "predicates", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[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-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[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 = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "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", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +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 = "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "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 = "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 = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[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.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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_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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +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 = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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 = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +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-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "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 = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "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 = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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.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.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.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.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.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.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.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.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 = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Rust/Cargo.toml b/Rust/Cargo.toml new file mode 100644 index 0000000..871764e --- /dev/null +++ b/Rust/Cargo.toml @@ -0,0 +1,29 @@ +[workspace] +resolver = "3" +members = ["intelx", "intelx-cli"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.96.0" +license = "MIT" +repository = "https://github.com/IntelligenceX/SDK" +authors = ["Intelligence X "] + +[workspace.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "gzip", "rustls-tls"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs", "io-util"] } +tokio-util = { version = "0.7", features = ["io"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_repr = "0.1" +thiserror = "2" +uuid = { version = "1", features = ["v4", "serde"] } +url = "2" +bytes = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dotenvy = "0.15" +futures-util = "0.3" + +intelx = { path = "intelx", version = "0.1.0" } diff --git a/Rust/README.md b/Rust/README.md new file mode 100644 index 0000000..2912152 --- /dev/null +++ b/Rust/README.md @@ -0,0 +1,276 @@ +# intelx Rust SDK + +## Introduction + +`intelx` is an async Rust SDK and command-line client for [intelx.io](https://intelx.io), +the Intelligence X search engine and data archive. It is a Rust port of the +[Python `intelx` SDK](../Python), covering intelligent search, phonebook search, file +operations, and the Identity Service (leaked-account search and reverse-domain lookup). + +The workspace has two crates: + +* [`intelx/`](intelx) - the library crate. +* [`intelx-cli/`](intelx-cli) - a `clap`-based command-line client built on the library, + producing the `intelx` binary. + +## Requirements + +* Rust **1.96.0** (pinned via [`rust-toolchain.toml`](rust-toolchain.toml); `rustup` will + install it automatically). + +## Installation + +### As a library + +From within this monorepo (before the crate is published to crates.io), add a path +dependency: + +```toml +[dependencies] +intelx = { path = "../Rust/intelx" } +``` + +Once published, use the registry dependency instead: + +```bash +cargo add intelx +``` + +### The CLI + +```bash +cargo install --path Rust/intelx-cli +``` + +This installs a binary named `intelx`. + +## Setup + +You will need an API key from . + +### Environment variable + +Copy `.env.sample` to `.env` and set your values: + +```bash +INTELX_KEY="00000000-0000-0000-0000-000000000000" +INTELX_BASE_URL="https://2.intelx.io" +``` + +Examples and the CLI both load `.env` automatically via the [`dotenvy`](https://docs.rs/dotenvy) +crate. + +### Via the CLI + +```bash +export INTELX_KEY=00000000-0000-0000-0000-000000000000 +intelx search riseup.net +``` + +or pass it explicitly: + +```bash +intelx --api-key "$INTELX_KEY" search riseup.net +``` + +## Usage as a CLI + +```bash +# Quick search +intelx search riseup.net + +# Search in specific buckets +intelx search riseup.net --buckets "pastes,darknet.tor" + +# Search with 100 results +intelx search riseup.net --limit 100 + +# Download an item (requires --bucket) +intelx download 29a97791-1138-40b3-8cf1-de1764e9d09c \ + --bucket leaks.private.general --name test.txt + +# View the full contents of a search result instead of a short preview +intelx search 3a4d5699-737c-4d22-8dbd-c5391ce805df --view + +# Export all matching files from a search +intelx search email@email.com --export --export-format zip --limit 5 \ + --buckets "pastes,leaks.private.general,leaks.logs,whois,usenet" + +# Extract emails from a phonebook search +intelx search cia.gov --phonebook emails + +# Identity Portal: export leaked accounts +intelx identity riseup.net export-accounts + +# Identity Portal: data leaks search +intelx identity riseup.net data-leaks + +# Account capabilities +intelx capabilities +``` + +Pass `--raw` to any command to print JSON instead of formatted output. + +## Usage as a library + +```rust +let client = intelx::IntelXClient::new("00000000-0000-0000-0000-000000000000")?; +let results = client.search(intelx::SearchParams::new("hackerone.com")).await?; +``` + +### Advanced search + +By default `maxresults` is `100`. The following parameters all have defaults but can be +overridden via the [`SearchParams`](intelx/src/models/search.rs) builder: + +* `maxresults` = 100 +* `buckets` = `[]` +* `timeout` = 5 (seconds) +* `datefrom` / `dateto` = `""` +* `sort` = `SortOrder::DateDesc` +* `media` = 0 +* `terminate` = `[]` + +```rust +let results = client + .search(intelx::SearchParams::new("hackerone.com").maxresults(200)) + .await?; +``` + +#### Searching in specific buckets + +```rust +let params = intelx::SearchParams::new("hackerone.com") + .maxresults(200) + .buckets(["darknet", "leaks.public", "leaks.private"]); +let results = client.search(params).await?; +``` + +Your account must have access to every specified bucket, otherwise you will receive +`401 Unauthorized`. The `leaks.private` bucket is only available on certain licenses. + +#### Filtering by date + +```rust +let params = intelx::SearchParams::new("riseup.net") + .maxresults(200) + .datefrom("2014-01-01 00:00:00") + .dateto("2014-02-02 23:59:59"); +let results = client.search(params).await?; +``` + +#### Filtering by media type + +See the [Media Types](#media-types) table below for the available IDs. + +```rust +let params = intelx::SearchParams::new("riseup.net") + .maxresults(200) + .media(1) // Paste document + .datefrom("2014-01-01 00:00:00") + .dateto("2014-02-02 23:59:59"); +let results = client.search(params).await?; +``` + +#### Statistics + +```rust +let results = client + .search(intelx::SearchParams::new("riseup.net").maxresults(1000)) + .await?; +let stats = intelx::stats(&results); +println!("{stats:?}"); +``` + +### Viewing/reading files + +There is a fundamental difference between `file_view` and `file_read`: viewing is for +quickly inspecting the contents of a file (assumed to be text); `file_read` is for direct +data download, reliably returning binary contents (ZIP, PDF, etc) without encoding issues. + +#### Viewing + +```rust +let results = client.search(intelx::SearchParams::new("riseup.net")).await?; +let first = &results[0]; +let contents = client + .file_view(first.item_type, first.media, &first.storageid, &first.bucket, 0) + .await?; +println!("{contents}"); +``` + +#### Reading + +```rust +let results = client.search(intelx::SearchParams::new("riseup.net")).await?; +let first = &results[0]; +client + .file_read(&first.systemid.to_string(), intelx::FileReadType::Raw, &first.bucket, + std::path::Path::new("file.bin")) + .await?; +``` + +### Date handling + +Date fields (`Item::added`, `Item::date`) are kept as plain `String`s rather than +`chrono::DateTime`, because the server's `YYYY-mm-dd HH:ii:ss` format is not RFC 3339. +Parse them yourself with `chrono::NaiveDateTime::parse_from_str(&item.date, "%Y-%m-%d %H:%M:%S")` +if you need typed dates. + +## Other notes + +### Media Types + +| ID | Media Type | +|----|------------------------------------| +| 0 | All | +| 1 | Paste document | +| 2 | Paste user | +| 3 | Forum | +| 4 | Forum board | +| 5 | Forum thread | +| 6 | Forum post | +| 7 | Forum user | +| 8 | Screenshot of website | +| 9 | HTML copy of website | +| 13 | Tweet | +| 14 | URL | +| 15 | PDF document | +| 16 | Word document | +| 17 | Excel document | +| 18 | Powerpoint document | +| 19 | Picture | +| 20 | Audio file | +| 21 | Video file | +| 22 | Container file (ZIP/RAR/TAR, etc) | +| 23 | HTML file | +| 24 | Text file | + +### Format Types + +| ID | Format Type | +|----|---------------------------------------| +| 0 | textview of content | +| 1 | hex view of content | +| 2 | auto detect hex view or text view | +| 3 | picture view | +| 4 | not supported | +| 5 | html inline view (sanitized) | +| 6 | text view of pdf | +| 7 | text view of html | +| 8 | text view of word file | + +## Testing + +```bash +cd Rust +cargo test --workspace # unit + wiremock + CLI tests, no network/credentials +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all -- --check +INTELX_KEY=... cargo test -p intelx --test live_smoke -- --ignored # live API smoke test +``` + +# Contribute + +Please use the [issue tracker](https://github.com/IntelligenceX/SDK/issues) to report any +bugs, security vulnerabilities, or feature requests. \ No newline at end of file diff --git a/Rust/changelog/0.1.0-CHANGELOG.md b/Rust/changelog/0.1.0-CHANGELOG.md new file mode 100644 index 0000000..705173b --- /dev/null +++ b/Rust/changelog/0.1.0-CHANGELOG.md @@ -0,0 +1,16 @@ +## [0.1.0] + +### Features + +- Initial Rust SDK: `intelx` library crate (async, `reqwest` + `tokio`) with full parity to + the Python SDK - intelligent search, phonebook search, file preview/view/read/treeview, + search export, and the Identity Service (`idsearch`, `export_accounts`, `reverse_domain`). +- `intelx-cli` binary crate (`clap`-based) providing `search`, `identity`, `download`, and + `capabilities` subcommands. + +### Testing + +- Unit tests for request serialization, format resolution, and error mapping. +- `wiremock`-backed HTTP integration tests (no live credentials required). +- `assert_cmd`-backed CLI integration tests. +- `#[ignore]`d live API smoke test, gated behind `INTELX_KEY`. diff --git a/Rust/intelx-cli/Cargo.toml b/Rust/intelx-cli/Cargo.toml new file mode 100644 index 0000000..530c176 --- /dev/null +++ b/Rust/intelx-cli/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "intelx-cli" +description = "Command-line client for the Intelligence X (intelx.io) API, built on the intelx crate" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[[bin]] +name = "intelx" +path = "src/main.rs" + +[dependencies] +intelx.workspace = true +tokio.workspace = true +clap = { version = "4", features = ["derive"] } +dotenvy.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +serde.workspace = true +serde_json.workspace = true +colored = "2" +comfy-table = "7" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" diff --git a/Rust/intelx-cli/src/cli.rs b/Rust/intelx-cli/src/cli.rs new file mode 100644 index 0000000..301be28 --- /dev/null +++ b/Rust/intelx-cli/src/cli.rs @@ -0,0 +1,158 @@ +use clap::{Args, Parser, Subcommand, ValueEnum}; + +/// Command-line client for https://intelx.io, built on the `intelx` crate. +#[derive(Debug, Parser)] +#[command(name = "intelx", version, about, long_about = None)] +pub struct Cli { + /// API key. Falls back to the `INTELX_KEY` environment variable. + #[arg(long, global = true)] + pub api_key: Option, + + /// Base URL override. Falls back to `INTELX_BASE_URL`, then the per-command default. + #[arg(long, global = true)] + pub base_url: Option, + + /// Print raw JSON instead of formatted output. + #[arg(long, global = true)] + pub raw: bool, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Search the general Intelligence X index (intelligent search or phonebook search). + Search(SearchArgs), + /// Search the Identity Service for leaked accounts / reverse-domain activity. + Identity(IdentityArgs), + /// Download a single item by its system ID. + Download(DownloadArgs), + /// Show your account's API capabilities. + Capabilities, +} + +#[derive(Debug, Args)] +pub struct SearchArgs { + /// The search term (a "strong selector": email, domain, IP, hash, etc). + pub term: String, + + /// Comma-separated list of buckets to search. + #[arg(long)] + pub buckets: Option, + + /// Maximum number of results to return. Defaults to 10 for display, 100 internally. + #[arg(long)] + pub limit: Option, + + /// Search timeout in seconds. + #[arg(long, default_value_t = 5)] + pub timeout: i32, + + /// Starting date filter, `YYYY-mm-dd HH:ii:ss`. + #[arg(long)] + pub datefrom: Option, + + /// Ending date filter, `YYYY-mm-dd HH:ii:ss`. + #[arg(long)] + pub dateto: Option, + + /// Media type filter (0 = all). + #[arg(long, default_value_t = 0)] + pub media: i32, + + /// Run a phonebook search instead, restricted to this selector type. + #[arg(long, value_enum)] + pub phonebook: Option, + + /// With `--phonebook`, print only email selectors. + #[arg(long)] + pub emails: bool, + + /// Show the full contents of each result instead of a short preview. + #[arg(long)] + pub view: bool, + + /// Skip the text preview/view snippet entirely. + #[arg(long)] + pub nopreview: bool, + + /// Print bucket-count statistics instead of individual results. + #[arg(long)] + pub stats: bool, + + /// Export all matching files instead of printing results. + #[arg(long)] + pub export: bool, + + /// Export format, used with `--export`. + #[arg(long, value_enum, default_value_t = ExportFormatArg::Zip)] + pub export_format: ExportFormatArg, + + /// Directory to write exported/downloaded files to. + #[arg(long, default_value = ".")] + pub out_dir: std::path::PathBuf, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum PhonebookKind { + All, + Domains, + Emails, + Urls, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ExportFormatArg { + Csv, + Zip, +} + +#[derive(Debug, Args)] +pub struct IdentityArgs { + /// The domain or email address to look up. + pub term: String, + + /// Maximum number of results to return. + #[arg(long, default_value_t = 10)] + pub limit: i32, + + /// Comma-separated list of buckets to search. + #[arg(long)] + pub buckets: Option, + + /// Starting date filter, `YYYY-mm-dd HH:ii:ss`. + #[arg(long)] + pub datefrom: Option, + + /// Ending date filter, `YYYY-mm-dd HH:ii:ss`. + #[arg(long)] + pub dateto: Option, + + #[command(subcommand)] + pub kind: IdentityKind, +} + +#[derive(Debug, Subcommand)] +pub enum IdentityKind { + /// Search for leaked data (`/live/search/internal`). + DataLeaks, + /// Export leaked accounts to a TSV file (`/accounts/csv`). + ExportAccounts, + /// Reverse-domain lookup (`/reverse/domain`). + ReverseDomain, +} + +#[derive(Debug, Args)] +pub struct DownloadArgs { + /// System ID of the item to download. + pub id: String, + + /// Bucket the item was found in. + #[arg(long)] + pub bucket: String, + + /// Filename to save the item as. Defaults to `.bin`. + #[arg(long)] + pub name: Option, +} diff --git a/Rust/intelx-cli/src/commands/capabilities.rs b/Rust/intelx-cli/src/commands/capabilities.rs new file mode 100644 index 0000000..fac5d43 --- /dev/null +++ b/Rust/intelx-cli/src/commands/capabilities.rs @@ -0,0 +1,10 @@ +use intelx::IntelXClient; + +use crate::output; + +pub async fn run(client: &IntelXClient) -> intelx::Result<()> { + output::info("Getting your API capabilities.\n"); + let capabilities = client.get_capabilities().await?; + output::print_json(&capabilities); + Ok(()) +} diff --git a/Rust/intelx-cli/src/commands/download.rs b/Rust/intelx-cli/src/commands/download.rs new file mode 100644 index 0000000..c961f12 --- /dev/null +++ b/Rust/intelx-cli/src/commands/download.rs @@ -0,0 +1,23 @@ +use intelx::IntelXClient; + +use crate::cli::DownloadArgs; +use crate::output; + +pub async fn run(client: &IntelXClient, args: DownloadArgs) -> intelx::Result<()> { + let filename = args.name.unwrap_or_else(|| format!("{}.bin", args.id)); + let dest = std::path::Path::new(&filename); + + match client + .file_read(&args.id, intelx::FileReadType::Raw, &args.bucket, dest) + .await + { + Ok(_) => { + output::info(&format!("Successfully downloaded the file '{filename}'.")); + Ok(()) + } + Err(err) => { + output::error(&format!("Failed to download item {}: {err}", args.id)); + Err(err) + } + } +} diff --git a/Rust/intelx-cli/src/commands/identity.rs b/Rust/intelx-cli/src/commands/identity.rs new file mode 100644 index 0000000..dc71f19 --- /dev/null +++ b/Rust/intelx-cli/src/commands/identity.rs @@ -0,0 +1,154 @@ +use intelx::IdentityClient; + +use crate::cli::{IdentityArgs, IdentityKind}; +use crate::output; + +fn parse_buckets(buckets: &Option) -> Vec { + buckets + .as_deref() + .map(|b| { + b.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +fn write_tsv(filename: &str, headers: &[&str], rows: &[Vec]) -> std::io::Result<()> { + use std::io::Write; + let mut file = std::fs::File::create(filename)?; + writeln!(file, "{}", headers.join("\t"))?; + for row in rows { + writeln!(file, "{}", row.join("\t"))?; + } + Ok(()) +} + +pub async fn run(client: &IdentityClient, args: IdentityArgs, raw: bool) -> intelx::Result<()> { + let buckets = parse_buckets(&args.buckets); + + match args.kind { + IdentityKind::DataLeaks => { + if !raw { + output::info(&format!("Starting data leaks search of \"{}\".", args.term)); + } + let mut params = intelx::IdSearchParams::new(&args.term).maxresults(args.limit); + if let Some(bucket) = buckets.first() { + params = params.bucket(bucket.clone()); + } + if let Some(datefrom) = &args.datefrom { + params = params.datefrom(datefrom.clone()); + } + if let Some(dateto) = &args.dateto { + params = params.dateto(dateto.clone()); + } + let records = client.idsearch(params).await?; + + if raw { + output::print_json(&records); + return Ok(()); + } + + let rows: Vec> = records + .iter() + .filter_map(|r| r.item.as_ref()) + .map(|item| vec![item.name.clone(), item.date.clone(), item.bucket.clone()]) + .collect(); + output::table(vec!["Name", "Date", "Bucket"], rows.clone()); + + let filename = format!("intelx-output-{}-data_leaks.tsv", args.term); + write_tsv(&filename, &["Name", "Date", "Bucket"], &rows)?; + output::info(&format!("Exported output to \"{filename}\".")); + } + IdentityKind::ExportAccounts => { + if !raw { + output::info(&format!("Starting account export of \"{}\".", args.term)); + } + let mut params = intelx::ExportAccountsParams::new(&args.term).maxresults(args.limit); + if let Some(datefrom) = &args.datefrom { + params = params.datefrom(datefrom.clone()); + } + if let Some(dateto) = &args.dateto { + params = params.dateto(dateto.clone()); + } + let records = client.export_accounts(params).await?; + + if raw { + output::print_json(&records); + return Ok(()); + } + + let rows: Vec> = records + .iter() + .map(|r| { + vec![ + r.user.clone(), + r.password.clone(), + r.passwordtype.to_string(), + r.sourceshort.clone(), + ] + }) + .collect(); + output::table( + vec!["User", "Password", "Password Type", "Source Short"], + rows.clone(), + ); + + let filename = format!("intelx-output-{}-export_accounts.tsv", args.term); + write_tsv( + &filename, + &["User", "Password", "Password Type", "Source Short"], + &rows, + )?; + output::info(&format!("Exported output to \"{filename}\".")); + } + IdentityKind::ReverseDomain => { + if !raw { + output::info(&format!( + "Starting reverse domain export of \"{}\".", + args.term + )); + } + let mut params = intelx::ReverseDomainParams::new(&args.term).maxresults(args.limit); + if let Some(datefrom) = &args.datefrom { + params = params.datefrom(datefrom.clone()); + } + if let Some(dateto) = &args.dateto { + params = params.dateto(dateto.clone()); + } + let records = client.reverse_domain(params).await?; + + if raw { + output::print_json(&records); + return Ok(()); + } + + let rows: Vec> = records + .iter() + .map(|r| { + vec![ + r.user.clone(), + r.password.clone(), + r.url.clone(), + r.sourceshort.clone(), + ] + }) + .collect(); + output::table( + vec!["User", "Password", "URL", "Source Short"], + rows.clone(), + ); + + let filename = format!("intelx-output-{}-export_accounts.tsv", args.term); + write_tsv( + &filename, + &["User", "Password", "URL", "Source Short"], + &rows, + )?; + output::info(&format!("Exported output to \"{filename}\".")); + } + } + + Ok(()) +} diff --git a/Rust/intelx-cli/src/commands/mod.rs b/Rust/intelx-cli/src/commands/mod.rs new file mode 100644 index 0000000..7ef4f73 --- /dev/null +++ b/Rust/intelx-cli/src/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod capabilities; +pub mod download; +pub mod identity; +pub mod search; diff --git a/Rust/intelx-cli/src/commands/search.rs b/Rust/intelx-cli/src/commands/search.rs new file mode 100644 index 0000000..31df071 --- /dev/null +++ b/Rust/intelx-cli/src/commands/search.rs @@ -0,0 +1,178 @@ +use intelx::IntelXClient; + +use crate::cli::{ExportFormatArg, PhonebookKind, SearchArgs}; +use crate::output; + +fn parse_buckets(buckets: &Option) -> Vec { + buckets + .as_deref() + .map(|b| { + b.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +pub async fn run(client: &IntelXClient, args: SearchArgs, raw: bool) -> intelx::Result<()> { + if args.limit.is_none() && !args.stats && args.phonebook.is_none() && !raw { + output::warn("Limit argument not supplied, setting default to 10 results."); + } + let limit = args.limit.unwrap_or(10); + let buckets = parse_buckets(&args.buckets); + + if let Some(phonebook) = args.phonebook { + return run_phonebook(client, &args, phonebook, buckets, raw).await; + } + + if !raw { + output::info(&format!("Starting search of \"{}\".", args.term)); + } + + let mut params = intelx::SearchParams::new(&args.term) + .maxresults(if args.stats { 1000 } else { limit.max(100) }) + .buckets(buckets) + .timeout(args.timeout) + .media(args.media); + if let Some(datefrom) = &args.datefrom { + params = params.datefrom(datefrom.clone()); + } + if let Some(dateto) = &args.dateto { + params = params + .dateto(dateto.clone()) + .sort(intelx::SortOrder::XScoreDesc); + } + + if args.export { + let format = match args.export_format { + ExportFormatArg::Csv => intelx::ExportFormat::Csv, + ExportFormatArg::Zip => intelx::ExportFormat::Zip, + }; + let path = client + .export_from_search(params, format, &args.out_dir) + .await?; + output::info(&format!( + "Exported search results to \"{}\".", + path.display() + )); + return Ok(()); + } + + let results = client.search(params).await?; + + if raw { + output::print_json(&results); + return Ok(()); + } + + if args.stats { + let stats = intelx::stats(&results); + output::print_json(&stats); + return Ok(()); + } + + for result in results.iter().take(limit as usize) { + let name = if result.name.is_empty() { + "Untitled Document" + } else { + &result.name + }; + println!( + "________________________________________________________________________________" + ); + println!("> Name: {name}"); + println!("> Date: {}", result.date); + println!("> Size: {} bytes", result.size); + println!("> Media: {}", result.mediah); + println!("> Bucket: {}", result.bucketh); + println!("> ID: {}", result.systemid); + + if args.view { + let text = client + .file_view( + result.item_type, + result.media, + &result.storageid, + &result.bucket, + 0, + ) + .await?; + if !text.is_empty() { + println!("\n{text}"); + } + } else if !args.nopreview { + let preview_params = intelx::FilePreviewParams::new( + result.item_type, + result.media, + 0, + result.storageid.clone(), + ) + .bucket(result.bucket.clone()); + let text = client.file_preview(preview_params).await?; + if !text.is_empty() { + println!("\n{text}"); + } + } + println!( + "________________________________________________________________________________" + ); + } + + Ok(()) +} + +async fn run_phonebook( + client: &IntelXClient, + args: &SearchArgs, + kind: PhonebookKind, + buckets: Vec, + raw: bool, +) -> intelx::Result<()> { + if !raw { + output::info(&format!("Starting phonebook search of \"{}\".", args.term)); + } + + let target = match kind { + PhonebookKind::All => intelx::PhonebookTarget::All, + PhonebookKind::Domains => intelx::PhonebookTarget::Domains, + PhonebookKind::Emails => intelx::PhonebookTarget::EmailAddresses, + PhonebookKind::Urls => intelx::PhonebookTarget::Urls, + }; + + let mut params = intelx::PhonebookSearchParams::new(&args.term) + .maxresults(args.limit.unwrap_or(1000)) + .buckets(buckets) + .target(target); + if let Some(datefrom) = &args.datefrom { + params.datefrom = datefrom.clone(); + } + if let Some(dateto) = &args.dateto { + params.dateto = dateto.clone(); + } + + let pages = client.phonebook_search_all(params).await?; + + if raw { + output::print_json(&pages); + return Ok(()); + } + + let selectors = intelx::flatten_selectors(&pages); + if args.emails { + for selector in &selectors { + if selector.selectortype == 1 { + println!("{}", selector.selectorvalue); + } + } + return Ok(()); + } + + let rows = selectors + .iter() + .map(|s| vec![s.selectortypeh.clone(), s.selectorvalue.clone()]) + .collect(); + output::table(vec!["Type", "Value"], rows); + + Ok(()) +} diff --git a/Rust/intelx-cli/src/main.rs b/Rust/intelx-cli/src/main.rs new file mode 100644 index 0000000..c9fd4e5 --- /dev/null +++ b/Rust/intelx-cli/src/main.rs @@ -0,0 +1,89 @@ +mod cli; +mod commands; +mod output; + +use clap::Parser; +use cli::{Cli, Command}; +use colored::Colorize; + +fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +#[tokio::main] +async fn main() -> std::process::ExitCode { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + + if !cli.raw { + println!("{}", output::BANNER.bold()); + println!("intelx v{}", version()); + } + + let api_key = cli + .api_key + .clone() + .or_else(|| std::env::var("INTELX_KEY").ok()); + let Some(api_key) = api_key else { + output::error( + "No API key specified. Please use the \"--api-key\" parameter or set the environment variable \"INTELX_KEY\".", + ); + return std::process::ExitCode::FAILURE; + }; + + let base_url = cli + .base_url + .clone() + .or_else(|| std::env::var("INTELX_BASE_URL").ok()); + + let result = run(api_key, base_url, cli).await; + match result { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(err) => { + output::error(&err.to_string()); + std::process::ExitCode::FAILURE + } + } +} + +async fn run(api_key: String, base_url: Option, cli: Cli) -> intelx::Result<()> { + match cli.command { + Command::Search(args) => { + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Some(base_url) = base_url { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + commands::search::run(&client, args, cli.raw).await + } + Command::Identity(args) => { + let mut builder = intelx::IdentityClient::builder().api_key(api_key); + if let Some(base_url) = base_url { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + commands::identity::run(&client, args, cli.raw).await + } + Command::Download(args) => { + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Some(base_url) = base_url { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + commands::download::run(&client, args).await + } + Command::Capabilities => { + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Some(base_url) = base_url { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + commands::capabilities::run(&client).await + } + } +} diff --git a/Rust/intelx-cli/src/output.rs b/Rust/intelx-cli/src/output.rs new file mode 100644 index 0000000..5f497b9 --- /dev/null +++ b/Rust/intelx-cli/src/output.rs @@ -0,0 +1,56 @@ +use colored::Colorize; +use comfy_table::Table; + +pub const BANNER: &str = r#" + _____ _ ___ __ + |_ _| | | | \ \ / / + | | _ __ | |_ ___| |\ V / + | || '_ \| __/ _ \ |/ \ + _| || | | | || __/ / /^\ \ + \___/_| |_|\__\___|_\/ \/ + + a command line client + for intelx.io +"#; + +fn timestamp() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let secs_today = now.as_secs() % 86400; + format!( + "{:02}:{:02}:{:02}", + secs_today / 3600, + (secs_today % 3600) / 60, + secs_today % 60 + ) +} + +pub fn info(message: &str) { + println!("{}", format!("[{}] {message}", timestamp()).green()); +} + +pub fn warn(message: &str) { + println!("{}", format!("[{}] {message}", timestamp()).yellow()); +} + +pub fn error(message: &str) { + eprintln!("{}", format!("[{}] {message}", timestamp()).red()); +} + +pub fn table(headers: Vec<&str>, rows: Vec>) { + let mut table = Table::new(); + table.set_header(headers); + for row in rows { + table.add_row(row); + } + println!("{table}"); +} + +pub fn print_json(value: &T) { + match serde_json::to_string_pretty(value) { + Ok(json) => println!("{json}"), + Err(err) => error(&format!("failed to serialize output as JSON: {err}")), + } +} diff --git a/Rust/intelx-cli/tests/cli.rs b/Rust/intelx-cli/tests/cli.rs new file mode 100644 index 0000000..20abed7 --- /dev/null +++ b/Rust/intelx-cli/tests/cli.rs @@ -0,0 +1,38 @@ +//! CLI-level integration tests: spawn the built binary and assert on its behavior. These never +//! hit the network and require no credentials. + +use assert_cmd::Command; +use predicates::str::contains; + +fn cmd() -> Command { + let mut cmd = Command::cargo_bin("intelx").unwrap(); + cmd.env_remove("INTELX_KEY"); + cmd.env_remove("INTELX_BASE_URL"); + cmd +} + +#[test] +fn help_lists_all_subcommands() { + cmd() + .arg("--help") + .assert() + .success() + .stdout(contains("search")) + .stdout(contains("identity")) + .stdout(contains("download")) + .stdout(contains("capabilities")); +} + +#[test] +fn search_without_api_key_fails_with_clear_message() { + cmd() + .args(["search", "test.com"]) + .assert() + .failure() + .stderr(contains("No API key specified")); +} + +#[test] +fn missing_subcommand_fails_with_usage() { + cmd().assert().failure().stderr(contains("Usage")); +} diff --git a/Rust/intelx/Cargo.toml b/Rust/intelx/Cargo.toml new file mode 100644 index 0000000..1baf37c --- /dev/null +++ b/Rust/intelx/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "intelx" +description = "Async Rust SDK for the Intelligence X (intelx.io) search engine and data archive API" +keywords = ["osint", "intelx", "intelligence-x", "search", "threat-intel"] +categories = ["api-bindings"] +readme = "../README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +reqwest.workspace = true +tokio.workspace = true +tokio-util.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_repr.workspace = true +thiserror.workspace = true +uuid.workspace = true +url.workspace = true +bytes.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } +dotenvy.workspace = true +wiremock = "0.6" +tempfile = "3" +tracing-subscriber.workspace = true + +[[example]] +name = "search" + +[[example]] +name = "advanced_search" + +[[example]] +name = "authenticate_info" + +[[example]] +name = "download" + +[[example]] +name = "file_preview" + +[[example]] +name = "file_view" + +[[example]] +name = "idsearch" + +[[example]] +name = "reverse_domain" + +[[example]] +name = "search_with_results" + +[[example]] +name = "stats" diff --git a/Rust/intelx/examples/advanced_search.rs b/Rust/intelx/examples/advanced_search.rs new file mode 100644 index 0000000..c7101fd --- /dev/null +++ b/Rust/intelx/examples/advanced_search.rs @@ -0,0 +1,38 @@ +fn client_from_env() -> intelx::Result { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Ok(base_url) = std::env::var("INTELX_BASE_URL") { + builder = builder.base_url(base_url); + } + builder.build() +} + +async fn count_in_buckets( + client: &intelx::IntelXClient, + target: &str, + buckets: &[&str], + label: &str, +) -> intelx::Result<()> { + let params = intelx::SearchParams::new(target) + .buckets(buckets.iter().copied()) + .maxresults(2000); + let results = client.search(params).await?; + println!( + "Found {} records for {target} in bucket '{label}'", + results.len() + ); + Ok(()) +} + +#[tokio::main] +async fn main() -> intelx::Result<()> { + let client = client_from_env()?; + let target = "riseup.net"; + + count_in_buckets(&client, target, &["leaks.public", "leaks.private"], "leaks").await?; + count_in_buckets(&client, target, &["pastes"], "pastes").await?; + count_in_buckets(&client, target, &["darknet"], "darknet").await?; + + Ok(()) +} diff --git a/Rust/intelx/examples/authenticate_info.rs b/Rust/intelx/examples/authenticate_info.rs new file mode 100644 index 0000000..16a4d5c --- /dev/null +++ b/Rust/intelx/examples/authenticate_info.rs @@ -0,0 +1,15 @@ +#[tokio::main] +async fn main() -> intelx::Result<()> { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Ok(base_url) = std::env::var("INTELX_BASE_URL") { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + + let capabilities: intelx::Capabilities = client.get_capabilities().await?; + println!("{capabilities:#?}"); + + Ok(()) +} diff --git a/Rust/intelx/examples/download.rs b/Rust/intelx/examples/download.rs new file mode 100644 index 0000000..71fd528 --- /dev/null +++ b/Rust/intelx/examples/download.rs @@ -0,0 +1,28 @@ +#[tokio::main] +async fn main() -> intelx::Result<()> { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Ok(base_url) = std::env::var("INTELX_BASE_URL") { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + + let results = client + .search(intelx::SearchParams::new("riseup.net")) + .await?; + let first = results.first().expect("search returned no results"); + + let dest = std::path::Path::new("file1.bin"); + client + .file_read( + &first.systemid.to_string(), + intelx::FileReadType::Raw, + &first.bucket, + dest, + ) + .await?; + println!("Saved first search result to {}", dest.display()); + + Ok(()) +} diff --git a/Rust/intelx/examples/file_preview.rs b/Rust/intelx/examples/file_preview.rs new file mode 100644 index 0000000..d4857d5 --- /dev/null +++ b/Rust/intelx/examples/file_preview.rs @@ -0,0 +1,23 @@ +#[tokio::main] +async fn main() -> intelx::Result<()> { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Ok(base_url) = std::env::var("INTELX_BASE_URL") { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + + let results = client + .search(intelx::SearchParams::new("riseup.net").maxresults(5)) + .await?; + let first = results.first().expect("search returned no results"); + + let params = intelx::FilePreviewParams::new(1, first.media, 0, first.storageid.clone()) + .bucket(first.bucket.clone()) + .lines(20); + let preview = client.file_preview(params).await?; + println!("{preview}"); + + Ok(()) +} diff --git a/Rust/intelx/examples/file_view.rs b/Rust/intelx/examples/file_view.rs new file mode 100644 index 0000000..bdf12fa --- /dev/null +++ b/Rust/intelx/examples/file_view.rs @@ -0,0 +1,22 @@ +#[tokio::main] +async fn main() -> intelx::Result<()> { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Ok(base_url) = std::env::var("INTELX_BASE_URL") { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + + let results = client + .search(intelx::SearchParams::new("riseup.net").maxresults(5)) + .await?; + let first = results.first().expect("search returned no results"); + + let view = client + .file_view(1, first.media, &first.storageid, &first.bucket, 0) + .await?; + println!("{view}"); + + Ok(()) +} diff --git a/Rust/intelx/examples/idsearch.rs b/Rust/intelx/examples/idsearch.rs new file mode 100644 index 0000000..c36555c --- /dev/null +++ b/Rust/intelx/examples/idsearch.rs @@ -0,0 +1,29 @@ +#[tokio::main] +async fn main() -> intelx::Result<()> { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + + let identity = intelx::IdentityClient::new(api_key.clone())?; + let general = intelx::IntelXClient::new(api_key)?; + + let records = identity + .idsearch(intelx::IdSearchParams::new("john.doe@example.com")) + .await?; + let first_item = records + .first() + .and_then(|record| record.item.as_ref()) + .expect("identity search returned no usable results"); + + let contents = general + .file_view( + first_item.item_type, + first_item.media, + &first_item.storageid, + &first_item.bucket, + 0, + ) + .await?; + println!("{contents}"); + + Ok(()) +} diff --git a/Rust/intelx/examples/reverse_domain.rs b/Rust/intelx/examples/reverse_domain.rs new file mode 100644 index 0000000..0482d2f --- /dev/null +++ b/Rust/intelx/examples/reverse_domain.rs @@ -0,0 +1,16 @@ +#[tokio::main] +async fn main() -> intelx::Result<()> { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + let identity = intelx::IdentityClient::new(api_key)?; + + let params = intelx::ReverseDomainParams::new("riseup.net") + .maxresults(10) + .datefrom("2022-01-01 00:00:00") + .dateto("2022-06-01 00:00:00"); + let results = identity.reverse_domain(params).await?; + + println!("{results:#?}"); + + Ok(()) +} diff --git a/Rust/intelx/examples/search.rs b/Rust/intelx/examples/search.rs new file mode 100644 index 0000000..199b79a --- /dev/null +++ b/Rust/intelx/examples/search.rs @@ -0,0 +1,23 @@ +fn client_from_env() -> intelx::Result { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Ok(base_url) = std::env::var("INTELX_BASE_URL") { + builder = builder.base_url(base_url); + } + builder.build() +} + +#[tokio::main] +async fn main() -> intelx::Result<()> { + let client = client_from_env()?; + + let results = client + .search(intelx::SearchParams::new("riseup.net")) + .await?; + for record in &results { + println!("Found media type {} in {}", record.media, record.bucket); + } + + Ok(()) +} diff --git a/Rust/intelx/examples/search_with_results.rs b/Rust/intelx/examples/search_with_results.rs new file mode 100644 index 0000000..bc78860 --- /dev/null +++ b/Rust/intelx/examples/search_with_results.rs @@ -0,0 +1,41 @@ +#[tokio::main] +async fn main() -> intelx::Result<()> { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Ok(base_url) = std::env::var("INTELX_BASE_URL") { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + + let params = intelx::SearchParams::new("riseup.net") + .maxresults(50) + .buckets(["leaks.public", "pastes"]) + .timeout(5) + .datefrom("2021-01-01 00:00:00") + .dateto("2022-02-02 23:00:00") + .sort(intelx::SortOrder::DateDesc) + .media(0); + + let search_id = client.intel_search(params).await?; + println!("Search ID: {search_id}"); + + let limit = 100; + loop { + let page = client.intel_search_result(search_id, limit).await?; + println!("Status: {:?}, records: {}", page.status, page.records.len()); + + for record in &page.records { + println!("{} {}", record.name, record.bucket); + } + + if matches!( + page.status, + intelx::SearchStatus::NoMoreResults | intelx::SearchStatus::NotFound + ) { + break; + } + } + + Ok(()) +} diff --git a/Rust/intelx/examples/stats.rs b/Rust/intelx/examples/stats.rs new file mode 100644 index 0000000..1a5c43c --- /dev/null +++ b/Rust/intelx/examples/stats.rs @@ -0,0 +1,18 @@ +#[tokio::main] +async fn main() -> intelx::Result<()> { + dotenvy::dotenv().ok(); + let api_key = std::env::var("INTELX_KEY").expect("INTELX_KEY must be set"); + let mut builder = intelx::IntelXClient::builder().api_key(api_key); + if let Ok(base_url) = std::env::var("INTELX_BASE_URL") { + builder = builder.base_url(base_url); + } + let client = builder.build()?; + + let results = client + .search(intelx::SearchParams::new("riseup.net").maxresults(1000)) + .await?; + let stats = intelx::stats(&results); + println!("{stats:#?}"); + + Ok(()) +} diff --git a/Rust/intelx/src/client.rs b/Rust/intelx/src/client.rs new file mode 100644 index 0000000..7a4f107 --- /dev/null +++ b/Rust/intelx/src/client.rs @@ -0,0 +1,264 @@ +//! The base HTTP client shared by every endpoint module. + +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::{IntelXError, Result, api_error_from_status}; + +const DEFAULT_BASE_URL: &str = "https://2.intelx.io"; +const DEFAULT_USER_AGENT: &str = concat!("IX-Rust/", env!("CARGO_PKG_VERSION")); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_RATE_LIMIT: Duration = Duration::from_secs(1); + +/// An async client for the general Intelligence X API (default base URL `https://2.intelx.io`). +/// +/// Construct one with [`IntelXClient::new`] for the common case, or [`IntelXClient::builder`] +/// to configure a custom base URL, proxy, timeout, or rate limit. +#[derive(Clone, Debug)] +pub struct IntelXClient { + pub(crate) http: reqwest::Client, + pub(crate) base_url: url::Url, + pub(crate) api_key: String, + pub(crate) rate_limit: Duration, +} + +/// Builder for [`IntelXClient`]. +pub struct IntelXClientBuilder { + api_key: Option, + base_url: String, + user_agent: String, + proxy: Option, + danger_accept_invalid_certs: bool, + timeout: Duration, + rate_limit: Duration, +} + +impl Default for IntelXClientBuilder { + fn default() -> Self { + Self { + api_key: None, + base_url: DEFAULT_BASE_URL.to_string(), + user_agent: DEFAULT_USER_AGENT.to_string(), + proxy: None, + danger_accept_invalid_certs: false, + timeout: DEFAULT_TIMEOUT, + rate_limit: DEFAULT_RATE_LIMIT, + } + } +} + +impl IntelXClientBuilder { + /// Sets the API key sent as the `X-Key` header on every request. Required. + pub fn api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + + /// Overrides the base URL. Defaults to `https://2.intelx.io`. + pub fn base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + /// Overrides the `User-Agent` header. Defaults to `IX-Rust/`. + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + + /// Routes all requests through the given proxy URL. + pub fn proxy(mut self, proxy_url: impl Into) -> Self { + self.proxy = Some(proxy_url.into()); + self + } + + /// Disables TLS certificate verification. Mirrors the Python SDK's `verify=False`. + /// + /// This is dangerous: only use it against a known/trusted endpoint (e.g. for local + /// testing through an intercepting proxy). + pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self { + self.danger_accept_invalid_certs = accept; + self + } + + /// Sets the per-request HTTP timeout. Defaults to 30 seconds. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Sets the delay applied before each request to stay within the API's rate limit. + /// Defaults to 1 second, matching the Python SDK's `API_RATE_LIMIT`. + pub fn rate_limit(mut self, rate_limit: Duration) -> Self { + self.rate_limit = rate_limit; + self + } + + /// Builds the [`IntelXClient`]. + /// + /// Returns [`IntelXError::MissingApiKey`] if no API key was set, or + /// [`IntelXError::InvalidUrl`] if the configured base URL doesn't parse. + pub fn build(self) -> Result { + let api_key = self.api_key.ok_or(IntelXError::MissingApiKey)?; + let base_url = url::Url::parse(&self.base_url)?; + + let mut headers = HeaderMap::new(); + headers.insert( + "X-Key", + HeaderValue::from_str(&api_key).map_err(|_| IntelXError::Api { + status: 0, + message: "invalid API key header value".into(), + })?, + ); + + let mut builder = reqwest::Client::builder() + .user_agent(self.user_agent) + .default_headers(headers) + .timeout(self.timeout) + .danger_accept_invalid_certs(self.danger_accept_invalid_certs); + + if let Some(proxy) = self.proxy { + builder = builder.proxy(reqwest::Proxy::all(proxy)?); + } + + let http = builder.build()?; + + Ok(IntelXClient { + http, + base_url, + api_key, + rate_limit: self.rate_limit, + }) + } +} + +impl IntelXClient { + /// Creates a builder for configuring a client. + pub fn builder() -> IntelXClientBuilder { + IntelXClientBuilder::default() + } + + /// Creates a client for `api_key` using all other defaults, equivalent to Python's + /// `intelx(api_key)`. + pub fn new(api_key: impl Into) -> Result { + Self::builder().api_key(api_key).build() + } + + /// The configured base URL. + pub fn base_url(&self) -> &url::Url { + &self.base_url + } + + /// The configured API key. + pub fn api_key(&self) -> &str { + &self.api_key + } + + /// Returns the current user's API capabilities (allowed buckets, per-endpoint credits, + /// concurrent search limits). Mirrors the Python SDK's `GET_CAPABILITIES()`. + pub async fn get_capabilities(&self) -> Result { + self.rate_limit_sleep().await; + let no_params: [(&str, &str); 0] = []; + self.get("/authenticate/info", &no_params).await + } + + fn resolve_url(&self, path: &str) -> Result { + if path.starts_with("http://") || path.starts_with("https://") { + Ok(url::Url::parse(path)?) + } else { + Ok(self.base_url.join(path.trim_start_matches('/'))?) + } + } + + /// Sleeps for the configured rate-limit duration before issuing a request, mirroring the + /// Python SDK's `time.sleep(self.API_RATE_LIMIT)`. + pub(crate) async fn rate_limit_sleep(&self) { + tokio::time::sleep(self.rate_limit).await; + } + + pub(crate) async fn get_response( + &self, + path: &str, + query: &(impl Serialize + ?Sized), + ) -> Result { + let url = self.resolve_url(path)?; + let response = self.http.get(url).query(query).send().await?; + Ok(response) + } + + pub(crate) async fn get( + &self, + path: &str, + query: &(impl Serialize + ?Sized), + ) -> Result { + let response = self.get_response(path, query).await?; + Self::deserialize_or_error(response).await + } + + pub(crate) async fn post_json( + &self, + path: &str, + body: &B, + ) -> Result { + let url = self.resolve_url(path)?; + let response = self.http.post(url).json(body).send().await?; + Self::deserialize_or_error(response).await + } + + async fn deserialize_or_error(response: reqwest::Response) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(api_error_from_status(status)); + } + let bytes = response.bytes().await?; + Ok(serde_json::from_slice(&bytes)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_fails_without_api_key() { + let err = IntelXClient::builder().build().unwrap_err(); + assert!(matches!(err, IntelXError::MissingApiKey)); + } + + #[test] + fn builder_applies_python_compatible_defaults() { + let client = IntelXClient::new("test-key").unwrap(); + assert_eq!(client.base_url().as_str(), "https://2.intelx.io/"); + assert_eq!(client.api_key(), "test-key"); + assert_eq!(client.rate_limit, DEFAULT_RATE_LIMIT); + } + + #[test] + fn builder_overrides_are_applied() { + let client = IntelXClient::builder() + .api_key("k") + .base_url("https://example.test") + .rate_limit(Duration::from_millis(0)) + .build() + .unwrap(); + assert_eq!(client.base_url().as_str(), "https://example.test/"); + assert_eq!(client.rate_limit, Duration::from_millis(0)); + } + + #[test] + fn resolve_url_joins_relative_paths_against_base() { + let client = IntelXClient::new("k").unwrap(); + let url = client.resolve_url("/intelligent/search").unwrap(); + assert_eq!(url.as_str(), "https://2.intelx.io/intelligent/search"); + } + + #[test] + fn resolve_url_leaves_absolute_urls_untouched() { + let client = IntelXClient::new("k").unwrap(); + let url = client.resolve_url("https://other.example/x").unwrap(); + assert_eq!(url.as_str(), "https://other.example/x"); + } +} diff --git a/Rust/intelx/src/error.rs b/Rust/intelx/src/error.rs new file mode 100644 index 0000000..ea352dd --- /dev/null +++ b/Rust/intelx/src/error.rs @@ -0,0 +1,116 @@ +//! Error types returned by every fallible operation in this crate. + +use thiserror::Error; + +/// The crate-wide result alias: every public, fallible function in `intelx` +/// returns `Result` rather than Python's mixed bool/int/status-code return values. +pub type Result = std::result::Result; + +/// Errors that can occur while talking to the Intelligence X API. +#[derive(Debug, Error)] +pub enum IntelXError { + /// The underlying HTTP transport failed (connection, TLS, etc). + #[error("HTTP transport error: {0}")] + Http(#[from] reqwest::Error), + + /// A response body could not be deserialized as the expected JSON shape. + #[error("JSON (de)serialization error: {0}")] + Json(#[from] serde_json::Error), + + /// A local file system operation failed (writing a downloaded file, etc). + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// The configured base URL could not be parsed. + #[error("invalid base URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + /// The API responded with a non-success HTTP status code. + #[error("API error {status}: {message}")] + Api { + /// The HTTP status code returned by the server. + status: u16, + /// A human-readable description, see [`describe_status`]. + message: String, + }, + + /// `/intelligent/search` (or `/phonebook/search`) rejected the search term + /// as not being a supported "strong selector" (status == 1 in the API). + #[error("invalid search term: the API rejected this term as not a supported selector")] + InvalidTerm, + + /// The account has reached its maximum number of concurrent searches. + #[error("maximum concurrent searches reached")] + MaxConcurrentSearches, + + /// The given search id is unknown to the API (it may have expired or already finished). + #[error("search id {0} not found")] + SearchNotFound(uuid::Uuid), + + /// A file download response had no usable filename in its `Content-Disposition` header. + #[error("download response had no usable filename in the Content-Disposition header")] + MissingFilename, + + /// `IntelXClientBuilder::build` was called without an API key. + #[error("an API key is required: call `.api_key(..)` on the builder before `.build()`")] + MissingApiKey, + + /// A polling/search operation exceeded a caller-supplied deadline. + #[error("operation timed out")] + Timeout(#[from] tokio::time::error::Elapsed), +} + +/// Maps an HTTP/API status code to a short human-readable description, mirroring the table +/// used by the Python SDK's `intelx.get_error()`. +pub fn describe_status(code: u16) -> &'static str { + match code { + 200 => "200 | Success", + 204 => "204 | No Content", + 400 => "400 | Bad Request", + 401 => "401 | Unauthorized", + 402 => "402 | Payment required", + 404 => "404 | Not Found", + 1 => "1 | Invalid term", + _ => "Unknown status code", + } +} + +pub(crate) fn api_error_from_status(status: reqwest::StatusCode) -> IntelXError { + IntelXError::Api { + status: status.as_u16(), + message: describe_status(status.as_u16()).to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn describe_status_matches_known_codes() { + assert_eq!(describe_status(200), "200 | Success"); + assert_eq!(describe_status(204), "204 | No Content"); + assert_eq!(describe_status(400), "400 | Bad Request"); + assert_eq!(describe_status(401), "401 | Unauthorized"); + assert_eq!(describe_status(402), "402 | Payment required"); + assert_eq!(describe_status(404), "404 | Not Found"); + assert_eq!(describe_status(1), "1 | Invalid term"); + } + + #[test] + fn describe_status_falls_back_for_unmapped_codes() { + assert_eq!(describe_status(418), "Unknown status code"); + } + + #[test] + fn api_error_from_status_carries_code_and_message() { + let err = api_error_from_status(reqwest::StatusCode::UNAUTHORIZED); + match err { + IntelXError::Api { status, message } => { + assert_eq!(status, 401); + assert_eq!(message, "401 | Unauthorized"); + } + other => panic!("expected Api variant, got {other:?}"), + } + } +} diff --git a/Rust/intelx/src/file.rs b/Rust/intelx/src/file.rs new file mode 100644 index 0000000..3ed53a2 --- /dev/null +++ b/Rust/intelx/src/file.rs @@ -0,0 +1,229 @@ +//! File operations: `/file/preview`, `/file/view`, `/file/read`. + +use std::path::Path; + +use crate::client::IntelXClient; +use crate::error::{IntelXError, Result, api_error_from_status}; + +/// `type` parameter for [`IntelXClient::file_read`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileReadType { + /// No content disposition. Returns the raw binary file. + Raw = 0, + /// Content disposition; may fix line endings to CRLF for text files. + ContentDisposition = 1, +} + +/// Parameters for [`IntelXClient::file_preview`], mirroring the Python SDK's `FILE_PREVIEW()`. +#[derive(Debug, Clone)] +pub struct FilePreviewParams { + /// Low-level content type (`Item::item_type`). + pub ctype: i32, + /// High-level media type (`Item::media`). + pub mediatype: i32, + /// Preview format: `0` = text, `1` = picture. + pub format: i32, + /// Storage ID of the item to preview. + pub sid: String, + /// Bucket the item was found in. Defaults to empty. + pub bucket: String, + /// `0` = don't escape HTML, `1` = default (escape). + pub escape: i32, + /// Maximum number of lines to return. Defaults to `8`. + pub lines: i32, +} + +impl FilePreviewParams { + /// Creates preview parameters with the same defaults as the Python SDK's `FILE_PREVIEW()`. + pub fn new(ctype: i32, mediatype: i32, format: i32, sid: impl Into) -> Self { + Self { + ctype, + mediatype, + format, + sid: sid.into(), + bucket: String::new(), + escape: 0, + lines: 8, + } + } + + /// Sets the bucket the item was found in. + pub fn bucket(mut self, bucket: impl Into) -> Self { + self.bucket = bucket.into(); + self + } + + /// Sets the maximum number of lines to return. + pub fn lines(mut self, lines: i32) -> Self { + self.lines = lines; + self + } +} + +/// Picks the `/file/view` format code for a given content/media type, mirroring the Python +/// SDK's `FILE_VIEW()` if/elif branch. +/// +/// Pulled out as a pure function so the format-selection logic is unit-testable without an +/// HTTP call. +pub(crate) fn resolve_view_format(ctype: i32, mediatype: i32) -> i32 { + match mediatype { + 23 | 9 => 7, // HTML + 15 => 6, // PDF + 16 => 8, // Word + 18 => 10, // PowerPoint + 25 => 11, // Ebook + 17 => 9, // Excel + _ if ctype == 1 => 0, // Text + _ => 1, // Hex view fallback + } +} + +impl IntelXClient { + /// Shows a preview of a file's contents based on its storage ID. Previews are capped at + /// 1000 characters server-side. Mirrors the Python SDK's `FILE_PREVIEW()`. + pub async fn file_preview(&self, params: FilePreviewParams) -> Result { + self.rate_limit_sleep().await; + let response = self + .get_response( + "/file/preview", + &[ + ("c", params.ctype.to_string()), + ("m", params.mediatype.to_string()), + ("f", params.format.to_string()), + ("sid", params.sid), + ("b", params.bucket), + ("e", params.escape.to_string()), + ("l", params.lines.to_string()), + ], + ) + .await?; + Ok(response.text().await?) + } + + /// Shows a file's contents based on its storage ID, auto-selecting the view format from + /// the item's content/media type. Mirrors the Python SDK's `FILE_VIEW()`. + pub async fn file_view( + &self, + ctype: i32, + mediatype: i32, + sid: &str, + bucket: &str, + escape: i32, + ) -> Result { + self.rate_limit_sleep().await; + let format = resolve_view_format(ctype, mediatype); + let response = self + .get_response( + "/file/view", + &[ + ("f", format.to_string()), + ("storageid", sid.to_string()), + ("bucket", bucket.to_string()), + ("escape", escape.to_string()), + ], + ) + .await?; + Ok(response.text().await?) + } + + /// Reads a file's raw contents and streams it to `dest`. Use this for direct data download. + /// Returns the number of bytes written. Mirrors the Python SDK's `FILE_READ()`. + pub async fn file_read( + &self, + system_id: &str, + kind: FileReadType, + bucket: &str, + dest: &Path, + ) -> Result { + self.rate_limit_sleep().await; + let response = self + .get_response( + "/file/read", + &[ + ("type", (kind as i32).to_string()), + ("systemid", system_id.to_string()), + ("bucket", bucket.to_string()), + ], + ) + .await?; + + if !response.status().is_success() { + return Err(api_error_from_status(response.status())); + } + + let bytes = response.bytes().await?; + tokio::fs::write(dest, &bytes).await?; + Ok(bytes.len() as u64) + } + + /// Shows a treeview of an item that has multiple files/folders. Returns `None` if the + /// server reports it could not generate a tree (matching Python's `"Could not generate"` + /// substring check). Mirrors the Python SDK's `FILE_TREE_VIEW()`. + pub async fn file_tree_view(&self, sid: &str) -> Result> { + self.rate_limit_sleep().await; + let response = self + .http_get_with_timeout( + "/file/view", + &[("f", "12".to_string()), ("storageid", sid.to_string())], + 5, + ) + .await?; + let text = response.text().await?; + if text.contains("Could not generate") { + Ok(None) + } else { + Ok(Some(text)) + } + } + + /// Fetches the tree view for an item as JSON. Use the storage ID from a search result's + /// `historyfile` (historical website copies) or `indexfile` (indexed sub-pages) field. + /// Mirrors the Python SDK's `treeview()`. + pub async fn treeview(&self, id: &str, bucket: &str) -> Result { + self.rate_limit_sleep().await; + self.get( + "/file/view", + &[ + ("f", "13".to_string()), + ("storageid", id.to_string()), + ("bucket", bucket.to_string()), + ], + ) + .await + } + + async fn http_get_with_timeout( + &self, + path: &str, + query: &[(&str, String)], + timeout_secs: u64, + ) -> Result { + match tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + self.get_response(path, query), + ) + .await + { + Ok(result) => result, + Err(elapsed) => Err(IntelXError::Timeout(elapsed)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_view_format_matches_python_branches() { + assert_eq!(resolve_view_format(0, 23), 7); // HTML by media + assert_eq!(resolve_view_format(0, 9), 7); // HTML copy of website + assert_eq!(resolve_view_format(0, 15), 6); // PDF + assert_eq!(resolve_view_format(0, 16), 8); // Word + assert_eq!(resolve_view_format(0, 18), 10); // PowerPoint + assert_eq!(resolve_view_format(0, 25), 11); // Ebook + assert_eq!(resolve_view_format(0, 17), 9); // Excel + assert_eq!(resolve_view_format(1, 0), 0); // ctype == 1 -> text + assert_eq!(resolve_view_format(0, 0), 1); // fallback -> hex + } +} diff --git a/Rust/intelx/src/identity.rs b/Rust/intelx/src/identity.rs new file mode 100644 index 0000000..a0bce52 --- /dev/null +++ b/Rust/intelx/src/identity.rs @@ -0,0 +1,307 @@ +//! The Identity Service: leaked-account search and reverse-domain lookup against +//! `https://3.intelx.io`. + +use std::time::Duration; + +use crate::client::{IntelXClient, IntelXClientBuilder}; +use crate::error::{IntelXError, Result, api_error_from_status}; +use crate::models::{ + AccountRecord, ExportAccountsParams, IdSearchParams, IdSearchResultPage, IdentityRecord, + ReverseDomainParams, ReverseDomainRecord, +}; + +const DEFAULT_IDENTITY_BASE_URL: &str = "https://3.intelx.io"; + +/// An async client for the Intelligence X Identity Service (leaked accounts, reverse-domain +/// lookup), default base URL `https://3.intelx.io`. +/// +/// Wraps an [`IntelXClient`] rather than extending it (composition over the Python SDK's +/// `IdentityService(intelx)` inheritance), since this client only exposes a small, disjoint +/// method set. +#[derive(Clone)] +pub struct IdentityClient { + inner: IntelXClient, +} + +/// Builder for [`IdentityClient`]. +pub struct IdentityClientBuilder { + inner: IntelXClientBuilder, +} + +impl Default for IdentityClientBuilder { + fn default() -> Self { + Self { + inner: IntelXClientBuilder::default().base_url(DEFAULT_IDENTITY_BASE_URL), + } + } +} + +impl IdentityClientBuilder { + /// Sets the API key sent as the `X-Key` header on every request. Required. + pub fn api_key(mut self, api_key: impl Into) -> Self { + self.inner = self.inner.api_key(api_key); + self + } + + /// Overrides the base URL. Defaults to `https://3.intelx.io`. + pub fn base_url(mut self, base_url: impl Into) -> Self { + self.inner = self.inner.base_url(base_url); + self + } + + /// Overrides the `User-Agent` header. + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.inner = self.inner.user_agent(user_agent); + self + } + + /// Routes all requests through the given proxy URL. + pub fn proxy(mut self, proxy_url: impl Into) -> Self { + self.inner = self.inner.proxy(proxy_url); + self + } + + /// Sets the per-request HTTP timeout. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.inner = self.inner.timeout(timeout); + self + } + + /// Sets the delay applied before each request to stay within the API's rate limit. + pub fn rate_limit(mut self, rate_limit: Duration) -> Self { + self.inner = self.inner.rate_limit(rate_limit); + self + } + + /// Builds the [`IdentityClient`]. + pub fn build(self) -> Result { + Ok(IdentityClient { + inner: self.inner.build()?, + }) + } +} + +impl IdentityClient { + /// Creates a builder for configuring an identity client. + pub fn builder() -> IdentityClientBuilder { + IdentityClientBuilder::default() + } + + /// Creates an identity client for `api_key` using all other defaults. + pub fn new(api_key: impl Into) -> Result { + Self::builder().api_key(api_key).build() + } + + /// Fetches a page of results for a previously started identity search. + /// + /// Mirrors the Python SDK's `IdentityService.get_search_results()`. + pub async fn get_search_results( + &self, + id: uuid::Uuid, + format: i32, + maxresults: i32, + ) -> Result { + self.inner.rate_limit_sleep().await; + self.inner + .get( + "/live/search/result", + &[ + ("id", id.to_string()), + ("format", format.to_string()), + ("limit", maxresults.to_string()), + ], + ) + .await + } + + /// Runs an identity (leaked-data) search to completion against `/live/search/internal`, + /// polling until the API reports the search is terminated/not-found or `maxresults` has + /// been satisfied. + /// + /// Mirrors the Python SDK's `idsearch()`. + pub async fn idsearch(&self, params: IdSearchParams) -> Result> { + let mut remaining = params.maxresults; + let search_id: uuid::Uuid = { + let response: serde_json::Value = self + .inner + .get( + "/live/search/internal", + &[ + ("selector", params.term), + ("bucket", params.bucket), + ("skipinvalid", params.skip_invalid.to_string()), + ("limit", remaining.to_string()), + ("analyze", params.analyze.to_string()), + ("datefrom", params.datefrom), + ("dateto", params.dateto), + ], + ) + .await?; + let id = response + .get("id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()); + id.ok_or_else(|| IntelXError::Api { + status: 200, + message: "missing search id in response".into(), + })? + }; + + let mut results = Vec::new(); + loop { + self.inner.rate_limit_sleep().await; + let page = self.get_search_results(search_id, 1, remaining).await?; + if page.status == 0 || page.status == 2 { + remaining -= page.records.len() as i32; + results.extend(page.records); + } + let exhausted = remaining <= 0; + if page.status == 2 || page.status == 3 || exhausted { + if exhausted || page.status == 3 { + let _ = self.terminate_search(search_id).await; + } + break; + } + } + Ok(results) + } + + /// Terminates a previously started identity search. Mirrors the Python SDK's + /// `terminate_search()`. + pub async fn terminate_search(&self, id: uuid::Uuid) -> Result<()> { + let response = self + .inner + .get_response("/live/search/internal", &[("id", id.to_string())]) + .await?; + if response.status().is_success() || response.status() == reqwest::StatusCode::NO_CONTENT { + Ok(()) + } else { + Err(api_error_from_status(response.status())) + } + } + + /// Exports leaked accounts for `params.term` (an email address or domain). Mirrors the + /// Python SDK's `export_accounts()`. + pub async fn export_accounts( + &self, + params: ExportAccountsParams, + ) -> Result> { + let mut remaining = params.maxresults; + let response: serde_json::Value = self + .inner + .get( + "/accounts/csv", + &[ + ("selector", params.term), + ("bucket", params.bucket), + ("limit", remaining.to_string()), + ("datefrom", params.datefrom), + ("dateto", params.dateto), + ], + ) + .await?; + let search_id: uuid::Uuid = response + .get("id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .ok_or_else(|| IntelXError::Api { + status: 200, + message: "missing search id in response".into(), + })?; + + let mut results: Vec = Vec::new(); + loop { + self.inner.rate_limit_sleep().await; + let page: serde_json::Value = self + .inner + .get( + "/live/search/result", + &[ + ("id", search_id.to_string()), + ("limit", remaining.to_string()), + ], + ) + .await?; + let status = page.get("status").and_then(|v| v.as_i64()).unwrap_or(0); + let records: Vec = page + .get("records") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + if status == 0 || status == 2 { + remaining -= records.len() as i32; + results.extend(records); + } + let exhausted = remaining <= 0; + if status == 2 || exhausted { + if exhausted { + let _ = self.terminate_search(search_id).await; + } + break; + } + } + Ok(results) + } + + /// Performs a reverse-domain lookup for `params.term`. Mirrors the Python SDK's + /// `reverse_domain()`. + pub async fn reverse_domain( + &self, + params: ReverseDomainParams, + ) -> Result> { + let mut remaining = params.maxresults; + let response: serde_json::Value = self + .inner + .get( + "/reverse/domain", + &[ + ("selector", params.term), + ("limit", remaining.to_string()), + ("datefrom", params.datefrom), + ("dateto", params.dateto), + ], + ) + .await?; + let search_id: uuid::Uuid = response + .get("id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .ok_or_else(|| IntelXError::Api { + status: 200, + message: "missing search id in response".into(), + })?; + + let mut results: Vec = Vec::new(); + loop { + self.inner.rate_limit_sleep().await; + let page: serde_json::Value = self + .inner + .get( + "/live/search/result", + &[ + ("id", search_id.to_string()), + ("limit", remaining.to_string()), + ], + ) + .await?; + let status = page.get("status").and_then(|v| v.as_i64()).unwrap_or(0); + let records: Vec = page + .get("records") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + if status == 0 || status == 2 { + remaining -= records.len() as i32; + results.extend(records); + } + let exhausted = remaining <= 0; + if status == 2 || exhausted { + if exhausted { + let _ = self.terminate_search(search_id).await; + } + break; + } + } + Ok(results) + } +} diff --git a/Rust/intelx/src/lib.rs b/Rust/intelx/src/lib.rs new file mode 100644 index 0000000..0897e18 --- /dev/null +++ b/Rust/intelx/src/lib.rs @@ -0,0 +1,57 @@ +#![warn(missing_docs)] +//! Async Rust SDK for the [Intelligence X](https://intelx.io) search engine and data archive +//! API. +//! +//! This crate is a Rust port of the +//! [Python `intelx` SDK](https://github.com/IntelligenceX/SDK/tree/master/Python), covering +//! intelligent search, phonebook search, file operations, and the Identity Service +//! (leaked-account search and reverse-domain lookup). +//! +//! # Quick start +//! +//! ```no_run +//! # async fn run() -> intelx::Result<()> { +//! let client = intelx::IntelXClient::new("00000000-0000-0000-0000-000000000000")?; +//! let results = client.search(intelx::SearchParams::new("riseup.net")).await?; +//! for record in &results { +//! println!("found {} in {}", record.name, record.bucket); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! # Date handling +//! +//! Date fields ([`Item::added`], [`Item::date`]) are kept as plain [`String`]s rather than +//! `chrono::DateTime`, because the server's `YYYY-mm-dd HH:ii:ss` format is not RFC 3339. +//! Parse them yourself if you need typed dates: +//! +//! ``` +//! # let date = "2024-01-31 23:59:59".to_string(); +//! // requires the `chrono` crate in your own Cargo.toml +//! // let parsed = chrono::NaiveDateTime::parse_from_str(&date, "%Y-%m-%d %H:%M:%S")?; +//! ``` + +mod client; +mod error; +mod file; +mod identity; +mod models; +mod phonebook; +mod search; +mod util; + +pub use client::{IntelXClient, IntelXClientBuilder}; +pub use error::{IntelXError, Result, describe_status}; +pub use file::{FilePreviewParams, FileReadType}; +pub use identity::{IdentityClient, IdentityClientBuilder}; +pub use models::{ + AccountRecord, ApiPath, Capabilities, ExportAccountsParams, ExportFormat, IdSearchParams, + IdSearchResultPage, IdentityRecord, IntelSearchRequest, IntelSearchResultPage, + IntelSearchStartResponse, Item, PanelSearchResultTag, PhonebookSearchParams, + PhonebookSearchRequest, PhonebookSearchResultPage, PhonebookSelector, PhonebookTarget, + Relationship, ReverseDomainParams, ReverseDomainRecord, SearchParams, SearchResult, + SearchStatus, SortOrder, Tag, +}; +pub use phonebook::flatten_selectors; +pub use search::stats; diff --git a/Rust/intelx/src/models/capabilities.rs b/Rust/intelx/src/models/capabilities.rs new file mode 100644 index 0000000..4ea251a --- /dev/null +++ b/Rust/intelx/src/models/capabilities.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::util::null_as_default; + +/// Per-endpoint credit information, as returned by `/authenticate/info`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiPath { + /// The API path this entry describes. + #[serde(rename = "Path")] + pub path: String, + /// Credits remaining. + #[serde(rename = "Credit")] + pub credit: i64, + /// Maximum credits for this path. + #[serde(rename = "CreditMax")] + pub credit_max: i64, + /// Seconds until the credit allowance resets. + #[serde(rename = "CreditReset")] + pub credit_reset: i64, +} + +/// The current user's API capabilities, returned by `GET /authenticate/info`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Capabilities { + /// When this capability snapshot was generated. + #[serde(default, deserialize_with = "null_as_default")] + pub added: String, + /// Bucket identifiers the user may search. + #[serde(default, deserialize_with = "null_as_default")] + pub buckets: Vec, + /// Human-readable bucket names, parallel to `buckets`. + #[serde(default, deserialize_with = "null_as_default")] + pub bucketsh: Vec, + /// Per-endpoint credit information, keyed by API path. + #[serde(default, deserialize_with = "null_as_default")] + pub paths: HashMap, + /// Number of currently active searches. + #[serde(default, deserialize_with = "null_as_default")] + pub searchesactive: i32, + /// Maximum number of concurrent searches allowed. + #[serde(default, deserialize_with = "null_as_default")] + pub maxconcurrentsearches: i32, +} diff --git a/Rust/intelx/src/models/identity.rs b/Rust/intelx/src/models/identity.rs new file mode 100644 index 0000000..d9c641e --- /dev/null +++ b/Rust/intelx/src/models/identity.rs @@ -0,0 +1,239 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::item::Item; + +/// An identity-leak result record from `/live/search/result`. +/// +/// The Identity Service's record shape is not described by the public OpenAPI schemas the way +/// `SearchResult` is, and the Python SDK never validates it either. This type captures the +/// fields known to be used by the existing Python CLI/examples and preserves any other fields +/// under `extra` so callers can still reach them via [`IdentityRecord::extra`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityRecord { + /// The underlying item meta-data, when present. + #[serde(default)] + pub item: Option, + /// Any fields not modeled above, preserved verbatim. + #[serde(flatten)] + pub extra: Map, +} + +/// A page of results from `GET /live/search/result`. +#[derive(Debug, Clone, Deserialize)] +pub struct IdSearchResultPage { + /// `0`/`1` = results available (keep polling), `2` = terminated, `3` = search id not found. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub status: i32, + /// Result records in this page. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub records: Vec, +} + +/// A leaked-account record from `/accounts/csv`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountRecord { + /// The leaked username/email. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub user: String, + /// The leaked password (may be hashed, depending on the source). + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub password: String, + /// Describes how `password` is encoded (plaintext, hash algorithm, etc). + #[serde(default)] + pub passwordtype: Value, + /// Short name of the breach source. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub sourceshort: String, + /// Any fields not modeled above, preserved verbatim. + #[serde(flatten)] + pub extra: Map, +} + +/// A reverse-domain-lookup record from `/reverse/domain`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReverseDomainRecord { + /// The leaked username/email. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub user: String, + /// The leaked password. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub password: String, + /// The URL the credentials were associated with. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub url: String, + /// Short name of the breach source. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub sourceshort: String, + /// Any fields not modeled above, preserved verbatim. + #[serde(flatten)] + pub extra: Map, +} + +/// Builder-style parameters for [`crate::IdentityClient::idsearch`], mirroring the Python SDK's +/// `IdentityService.idsearch()` keyword arguments. +#[derive(Debug, Clone)] +pub struct IdSearchParams { + /// The search term: an email address, domain, SSN, or credit card number. + pub term: String, + /// Maximum results to return. Defaults to `100`. + pub maxresults: i32, + /// Optional single bucket filter. Defaults to empty (all buckets). + pub bucket: String, + /// Timeout in seconds. Defaults to `5`. + pub timeout: i32, + /// Starting date filter, `YYYY-mm-dd HH:ii:ss`. Defaults to empty. + pub datefrom: String, + /// Ending date filter, `YYYY-mm-dd HH:ii:ss`. Defaults to empty. + pub dateto: String, + /// Prior search IDs to terminate first. + pub terminate: Vec, + /// Whether the server should run additional analysis on results. Defaults to `false`. + pub analyze: bool, + /// Whether to skip invalid entries server-side (recommended). Defaults to `false`. + pub skip_invalid: bool, +} + +impl IdSearchParams { + /// Creates identity search parameters for `term` with Python-compatible defaults. + pub fn new(term: impl Into) -> Self { + Self { + term: term.into(), + maxresults: 100, + bucket: String::new(), + timeout: 5, + datefrom: String::new(), + dateto: String::new(), + terminate: Vec::new(), + analyze: false, + skip_invalid: false, + } + } + + /// Sets the maximum number of results to return. + pub fn maxresults(mut self, maxresults: i32) -> Self { + self.maxresults = maxresults; + self + } + + /// Sets the single-bucket filter. + pub fn bucket(mut self, bucket: impl Into) -> Self { + self.bucket = bucket.into(); + self + } + + /// Sets whether to skip invalid entries server-side. + pub fn skip_invalid(mut self, skip_invalid: bool) -> Self { + self.skip_invalid = skip_invalid; + self + } + + /// Sets the starting date filter (`YYYY-mm-dd HH:ii:ss`). + pub fn datefrom(mut self, datefrom: impl Into) -> Self { + self.datefrom = datefrom.into(); + self + } + + /// Sets the ending date filter (`YYYY-mm-dd HH:ii:ss`). + pub fn dateto(mut self, dateto: impl Into) -> Self { + self.dateto = dateto.into(); + self + } +} + +/// Builder-style parameters for [`crate::IdentityClient::export_accounts`], mirroring the +/// Python SDK's `export_accounts()` keyword arguments. +#[derive(Debug, Clone)] +pub struct ExportAccountsParams { + /// The search term: a domain or email address. + pub term: String, + /// Maximum results to return. Defaults to `10`. + pub maxresults: i32, + /// Optional single bucket filter. Defaults to empty. + pub bucket: String, + /// Starting date filter. Defaults to empty. + pub datefrom: String, + /// Ending date filter. Defaults to empty. + pub dateto: String, + /// Prior search IDs to terminate first. + pub terminate: Vec, +} + +impl ExportAccountsParams { + /// Creates export-accounts parameters for `term` with Python-compatible defaults. + pub fn new(term: impl Into) -> Self { + Self { + term: term.into(), + maxresults: 10, + bucket: String::new(), + datefrom: String::new(), + dateto: String::new(), + terminate: Vec::new(), + } + } + + /// Sets the maximum number of results to return. + pub fn maxresults(mut self, maxresults: i32) -> Self { + self.maxresults = maxresults; + self + } + + /// Sets the starting date filter (`YYYY-mm-dd HH:ii:ss`). + pub fn datefrom(mut self, datefrom: impl Into) -> Self { + self.datefrom = datefrom.into(); + self + } + + /// Sets the ending date filter (`YYYY-mm-dd HH:ii:ss`). + pub fn dateto(mut self, dateto: impl Into) -> Self { + self.dateto = dateto.into(); + self + } +} + +/// Builder-style parameters for [`crate::IdentityClient::reverse_domain`], mirroring the +/// Python SDK's `reverse_domain()` keyword arguments. +#[derive(Debug, Clone)] +pub struct ReverseDomainParams { + /// The domain to look up. + pub term: String, + /// Maximum results to return. Defaults to `10`. + pub maxresults: i32, + /// Starting date filter. Defaults to empty. + pub datefrom: String, + /// Ending date filter. Defaults to empty. + pub dateto: String, + /// Prior search IDs to terminate first. + pub terminate: Vec, +} + +impl ReverseDomainParams { + /// Creates reverse-domain parameters for `term` with Python-compatible defaults. + pub fn new(term: impl Into) -> Self { + Self { + term: term.into(), + maxresults: 10, + datefrom: String::new(), + dateto: String::new(), + terminate: Vec::new(), + } + } + + /// Sets the maximum number of results to return. + pub fn maxresults(mut self, maxresults: i32) -> Self { + self.maxresults = maxresults; + self + } + + /// Sets the starting date filter (`YYYY-mm-dd HH:ii:ss`). + pub fn datefrom(mut self, datefrom: impl Into) -> Self { + self.datefrom = datefrom.into(); + self + } + + /// Sets the ending date filter (`YYYY-mm-dd HH:ii:ss`). + pub fn dateto(mut self, dateto: impl Into) -> Self { + self.dateto = dateto.into(); + self + } +} diff --git a/Rust/intelx/src/models/item.rs b/Rust/intelx/src/models/item.rs new file mode 100644 index 0000000..240d87b --- /dev/null +++ b/Rust/intelx/src/models/item.rs @@ -0,0 +1,136 @@ +use serde::{Deserialize, Serialize}; + +use crate::util::null_as_default; + +/// A meta-data tag helping classify an item's content (language, topic, etc). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tag { + /// Tag class. + pub class: i32, + /// Tag value. + pub value: String, +} + +/// A relation between two items. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Relationship { + /// Target item's system ID. + pub target: uuid::Uuid, + /// Relation type (server-defined). + pub relation: i32, +} + +/// Generic item meta-data, as used for search results. +/// +/// Every field except `systemid` is optional server-side and defaults to its zero value when +/// absent *or explicitly `null`* (the API sends `null` rather than omitting some fields), +/// mirroring the OpenAPI spec's `required: [systemid]`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + /// System identifier uniquely identifying the item. + pub systemid: uuid::Uuid, + /// Storage identifier, empty if not stored/available. + #[serde(default, deserialize_with = "null_as_default")] + pub storageid: String, + /// Whether the data of the item is in store and `storageid` is valid. + #[serde(default, deserialize_with = "null_as_default")] + pub instore: bool, + /// Size in bytes of the item data. + #[serde(default, deserialize_with = "null_as_default")] + pub size: i64, + /// Native access level of the item. + #[serde(default, deserialize_with = "null_as_default")] + pub accesslevel: i32, + /// Low-level content type (0 = Binary, 1 = Plain text, ...). + #[serde(rename = "type", default, deserialize_with = "null_as_default")] + pub item_type: i32, + /// High-level media type (1 = Paste document, ... 24 = Text file). + #[serde(default, deserialize_with = "null_as_default")] + pub media: i32, + /// When the item was added to the system, as `YYYY-mm-dd HH:ii:ss` (not RFC 3339). + #[serde(default, deserialize_with = "null_as_default")] + pub added: String, + /// When the item was discovered or created, as `YYYY-mm-dd HH:ii:ss` (not RFC 3339). + #[serde(default, deserialize_with = "null_as_default")] + pub date: String, + /// Name or title. + #[serde(default, deserialize_with = "null_as_default")] + pub name: String, + /// Full description, text only. + #[serde(default, deserialize_with = "null_as_default")] + pub description: String, + /// X-Score, ranking relevancy, 0-100. + #[serde(default, deserialize_with = "null_as_default")] + pub xscore: i32, + /// Simhash of the item data, used to compare similarity via Hamming distance. + /// + /// The OpenAPI spec documents this as `int64`, but the server actually returns the raw + /// 64-bit hash bit pattern, which can exceed `i64::MAX`. Stored as `u64` to match (the Go + /// SDK's `Item.Simhash` is `uint64` for the same reason). + #[serde(default, deserialize_with = "null_as_default")] + pub simhash: u64, + /// Bucket identifier the item was found in. + #[serde(default, deserialize_with = "null_as_default")] + pub bucket: String, + /// Meta-data tags helping in classification of the item data. + #[serde(default, deserialize_with = "null_as_default")] + pub tags: Vec, + /// Related items. + #[serde(default, deserialize_with = "null_as_default")] + pub relations: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_deserializes_with_only_systemid_present() { + let json = r#"{"systemid":"61202067-543e-4e6a-8c23-11f9b8f008cf"}"#; + let item: Item = serde_json::from_str(json).unwrap(); + assert_eq!( + item.systemid.to_string(), + "61202067-543e-4e6a-8c23-11f9b8f008cf" + ); + assert_eq!(item.bucket, ""); + assert_eq!(item.xscore, 0); + assert!(item.tags.is_empty()); + } + + #[test] + fn item_type_field_renames_to_type_keyword() { + let json = r#"{"systemid":"61202067-543e-4e6a-8c23-11f9b8f008cf","type":5}"#; + let item: Item = serde_json::from_str(json).unwrap(); + assert_eq!(item.item_type, 5); + } + + #[test] + fn item_tolerates_explicit_nulls_for_optional_fields() { + let json = r#"{ + "systemid": "61202067-543e-4e6a-8c23-11f9b8f008cf", + "storageid": null, + "size": null, + "name": null, + "tags": null, + "relations": null, + "simhash": null + }"#; + let item: Item = serde_json::from_str(json).unwrap(); + assert_eq!(item.storageid, ""); + assert_eq!(item.size, 0); + assert_eq!(item.name, ""); + assert!(item.tags.is_empty()); + assert!(item.relations.is_empty()); + assert_eq!(item.simhash, 0); + } + + #[test] + fn item_accepts_simhash_values_above_i64_max() { + let json = r#"{ + "systemid": "61202067-543e-4e6a-8c23-11f9b8f008cf", + "simhash": 12638153115695167422 + }"#; + let item: Item = serde_json::from_str(json).unwrap(); + assert_eq!(item.simhash, 12638153115695167422); + } +} diff --git a/Rust/intelx/src/models/mod.rs b/Rust/intelx/src/models/mod.rs new file mode 100644 index 0000000..252ddab --- /dev/null +++ b/Rust/intelx/src/models/mod.rs @@ -0,0 +1,29 @@ +//! Serde data models for Intelligence X API requests and responses. +//! +//! See the crate-level docs for a note on why date fields (`added`, `date`) are kept as +//! [`String`] rather than `chrono::DateTime`: the server's `YYYY-mm-dd HH:ii:ss` format is not +//! RFC 3339, and guessing a deserializer risks silently corrupting dates. Parse them yourself +//! with `chrono::NaiveDateTime::parse_from_str(&item.date, "%Y-%m-%d %H:%M:%S")` if needed. + +mod capabilities; +mod identity; +mod item; +mod phonebook; +mod search; +mod search_result; + +pub use capabilities::{ApiPath, Capabilities}; +pub use identity::{ + AccountRecord, ExportAccountsParams, IdSearchParams, IdSearchResultPage, IdentityRecord, + ReverseDomainParams, ReverseDomainRecord, +}; +pub use item::{Item, Relationship, Tag}; +pub use phonebook::{ + PhonebookSearchParams, PhonebookSearchRequest, PhonebookSearchResultPage, PhonebookSelector, + PhonebookTarget, +}; +pub use search::{ + ExportFormat, IntelSearchRequest, IntelSearchResultPage, IntelSearchStartResponse, + SearchParams, SearchStatus, SortOrder, +}; +pub use search_result::{PanelSearchResultTag, SearchResult}; diff --git a/Rust/intelx/src/models/phonebook.rs b/Rust/intelx/src/models/phonebook.rs new file mode 100644 index 0000000..644e759 --- /dev/null +++ b/Rust/intelx/src/models/phonebook.rs @@ -0,0 +1,177 @@ +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +use super::search::SortOrder; + +/// What kind of selector to search for in a phonebook search. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize_repr, Deserialize_repr)] +#[repr(i32)] +pub enum PhonebookTarget { + /// All selector types. + #[default] + All = 0, + /// Domains only. + Domains = 1, + /// Email addresses only. + EmailAddresses = 2, + /// URLs only. + Urls = 3, +} + +/// Request body for `POST /phonebook/search`. +#[derive(Debug, Clone, Serialize)] +pub struct PhonebookSearchRequest { + /// The search term. + pub term: String, + /// Buckets to search. Empty means all buckets the account has access to. + pub buckets: Vec, + /// Always `0`; reserved by the API. + pub lookuplevel: i32, + /// Maximum results to return. + pub maxresults: i32, + /// Timeout in seconds for the search. + pub timeout: i32, + /// Starting date filter, `YYYY-mm-dd HH:ii:ss` or empty. + pub datefrom: String, + /// Ending date filter, `YYYY-mm-dd HH:ii:ss` or empty. + pub dateto: String, + /// Sort order for results. + pub sort: SortOrder, + /// Media type filter. `0` means all media types. + pub media: i32, + /// IDs of previous searches to terminate before starting this one. + pub terminate: Vec, + /// Which selector types to return. + pub target: PhonebookTarget, +} + +/// A single selector discovered by a phonebook search. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhonebookSelector { + /// Numeric selector type. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub selectortype: i32, + /// Human-readable selector type. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub selectortypeh: String, + /// The selector's value (e.g. an email address or domain). + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub selectorvalue: String, +} + +/// A page of results from `GET /phonebook/search/result`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhonebookSearchResultPage { + /// Selectors discovered in this page. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub selectors: Vec, + /// Status of the search result. + pub status: super::search::SearchStatus, +} + +/// Builder-style parameters for [`crate::IntelXClient::phonebook_search_all`], mirroring the +/// Python SDK's `phonebooksearch()` keyword arguments. +#[derive(Debug, Clone)] +pub struct PhonebookSearchParams { + /// The search term. + pub term: String, + /// Maximum results to return. Defaults to `1000`, matching Python's `phonebooksearch()`. + pub maxresults: i32, + /// Buckets to search. Defaults to empty (all buckets). + pub buckets: Vec, + /// Timeout in seconds. Defaults to `5`. + pub timeout: i32, + /// Starting date filter. Defaults to empty. + pub datefrom: String, + /// Ending date filter. Defaults to empty. + pub dateto: String, + /// Sort order. Defaults to [`SortOrder::DateDesc`]. + pub sort: SortOrder, + /// Media type filter. Defaults to `0`. + pub media: i32, + /// Prior search IDs to terminate first. + pub terminate: Vec, + /// Which selector types to return. Defaults to [`PhonebookTarget::All`]. + pub target: PhonebookTarget, +} + +impl PhonebookSearchParams { + /// Creates phonebook search parameters for `term` with Python-compatible defaults. + pub fn new(term: impl Into) -> Self { + Self { + term: term.into(), + maxresults: 1000, + buckets: Vec::new(), + timeout: 5, + datefrom: String::new(), + dateto: String::new(), + sort: SortOrder::DateDesc, + media: 0, + terminate: Vec::new(), + target: PhonebookTarget::All, + } + } + + /// Sets the maximum number of results to return. + pub fn maxresults(mut self, maxresults: i32) -> Self { + self.maxresults = maxresults; + self + } + + /// Sets the buckets to search. + pub fn buckets(mut self, buckets: impl IntoIterator>) -> Self { + self.buckets = buckets.into_iter().map(Into::into).collect(); + self + } + + /// Sets which selector types to return. + pub fn target(mut self, target: PhonebookTarget) -> Self { + self.target = target; + self + } + + pub(crate) fn into_request(self) -> PhonebookSearchRequest { + PhonebookSearchRequest { + term: self.term, + buckets: self.buckets, + lookuplevel: 0, + maxresults: self.maxresults, + timeout: self.timeout, + datefrom: self.datefrom, + dateto: self.dateto, + sort: self.sort, + media: self.media, + terminate: self.terminate, + target: self.target, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phonebook_search_request_serializes_with_python_compatible_field_names() { + let req = PhonebookSearchParams::new("info@intelx.io").into_request(); + let value = serde_json::to_value(&req).unwrap(); + let obj = value.as_object().unwrap(); + for key in [ + "term", + "buckets", + "lookuplevel", + "maxresults", + "timeout", + "datefrom", + "dateto", + "sort", + "media", + "terminate", + "target", + ] { + assert!(obj.contains_key(key), "missing key: {key}"); + } + assert_eq!(obj["maxresults"], 1000); + assert_eq!(obj["target"], 0); + } +} diff --git a/Rust/intelx/src/models/search.rs b/Rust/intelx/src/models/search.rs new file mode 100644 index 0000000..0f81fa9 --- /dev/null +++ b/Rust/intelx/src/models/search.rs @@ -0,0 +1,251 @@ +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +use super::search_result::SearchResult; + +/// Result-sort order for `/intelligent/search` and `/phonebook/search`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize_repr, Deserialize_repr)] +#[repr(i32)] +pub enum SortOrder { + /// No sorting. + NoSort = 0, + /// X-Score ascending: least relevant items first. + XScoreAsc = 1, + /// X-Score descending: most relevant items first. + XScoreDesc = 2, + /// Date ascending: oldest items first. + DateAsc = 3, + /// Date descending: newest items first. The API default. + #[default] + DateDesc = 4, +} + +/// Export format for `/intelligent/search/export`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)] +#[repr(i32)] +pub enum ExportFormat { + /// CSV summary file. + Csv = 0, + /// ZIP archive containing the CSV summary and binary files. + Zip = 1, +} + +/// Result status returned by `/intelligent/search/result` and `/phonebook/search/result`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)] +#[repr(i32)] +pub enum SearchStatus { + /// Success with results; the client should keep polling for more. + Success = 0, + /// No more results available (this response may still contain results). + NoMoreResults = 1, + /// Search ID not found. + NotFound = 2, + /// No results yet available; keep trying. + Pending = 3, + /// An error occurred. + Error = 4, +} + +/// Request body for `POST /intelligent/search`. +#[derive(Debug, Clone, Serialize)] +pub struct IntelSearchRequest { + /// The search term. Must be a "strong selector" (email, domain, IP, hash, etc). + pub term: String, + /// Buckets to search. Empty means all buckets the account has access to. + pub buckets: Vec, + /// Always `0`; reserved by the API. + pub lookuplevel: i32, + /// Maximum results to return per bucket. + pub maxresults: i32, + /// Timeout in seconds for the search. `0` uses the server default. + pub timeout: i32, + /// Starting date filter, `YYYY-mm-dd HH:ii:ss` or empty. + pub datefrom: String, + /// Ending date filter, `YYYY-mm-dd HH:ii:ss` or empty. + pub dateto: String, + /// Sort order for results. + pub sort: SortOrder, + /// Media type filter. `0` means all media types. + pub media: i32, + /// IDs of previous searches to terminate before starting this one. + pub terminate: Vec, +} + +/// Response to `POST /intelligent/search`. +#[derive(Debug, Clone, Deserialize)] +pub struct IntelSearchStartResponse { + /// The new search's ID, used to fetch results. Absent if `status != 0`. + pub id: Option, + /// `0` = success (id is valid), `1` = invalid term, `2` = max concurrent searches reached. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub status: i32, + /// Warning that the term resolved to soft (low-quality/generic) selectors. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub softselectorwarning: bool, +} + +/// A page of results from `GET /intelligent/search/result`. +#[derive(Debug, Clone, Deserialize)] +pub struct IntelSearchResultPage { + /// Result records in this page. + #[serde(default, deserialize_with = "crate::util::null_as_default")] + pub records: Vec, + /// Status of the search result; see [`SearchStatus`]. + pub status: SearchStatus, +} + +/// Builder-style parameters for [`crate::IntelXClient::search`] and +/// [`crate::IntelXClient::intel_search`], mirroring the Python SDK's +/// `INTEL_SEARCH`/`search()` keyword arguments. +#[derive(Debug, Clone)] +pub struct SearchParams { + /// The search term. Must be a "strong selector" (email, domain, IP, hash, etc). + pub term: String, + /// Maximum results to return per bucket. Defaults to `100`. + pub maxresults: i32, + /// Buckets to search. Defaults to empty (all buckets). + pub buckets: Vec, + /// Timeout in seconds for the search. Defaults to `5`. + pub timeout: i32, + /// Starting date filter, `YYYY-mm-dd HH:ii:ss`. Defaults to empty (unset). + pub datefrom: String, + /// Ending date filter, `YYYY-mm-dd HH:ii:ss`. Defaults to empty (unset). + pub dateto: String, + /// Sort order for results. Defaults to [`SortOrder::DateDesc`]. + pub sort: SortOrder, + /// Media type filter. Defaults to `0` (all media types). + pub media: i32, + /// IDs of previous searches to terminate before starting this one. + pub terminate: Vec, +} + +impl SearchParams { + /// Creates search parameters for `term` with the same defaults as the Python SDK's + /// `search()`/`INTEL_SEARCH()`: `maxresults=100`, `buckets=[]`, `timeout=5`, `sort=DateDesc`, + /// `media=0`. + pub fn new(term: impl Into) -> Self { + Self { + term: term.into(), + maxresults: 100, + buckets: Vec::new(), + timeout: 5, + datefrom: String::new(), + dateto: String::new(), + sort: SortOrder::DateDesc, + media: 0, + terminate: Vec::new(), + } + } + + /// Sets the maximum number of results to return per bucket. + pub fn maxresults(mut self, maxresults: i32) -> Self { + self.maxresults = maxresults; + self + } + + /// Sets the buckets to search. + pub fn buckets(mut self, buckets: impl IntoIterator>) -> Self { + self.buckets = buckets.into_iter().map(Into::into).collect(); + self + } + + /// Sets the search timeout, in seconds. + pub fn timeout(mut self, timeout: i32) -> Self { + self.timeout = timeout; + self + } + + /// Sets the starting date filter (`YYYY-mm-dd HH:ii:ss`). + pub fn datefrom(mut self, datefrom: impl Into) -> Self { + self.datefrom = datefrom.into(); + self + } + + /// Sets the ending date filter (`YYYY-mm-dd HH:ii:ss`). + pub fn dateto(mut self, dateto: impl Into) -> Self { + self.dateto = dateto.into(); + self + } + + /// Sets the result sort order. + pub fn sort(mut self, sort: SortOrder) -> Self { + self.sort = sort; + self + } + + /// Sets the media type filter. + pub fn media(mut self, media: i32) -> Self { + self.media = media; + self + } + + /// Sets prior search IDs to terminate before starting this search. + pub fn terminate(mut self, terminate: impl IntoIterator) -> Self { + self.terminate = terminate.into_iter().collect(); + self + } + + pub(crate) fn into_request(self) -> IntelSearchRequest { + IntelSearchRequest { + term: self.term, + buckets: self.buckets, + lookuplevel: 0, + maxresults: self.maxresults, + timeout: self.timeout, + datefrom: self.datefrom, + dateto: self.dateto, + sort: self.sort, + media: self.media, + terminate: self.terminate, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intel_search_request_serializes_with_python_compatible_field_names() { + let req = SearchParams::new("riseup.net").into_request(); + let value = serde_json::to_value(&req).unwrap(); + let obj = value.as_object().unwrap(); + for key in [ + "term", + "buckets", + "lookuplevel", + "maxresults", + "timeout", + "datefrom", + "dateto", + "sort", + "media", + "terminate", + ] { + assert!(obj.contains_key(key), "missing key: {key}"); + } + assert_eq!(obj["term"], "riseup.net"); + assert_eq!(obj["lookuplevel"], 0); + assert_eq!(obj["maxresults"], 100); + assert_eq!(obj["sort"], 4); + } + + #[test] + fn search_status_round_trips_via_repr() { + let json = "0"; + let status: SearchStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status, SearchStatus::Success); + assert_eq!(serde_json::to_string(&SearchStatus::Pending).unwrap(), "3"); + } + + #[test] + fn search_params_builder_overrides_defaults() { + let params = SearchParams::new("test.com") + .maxresults(50) + .buckets(["pastes", "darknet.i2p"]) + .sort(SortOrder::XScoreDesc); + assert_eq!(params.maxresults, 50); + assert_eq!(params.buckets, vec!["pastes", "darknet.i2p"]); + assert_eq!(params.sort, SortOrder::XScoreDesc); + } +} diff --git a/Rust/intelx/src/models/search_result.rs b/Rust/intelx/src/models/search_result.rs new file mode 100644 index 0000000..fd02265 --- /dev/null +++ b/Rust/intelx/src/models/search_result.rs @@ -0,0 +1,90 @@ +use std::ops::{Deref, DerefMut}; + +use serde::{Deserialize, Serialize}; + +use super::item::Item; +use crate::util::null_as_default; + +/// A tag in human-readable form, as attached to a [`SearchResult`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PanelSearchResultTag { + /// Tag class. + pub class: i32, + /// Human-friendly tag class. + #[serde(default, deserialize_with = "null_as_default")] + pub classh: String, + /// Tag value. + pub value: String, + /// Human-friendly tag value. + #[serde(default, deserialize_with = "null_as_default")] + pub valueh: String, +} + +/// A single search result record. Extends [`Item`] with human-readable fields. +/// +/// `SearchResult` derefs to [`Item`], so `result.bucket` works directly instead of requiring +/// `result.item.bucket`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + /// The underlying item meta-data. + #[serde(flatten)] + pub item: Item, + /// Human friendly access level info. + #[serde(default, deserialize_with = "null_as_default")] + pub accesslevelh: String, + /// Human friendly media type info. + #[serde(default, deserialize_with = "null_as_default")] + pub mediah: String, + /// Human friendly simhash. + #[serde(default, deserialize_with = "null_as_default")] + pub simhashh: String, + /// Human friendly content type info. + #[serde(default, deserialize_with = "null_as_default")] + pub typeh: String, + /// Human friendly tags. + #[serde(default, deserialize_with = "null_as_default")] + pub tagsh: Vec, + /// Random ID assigned to this result row. + #[serde(default)] + pub randomid: Option, + /// Human friendly bucket name. + #[serde(default, deserialize_with = "null_as_default")] + pub bucketh: String, + /// File group. + #[serde(default, deserialize_with = "null_as_default")] + pub group: String, + /// Index file ID (storage ID of an indexed sub-page tree view, if any). + #[serde(default, deserialize_with = "null_as_default")] + pub indexfile: String, +} + +impl Deref for SearchResult { + type Target = Item; + + fn deref(&self) -> &Item { + &self.item + } +} + +impl DerefMut for SearchResult { + fn deref_mut(&mut self) -> &mut Item { + &mut self.item + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_result_flattens_item_fields_and_derefs() { + let json = r#"{ + "systemid": "61202067-543e-4e6a-8c23-11f9b8f008cf", + "bucket": "pastes", + "mediah": "Paste document" + }"#; + let result: SearchResult = serde_json::from_str(json).unwrap(); + assert_eq!(result.bucket, "pastes"); + assert_eq!(result.mediah, "Paste document"); + } +} diff --git a/Rust/intelx/src/phonebook.rs b/Rust/intelx/src/phonebook.rs new file mode 100644 index 0000000..43414ea --- /dev/null +++ b/Rust/intelx/src/phonebook.rs @@ -0,0 +1,94 @@ +//! Phonebook search: `/phonebook/search*`. + +use std::time::Duration; + +use crate::client::IntelXClient; +use crate::error::{IntelXError, Result}; +use crate::models::{ + IntelSearchStartResponse, PhonebookSearchParams, PhonebookSearchResultPage, PhonebookSelector, + SearchStatus, +}; + +impl IntelXClient { + /// Starts a phonebook search and returns its ID for further processing. + /// + /// Mirrors the Python SDK's `PHONEBOOK_SEARCH()`. + pub async fn phonebook_search(&self, params: PhonebookSearchParams) -> Result { + self.rate_limit_sleep().await; + let request = params.into_request(); + let response: IntelSearchStartResponse = + self.post_json("/phonebook/search", &request).await?; + response.id.ok_or_else(|| IntelXError::Api { + status: 200, + message: "missing search id in response".into(), + }) + } + + /// Fetches a page of results for a previously started phonebook search. + /// + /// Mirrors the Python SDK's `PHONEBOOK_SEARCH_RESULT()` / `query_pb_results()`. + /// `offset` should normally be left at `-1` (the API default: each call returns the next + /// available results); the Python SDK explicitly recommends not overriding it. + pub async fn phonebook_search_result( + &self, + id: uuid::Uuid, + limit: i32, + offset: i32, + ) -> Result { + self.rate_limit_sleep().await; + self.get( + "/phonebook/search/result", + &[ + ("id", id.to_string()), + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ], + ) + .await + } + + /// Runs a phonebook search to completion, polling until the API reports no more results + /// (or `params.maxresults` has been satisfied), and returns every fetched page. + /// + /// Mirrors the Python SDK's high-level `phonebooksearch()`. Pages are kept separate (not + /// flattened) to match Python's behavior of accumulating raw response pages; use + /// [`flatten_selectors`] to get a single `Vec` if you don't need + /// per-page status information. + pub async fn phonebook_search_all( + &self, + params: PhonebookSearchParams, + ) -> Result> { + let mut remaining = params.maxresults; + let search_id = self.phonebook_search(params).await?; + let mut pages = Vec::new(); + + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let page = self + .phonebook_search_result(search_id, remaining, -1) + .await?; + remaining -= page.selectors.len() as i32; + let status = page.status; + pages.push(page); + + let exhausted = remaining <= 0; + if exhausted { + let _ = self.intel_terminate_search(search_id).await; + } + if exhausted || matches!(status, SearchStatus::NoMoreResults | SearchStatus::NotFound) { + break; + } + } + + Ok(pages) + } +} + +/// Flattens the pages returned by [`IntelXClient::phonebook_search_all`] into a single list of +/// selectors. +pub fn flatten_selectors(pages: &[PhonebookSearchResultPage]) -> Vec { + pages + .iter() + .flat_map(|page| page.selectors.iter().cloned()) + .collect() +} diff --git a/Rust/intelx/src/search.rs b/Rust/intelx/src/search.rs new file mode 100644 index 0000000..0f98e42 --- /dev/null +++ b/Rust/intelx/src/search.rs @@ -0,0 +1,234 @@ +//! Intelligent search: `/intelligent/search*`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::client::IntelXClient; +use crate::error::{IntelXError, Result}; +use crate::models::{ + ExportFormat, IntelSearchResultPage, IntelSearchStartResponse, SearchParams, SearchResult, + SearchStatus, +}; +use crate::util::parse_content_disposition_filename; + +impl IntelXClient { + /// Starts an intelligent search and returns its ID for further processing. + /// + /// Mirrors the Python SDK's `INTEL_SEARCH()`. Returns [`IntelXError::InvalidTerm`] if the + /// API rejects `params.term` as not being a supported "strong selector", or + /// [`IntelXError::MaxConcurrentSearches`] if the account has too many active searches. + pub async fn intel_search(&self, params: SearchParams) -> Result { + self.rate_limit_sleep().await; + let request = params.into_request(); + let response: IntelSearchStartResponse = + self.post_json("/intelligent/search", &request).await?; + match response.status { + 1 => Err(IntelXError::InvalidTerm), + 2 => Err(IntelXError::MaxConcurrentSearches), + _ => response.id.ok_or_else(|| IntelXError::Api { + status: 200, + message: "missing search id in response".into(), + }), + } + } + + /// Fetches a page of results for a previously started intelligent search. + /// + /// Mirrors the Python SDK's `INTEL_SEARCH_RESULT()` / `query_results()`. + pub async fn intel_search_result( + &self, + id: uuid::Uuid, + limit: i32, + ) -> Result { + self.rate_limit_sleep().await; + self.get( + "/intelligent/search/result", + &[("id", id.to_string()), ("limit", limit.to_string())], + ) + .await + } + + /// Terminates a previously started intelligent search. + /// + /// Mirrors the Python SDK's `INTEL_TERMINATE_SEARCH()`. + pub async fn intel_terminate_search(&self, id: uuid::Uuid) -> Result<()> { + self.rate_limit_sleep().await; + let response = self + .get_response("/intelligent/search/terminate", &[("id", id.to_string())]) + .await?; + if response.status().is_success() { + Ok(()) + } else { + Err(crate::error::api_error_from_status(response.status())) + } + } + + /// Exports all files from a search to `dest_dir`, mirroring the Python SDK's + /// `INTEL_EXPORT()`. The filename is taken from the response's `Content-Disposition` + /// header; returns the resolved path it was written to. + pub async fn intel_export( + &self, + id: uuid::Uuid, + format: ExportFormat, + limit: i32, + dest_dir: &Path, + ) -> Result { + self.rate_limit_sleep().await; + let format_code = format as i32; + let response = self + .get_response( + "/intelligent/search/export", + &[ + ("id", id.to_string()), + ("f", format_code.to_string()), + ("l", limit.to_string()), + ], + ) + .await?; + + if !response.status().is_success() { + return Err(crate::error::api_error_from_status(response.status())); + } + + let filename = response + .headers() + .get(reqwest::header::CONTENT_DISPOSITION) + .and_then(|v| v.to_str().ok()) + .and_then(parse_content_disposition_filename) + .ok_or(IntelXError::MissingFilename)?; + + let dest_path = dest_dir.join(filename); + let bytes = response.bytes().await?; + tokio::fs::write(&dest_path, &bytes).await?; + Ok(dest_path) + } + + /// Lists all selectors found within a document. + /// + /// Mirrors the Python SDK's `selectors()`. + pub async fn selectors(&self, document: uuid::Uuid) -> Result> { + self.rate_limit_sleep().await; + let value: serde_json::Value = self + .get("/item/selector/list/human", &[("id", document.to_string())]) + .await?; + Ok(value + .get("selectors") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default()) + } + + /// Runs an intelligent search to completion, polling until the API reports no more results + /// (or `params.maxresults` has been satisfied), and returns the accumulated records. + /// + /// Mirrors the Python SDK's high-level `search()`. The search is terminated server-side if + /// it's abandoned early because `maxresults` was reached, matching Python's behavior. + /// + /// This is the recommended entry point for most callers; for custom polling cadence or + /// early-exit logic, compose [`IntelXClient::intel_search`], + /// [`IntelXClient::intel_search_result`], and [`IntelXClient::intel_terminate_search`] + /// directly. + pub async fn search(&self, params: SearchParams) -> Result> { + let mut remaining = params.maxresults; + let search_id = self.intel_search(params).await?; + let mut results = Vec::new(); + + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let page = self.intel_search_result(search_id, remaining).await?; + remaining -= page.records.len() as i32; + results.extend(page.records); + + let exhausted = remaining <= 0; + if exhausted { + let _ = self.intel_terminate_search(search_id).await; + } + if exhausted + || matches!( + page.status, + SearchStatus::NoMoreResults | SearchStatus::NotFound + ) + { + break; + } + } + + Ok(results) + } + + /// Starts a search and exports its results to `dest_dir`. Mirrors the Python SDK's + /// `exportfromsearch()`. + pub async fn export_from_search( + &self, + params: SearchParams, + format: ExportFormat, + dest_dir: &Path, + ) -> Result { + let limit = params.maxresults; + let search_id = self.intel_search(params).await?; + self.intel_export(search_id, format, limit, dest_dir).await + } +} + +/// Counts results by bucket. Mirrors the Python SDK's `stats()`. +/// +/// This is a pure function operating on already-fetched results, so it does not require a +/// client or perform any I/O. +pub fn stats(results: &[SearchResult]) -> BTreeMap { + let mut counts = BTreeMap::new(); + for result in results { + *counts.entry(result.bucket.clone()).or_insert(0) += 1; + } + counts +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Item; + + fn result_with_bucket(bucket: &str) -> SearchResult { + SearchResult { + item: Item { + systemid: uuid::Uuid::new_v4(), + storageid: String::new(), + instore: false, + size: 0, + accesslevel: 0, + item_type: 0, + media: 0, + added: String::new(), + date: String::new(), + name: String::new(), + description: String::new(), + xscore: 0, + simhash: 0, + bucket: bucket.to_string(), + tags: Vec::new(), + relations: Vec::new(), + }, + accesslevelh: String::new(), + mediah: String::new(), + simhashh: String::new(), + typeh: String::new(), + tagsh: Vec::new(), + randomid: None, + bucketh: String::new(), + group: String::new(), + indexfile: String::new(), + } + } + + #[test] + fn stats_counts_records_by_bucket() { + let results = vec![ + result_with_bucket("pastes"), + result_with_bucket("pastes"), + result_with_bucket("darknet.i2p"), + ]; + let counts = stats(&results); + assert_eq!(counts.get("pastes"), Some(&2)); + assert_eq!(counts.get("darknet.i2p"), Some(&1)); + } +} diff --git a/Rust/intelx/src/util.rs b/Rust/intelx/src/util.rs new file mode 100644 index 0000000..f891adc --- /dev/null +++ b/Rust/intelx/src/util.rs @@ -0,0 +1,108 @@ +//! Internal helpers shared across modules. + +use serde::Deserialize; + +/// Deserializes a field that may be missing, `null`, or present, falling back to +/// `T::default()` in the first two cases. +/// +/// `#[serde(default)]` alone only covers a *missing* key; if the API sends the key with an +/// explicit JSON `null` (which the Intelligence X API does for some array/string fields), +/// deserialization still fails because `null` doesn't satisfy most types' `Deserialize` impl. +/// Pair this with `#[serde(default)]` to handle both cases: +/// `#[serde(default, deserialize_with = "crate::util::null_as_default")]`. +pub(crate) fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +/// Extracts a filename from a `Content-Disposition` header value, mirroring the Python SDK's +/// `re.search(r'filename="?([^"]+)"?', cd)` used by `INTEL_EXPORT`. +/// +/// Handles the common `filename="x.zip"` and `filename=x.zip` forms. RFC 5987's +/// `filename*=UTF-8''x.zip` form is also recognized as a fallback. +pub(crate) fn parse_content_disposition_filename(header: &str) -> Option { + for part in header.split(';') { + let part = part.trim(); + if let Some(value) = part.strip_prefix("filename=") { + let value = value.trim().trim_matches('"'); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + for part in header.split(';') { + let part = part.trim(); + if let Some(value) = part.strip_prefix("filename*=") { + // Strip the `UTF-8''` (or similar charset/lang) prefix per RFC 5987. + if let Some(idx) = value.find("''") { + let value = &value[idx + 2..]; + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq, serde::Deserialize)] + struct NullableVec { + #[serde(default, deserialize_with = "null_as_default")] + items: Vec, + } + + #[test] + fn null_as_default_treats_explicit_null_as_default() { + let parsed: NullableVec = serde_json::from_str(r#"{"items": null}"#).unwrap(); + assert_eq!(parsed.items, Vec::::new()); + } + + #[test] + fn null_as_default_treats_missing_key_as_default() { + let parsed: NullableVec = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(parsed.items, Vec::::new()); + } + + #[test] + fn null_as_default_passes_through_present_value() { + let parsed: NullableVec = serde_json::from_str(r#"{"items": [1, 2, 3]}"#).unwrap(); + assert_eq!(parsed.items, vec![1, 2, 3]); + } + + #[test] + fn parses_quoted_filename() { + assert_eq!( + parse_content_disposition_filename(r#"attachment; filename="Search 2024.csv""#), + Some("Search 2024.csv".to_string()) + ); + } + + #[test] + fn parses_unquoted_filename() { + assert_eq!( + parse_content_disposition_filename("attachment; filename=export.zip"), + Some("export.zip".to_string()) + ); + } + + #[test] + fn parses_rfc5987_filename_star() { + assert_eq!( + parse_content_disposition_filename("attachment; filename*=UTF-8''export%20file.zip"), + Some("export%20file.zip".to_string()) + ); + } + + #[test] + fn returns_none_when_no_filename_present() { + assert_eq!(parse_content_disposition_filename("attachment"), None); + assert_eq!(parse_content_disposition_filename(""), None); + } +} diff --git a/Rust/intelx/tests/live_smoke.rs b/Rust/intelx/tests/live_smoke.rs new file mode 100644 index 0000000..9eadfb9 --- /dev/null +++ b/Rust/intelx/tests/live_smoke.rs @@ -0,0 +1,36 @@ +//! Smoke tests against the real Intelligence X API. +//! +//! These are `#[ignore]`d by default and additionally bail out unless `INTELX_KEY` is set, so +//! `cargo test` never touches the network. Run explicitly with: +//! +//! ```text +//! INTELX_KEY=... cargo test -p intelx --test live_smoke -- --ignored +//! ``` + +use intelx::{IntelXClient, SearchParams}; + +fn live_client() -> Option { + let api_key = std::env::var("INTELX_KEY").ok()?; + let base_url = std::env::var("INTELX_BASE_URL").ok(); + let mut builder = IntelXClient::builder().api_key(api_key); + if let Some(base_url) = base_url { + builder = builder.base_url(base_url); + } + builder.build().ok() +} + +#[tokio::test] +#[ignore] +async fn search_against_live_api() { + let Some(client) = live_client() else { + eprintln!("skipping: INTELX_KEY not set"); + return; + }; + + let results = client + .search(SearchParams::new("riseup.net").maxresults(5)) + .await + .expect("live search should succeed with a valid key"); + + assert!(!results.is_empty()); +} diff --git a/Rust/intelx/tests/search_integration.rs b/Rust/intelx/tests/search_integration.rs new file mode 100644 index 0000000..6e2bde4 --- /dev/null +++ b/Rust/intelx/tests/search_integration.rs @@ -0,0 +1,242 @@ +//! HTTP-layer integration tests using `wiremock`. These never hit the live API and require no +//! credentials, so they run in the default `cargo test`. + +use intelx::{IntelXClient, IntelXError, SearchParams}; +use serde_json::json; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +async fn client_for(mock_server: &MockServer) -> IntelXClient { + IntelXClient::builder() + .api_key("test-key") + .base_url(mock_server.uri()) + .rate_limit(std::time::Duration::from_millis(0)) + .build() + .unwrap() +} + +#[tokio::test] +async fn intel_search_returns_parsed_search_id() { + let mock_server = MockServer::start().await; + let search_id = "61202067-543e-4e6a-8c23-11f9b8f008cf"; + + Mock::given(method("POST")) + .and(path("/intelligent/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": search_id, + "status": 0, + "softselectorwarning": false + }))) + .mount(&mock_server) + .await; + + let client = client_for(&mock_server).await; + let id = client + .intel_search(SearchParams::new("riseup.net")) + .await + .unwrap(); + assert_eq!(id.to_string(), search_id); +} + +#[tokio::test] +async fn intel_search_maps_invalid_term_status() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/intelligent/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": null, + "status": 1 + }))) + .mount(&mock_server) + .await; + + let client = client_for(&mock_server).await; + let err = client + .intel_search(SearchParams::new("not a selector")) + .await + .unwrap_err(); + assert!(matches!(err, IntelXError::InvalidTerm)); +} + +#[tokio::test] +async fn intel_search_maps_unauthorized_response() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/intelligent/search")) + .respond_with(ResponseTemplate::new(401)) + .mount(&mock_server) + .await; + + let client = client_for(&mock_server).await; + let err = client + .intel_search(SearchParams::new("riseup.net")) + .await + .unwrap_err(); + match err { + IntelXError::Api { status, .. } => assert_eq!(status, 401), + other => panic!("expected Api error, got {other:?}"), + } +} + +#[tokio::test] +async fn search_polls_across_multiple_pages_and_accumulates_records() { + let mock_server = MockServer::start().await; + let search_id = "61202067-543e-4e6a-8c23-11f9b8f008cf"; + + Mock::given(method("POST")) + .and(path("/intelligent/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": search_id, + "status": 0, + "softselectorwarning": false + }))) + .mount(&mock_server) + .await; + + // First page: status 0 (more results available), one record. + Mock::given(method("GET")) + .and(path("/intelligent/search/result")) + .and(query_param("id", search_id)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": 0, + "records": [{ + "systemid": "11111111-1111-1111-1111-111111111111", + "bucket": "pastes" + }] + }))) + .up_to_n_times(1) + .mount(&mock_server) + .await; + + // Second page: status 1 (no more results), one more record. + Mock::given(method("GET")) + .and(path("/intelligent/search/result")) + .and(query_param("id", search_id)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": 1, + "records": [{ + "systemid": "22222222-2222-2222-2222-222222222222", + "bucket": "darknet.i2p" + }] + }))) + .mount(&mock_server) + .await; + + let client = client_for(&mock_server).await; + let results = client + .search(SearchParams::new("riseup.net").maxresults(100)) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].bucket, "pastes"); + assert_eq!(results[1].bucket, "darknet.i2p"); +} + +#[tokio::test] +async fn search_terminates_search_when_maxresults_exhausted_early() { + let mock_server = MockServer::start().await; + let search_id = "61202067-543e-4e6a-8c23-11f9b8f008cf"; + + Mock::given(method("POST")) + .and(path("/intelligent/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": search_id, + "status": 0 + }))) + .mount(&mock_server) + .await; + + // Single page already satisfies maxresults=1, while status still says "more available". + Mock::given(method("GET")) + .and(path("/intelligent/search/result")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": 0, + "records": [{ + "systemid": "11111111-1111-1111-1111-111111111111", + "bucket": "pastes" + }] + }))) + .mount(&mock_server) + .await; + + Mock::given(method("GET")) + .and(path("/intelligent/search/terminate")) + .and(query_param("id", search_id)) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + let client = client_for(&mock_server).await; + let results = client + .search(SearchParams::new("riseup.net").maxresults(1)) + .await + .unwrap(); + assert_eq!(results.len(), 1); + + // wiremock verifies the `expect(1)` terminate call was made when the MockServer is dropped. +} + +#[tokio::test] +async fn file_read_streams_response_body_to_disk() { + let mock_server = MockServer::start().await; + let payload = b"hello from intelx"; + + Mock::given(method("GET")) + .and(path("/file/read")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec())) + .mount(&mock_server) + .await; + + let client = client_for(&mock_server).await; + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("downloaded.bin"); + + let written = client + .file_read("system-id", intelx::FileReadType::Raw, "pastes", &dest) + .await + .unwrap(); + + assert_eq!(written, payload.len() as u64); + let contents = std::fs::read(&dest).unwrap(); + assert_eq!(contents, payload); +} + +#[tokio::test] +async fn intel_export_writes_file_named_from_content_disposition() { + let mock_server = MockServer::start().await; + let search_id = "61202067-543e-4e6a-8c23-11f9b8f008cf"; + let payload = b"id,name\n1,test\n"; + + Mock::given(method("GET")) + .and(path("/intelligent/search/export")) + .respond_with( + ResponseTemplate::new(200) + .insert_header( + "Content-Disposition", + "attachment; filename=\"Search 2024.csv\"", + ) + .set_body_bytes(payload.to_vec()), + ) + .mount(&mock_server) + .await; + + let client = client_for(&mock_server).await; + let dir = tempfile::tempdir().unwrap(); + + let path = client + .intel_export( + search_id.parse().unwrap(), + intelx::ExportFormat::Csv, + 100, + dir.path(), + ) + .await + .unwrap(); + + assert_eq!(path.file_name().unwrap(), "Search 2024.csv"); + assert_eq!(std::fs::read(&path).unwrap(), payload); +} diff --git a/Rust/rust-toolchain.toml b/Rust/rust-toolchain.toml new file mode 100644 index 0000000..01a3bee --- /dev/null +++ b/Rust/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.96.0" +components = ["clippy", "rustfmt"] diff --git a/Rust/rustfmt.toml b/Rust/rustfmt.toml new file mode 100644 index 0000000..f216078 --- /dev/null +++ b/Rust/rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" From 4936247b97ca74cad71609c42f440f6dc661deff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Vi=E1=BB=87t=20H=C6=B0ng?= Date: Wed, 1 Jul 2026 00:33:39 +0700 Subject: [PATCH 2/2] fix YAML Linting and sonatype jake --- .github/workflows/sonatype-jack.yml | 6 +- openapi.yaml | 538 ++++++++++++++++++---------- 2 files changed, 361 insertions(+), 183 deletions(-) diff --git a/.github/workflows/sonatype-jack.yml b/.github/workflows/sonatype-jack.yml index 9bc19ed..ac6a966 100644 --- a/.github/workflows/sonatype-jack.yml +++ b/.github/workflows/sonatype-jack.yml @@ -10,11 +10,11 @@ jobs: # runs-on: ubuntu-latest runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python 3.10 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: 3.10 + python-version: "3.10" - name: Run jake from Sonatype run: |- cd ./Python diff --git a/openapi.yaml b/openapi.yaml index 8b02532..cc04823 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,13 +1,15 @@ +--- openapi: 3.1.0 info: title: Intelx.io - Search API version: 0.8.0 description: | - Internal search in "buckets" (e.g. usenet, web.public, whois). + Internal search in "buckets" (e.g. usenet, web.public, whois). Needs `X-Key` header authorization, or as k=[key] query parameter. - The API will return HTTP 401 Unauthorized in case the API key is invalid or not authorized. + The API will return HTTP 401 Unauthorized in case the API key is invalid or + not authorized. termsOfService: https://intelx.io/terms-of-service - contact: + contact: name: "Intelligent X" url: "https://intelx.io/" email: "info@intelx.io" @@ -27,9 +29,9 @@ servers: see https://blog.intelx.io/2025/03/08/new-search-api-instances/ - url: 'https://3.intelx.io' description: | - Identity Leaks API - 1. Search for a term and return each line where it appears. - 2. Export leaked accounts as CSV. + Identity Leaks API + 1. Search for a term and return each line where it appears. + 2. Export leaked accounts as CSV. paths: /authenticate/info: get: @@ -46,32 +48,116 @@ paths: application/json: example: added: "2025-11-04T22:45:01.1220324Z" - buckets: ["darknet","dns","documents.public","dumpster","leaks.logs","leaks.private","leaks.public","pastes","usenet","web.gov.ru","web.public","whois"] - bucketsh: ["Darknet","DNS","Documents » Public","Dumpster","Leaks » Logs","Leaks » Restricted","Leaks » Public","Pastes","Usenet","Web » Government » Ukraine","Web » Public","Whois"] + buckets: + - darknet + - dns + - documents.public + - dumpster + - leaks.logs + - leaks.private + - leaks.public + - pastes + - usenet + - web.gov.ru + - web.public + - whois + bucketsh: + - Darknet + - DNS + - Documents » Public + - Dumpster + - Leaks » Logs + - Leaks » Restricted + - Leaks » Public + - Pastes + - Usenet + - Web » Government » Ukraine + - Web » Public + - Whois preview: [] previewh: [] redacted: [] redactedh: [] paths: - /authenticate/info: { Path: "/authenticate/info", Credit: 0, CreditMax: 0, CreditReset: 0 } - /file/preview: { Path: "/file/preview", Credit: 9971, CreditMax: 10000, CreditReset: 2 } - /file/read: { Path: "/file/read", Credit: 998, CreditMax: 1000, CreditReset: 2 } - /file/treeview: { Path: "/file/treeview", Credit: 500, CreditMax: 500, CreditReset: 2 } - /file/view: { Path: "/file/view", Credit: 998, CreditMax: 1000, CreditReset: 2 } - /intelligent/search: { Path: "/intelligent/search", Credit: 479, CreditMax: 500, CreditReset: 2 } - /intelligent/search/export: { Path: "/intelligent/search/export", Credit: 100, CreditMax: 100, CreditReset: 2 } - /intelligent/search/result: { Path: "/intelligent/search/result", Credit: 0, CreditMax: 0, CreditReset: 0 } - /intelligent/search/statistic: { Path: "/intelligent/search/statistic", Credit: 0, CreditMax: 0, CreditReset: 0 } - /intelligent/search/terminate: { Path: "/intelligent/search/terminate", Credit: 0, CreditMax: 0, CreditReset: 0 } - /item/selector/list/export: { Path: "/item/selector/list/export", Credit: 1000, CreditMax: 1000, CreditReset: 2 } - /item/selector/list/human: { Path: "/item/selector/list/human", Credit: 1000, CreditMax: 1000, CreditReset: 2 } - /phonebook/search: { Path: "/phonebook/search", Credit: 100, CreditMax: 100, CreditReset: 2 } - /phonebook/search/export: { Path: "/phonebook/search/export", Credit: 100, CreditMax: 100, CreditReset: 2 } - /phonebook/search/result: { Path: "/phonebook/search/result", Credit: 0, CreditMax: 0, CreditReset: 0 } + /authenticate/info: + Path: "/authenticate/info" + Credit: 0 + CreditMax: 0 + CreditReset: 0 + /file/preview: + Path: "/file/preview" + Credit: 9971 + CreditMax: 10000 + CreditReset: 2 + /file/read: + Path: "/file/read" + Credit: 998 + CreditMax: 1000 + CreditReset: 2 + /file/treeview: + Path: "/file/treeview" + Credit: 500 + CreditMax: 500 + CreditReset: 2 + /file/view: + Path: "/file/view" + Credit: 998 + CreditMax: 1000 + CreditReset: 2 + /intelligent/search: + Path: "/intelligent/search" + Credit: 479 + CreditMax: 500 + CreditReset: 2 + /intelligent/search/export: + Path: "/intelligent/search/export" + Credit: 100 + CreditMax: 100 + CreditReset: 2 + /intelligent/search/result: + Path: "/intelligent/search/result" + Credit: 0 + CreditMax: 0 + CreditReset: 0 + /intelligent/search/statistic: + Path: "/intelligent/search/statistic" + Credit: 0 + CreditMax: 0 + CreditReset: 0 + /intelligent/search/terminate: + Path: "/intelligent/search/terminate" + Credit: 0 + CreditMax: 0 + CreditReset: 0 + /item/selector/list/export: + Path: "/item/selector/list/export" + Credit: 1000 + CreditMax: 1000 + CreditReset: 2 + /item/selector/list/human: + Path: "/item/selector/list/human" + Credit: 1000 + CreditMax: 1000 + CreditReset: 2 + /phonebook/search: + Path: "/phonebook/search" + Credit: 100 + CreditMax: 100 + CreditReset: 2 + /phonebook/search/export: + Path: "/phonebook/search/export" + Credit: 100 + CreditMax: 100 + CreditReset: 2 + /phonebook/search/result: + Path: "/phonebook/search/result" + Credit: 0 + CreditMax: 0 + CreditReset: 0 searchesactive: 0 maxconcurrentsearches: 10 '401': - description: Unauthorized + description: Unauthorized /file/preview: servers: - url: 'https://2.intelx.io' @@ -88,7 +174,8 @@ paths: style: form schema: type: string - example: 'fb054e59c7eecab877b317d1f4204abbe4130c727a15ea6e2555e1ebdcd63540b54ecd3242a0a77f70a7e57a4dfc9b70712cfc80f3757beda93e3fc657b41ee7' + example: "fb054e59c7eecab877b317d1f4204abbe4130c727a15ea6e2555e1ebdcd\ + 63540b54ecd3242a0a77f70a7e57a4dfc9b70712cfc80f3757beda93e3fc657b41ee7" - name: f in: query required: false @@ -105,7 +192,7 @@ paths: explode: true schema: type: integer - enum: [ 1, 2, 3, 4 ] + enum: [1, 2, 3, 4] description: Data type - name: m in: query @@ -130,11 +217,12 @@ paths: style: form explode: true schema: - type: integer - enum: [ 0, 1 ] - description: "0 = don't escape, 1 = default behavior" + type: integer + enum: [0, 1] + description: "0 = don't escape, 1 = default behavior" description: | - For text the default behavior (unless overwritten by &escape=0) is to escape HTML characters + For text the default behavior (unless overwritten by &escape=0) is to + escape HTML characters for safe placement of the text in HTML. responses: '200': @@ -150,19 +238,20 @@ paths: description: | id option: - Specifies the item's system ID to read. - + type option: - Specifies content disposition or not. - 0: No content disposition. Returns raw binary file. - - 1: Content disposition. May fix line endings to CR LF for text files. - + - 1: Content disposition. May fix line endings to CR LF for text + files. + bucket option: - Bucket is required. - + name option: - Specify the name to save the file as (e.g document.pdf). security: - - ApiKeyAuth: [ ] + - ApiKeyAuth: [] parameters: - $ref: '#/components/parameters/SearchIdParam' - name: type @@ -174,12 +263,15 @@ paths: type: integer enum: [0, 1, 2, 3, 4] description: | - Types: + Types: 0 = Raw binary - 1 = Raw binary with content disposition and optional file &name=[name] as parameter - 2 = Text view, any non-printable characters shall be removed, UTF-8 encoding. Optional max &size=[number] as parameter. + 1 = Raw binary with content disposition and optional file + &name=[name] as parameter + 2 = Text view, any non-printable characters shall be removed, + UTF-8 encoding. Optional max &size=[number] as parameter. 3 = Hex view of data. Optional max &size=[number] as parameter. - 4 = Auto-detect hex view or text view. Optional max &size=[number] as parameter. + 4 = Auto-detect hex view or text view. Optional max + &size=[number] as parameter. The default max size if none is specified is 1 MB. - name: name in: query @@ -189,8 +281,9 @@ paths: schema: type: string description: | - Types: - 1 = Raw binary with content disposition and optional file &name=[name] as parameter + Types: + 1 = Raw binary with content disposition and optional file + &name=[name] as parameter - name: size in: query required: false @@ -208,7 +301,9 @@ paths: explode: true schema: type: string - description: Either the storage identifier or system identifier has to be specified. + description: + Either the storage identifier or system identifier has to be + specified. - name: systemid in: query required: true @@ -216,11 +311,13 @@ paths: explode: true schema: type: string - description: Either the storage identifier or system identifier has to be specified. + description: + Either the storage identifier or system identifier has to be + specified. responses: "200": description: OK - "404": + "404": description: item was not found "503": description: no storage server was available @@ -231,9 +328,10 @@ paths: summary: Read a file's raw contents. Use this for direct data download. operationId: fileView description: | - Show a file's contents based on its storageid (sid), convert to text where necessary. + Show a file's contents based on its storageid (sid), convert to text + where necessary. security: - - ApiKeyQueryParam: [ ] + - ApiKeyQueryParam: [] parameters: - name: f in: query @@ -260,7 +358,9 @@ paths: explode: true schema: type: string - description: Either the storage identifier or system identifier has to be specified. + description: + Either the storage identifier or system identifier has to be + specified. - $ref: '#/components/parameters/OneBucketParam' - name: escape in: query @@ -272,7 +372,8 @@ paths: enum: [0, 1] description: "0 = don't escape, 1 = default behavior" description: | - For text the default behavior (unless overwritten by &escape=0) is to escape HTML characters + For text the default behavior (unless overwritten by &escape=0) is + to escape HTML characters for safe placement of the text in HTML. responses: '200': @@ -285,12 +386,15 @@ paths: servers: - url: 'https://2.intelx.io' post: - summary: Initialize an intelligent search and return the ID of the task/search for further processing. + summary: + Initialize an intelligent search and return the ID of the task/search + for further processing. operationId: intelligentSearch security: - - ApiKeyAuth: [ ] + - ApiKeyAuth: [] description: | - Initialize an intelligent search and return the ID of the task/search for further processing. + Initialize an intelligent search and return the ID of the task/search + for further processing. parameters: - name: term in: query @@ -301,7 +405,8 @@ paths: type: string example: info@intelx.io description: | - The term must be a strong selector. These selector types are currently supported: + The term must be a strong selector. These selector types are + currently supported: - Email address - Domain, including wildcards like *.example.com - URL @@ -328,7 +433,7 @@ paths: explode: true schema: type: string - example: 2020-01-01 00:00:00 + example: 2020-01-01 00:00:00 description: | - Set a starting date to begin the search from. - Example: 2020-01-01 00:00:00 @@ -341,7 +446,7 @@ paths: schema: type: string example: 2020-02-02 23:59:59 - + description: | - Set an ending date to finish the search from. - Example: 2020-02-02 23:59:59 @@ -387,7 +492,8 @@ paths: id: type: string example: '61202067-543e-4e6a-8c23-11f9b8f008cf' - pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\ + [0-9a-f]{12}$" links: LiveSearchResult: operationId: liveSearchResult @@ -400,44 +506,49 @@ paths: '402': description: if no credits available /intelligent/search/export: - servers: + servers: - url: 'https://2.intelx.io' - get: - operationId: intelligent/search/export - summary: Export intelligent search - description: | - Exports search results as either a CSV summary or a ZIP archive. This endpoint requires the - Search ID returned by /intelligent/search. + get: + operationId: intelligent/search/export + summary: Export intelligent search + description: | + Exports search results as either a CSV summary or a ZIP archive. + This endpoint requires the Search ID returned by /intelligent/search. - The CSV summary contains the following columns: Name, Date, Bucket, Media, Content Type, Size, System ID + The CSV summary contains the following columns: Name, Date, Bucket, + Media, Content Type, Size, System ID See: https://help.intelx.io/api/search/#intelligentsearchexport - security: - - ApiKeyQueryParam: [ ] - parameters: - - $ref: '#/components/parameters/SearchIdParam' - - $ref: '#/components/parameters/LAsLimitParam' - - name: f - in: query - required: true - style: form - explode: true - schema: - type: integer - description: | + security: + - ApiKeyQueryParam: [] + parameters: + - $ref: '#/components/parameters/SearchIdParam' + - $ref: '#/components/parameters/LAsLimitParam' + - name: f + in: query + required: true + style: form + explode: true + schema: + type: integer + description: | Format: - - 0: CSV summary file. - - 1: ZIP archive containing the CSV summary (Info.csv) and binary files named [system ID].bin. + - 0: CSV summary file. + - 1: ZIP archive containing the CSV summary (Info.csv) and + binary files named [system ID].bin. https://help.intelx.io/api/limits/#export-zip-file - responses: - "200": - description: | - Successful export. Response contains the file. The Content-Disposition header includes a suggested filename such as Search [Date].csv. - "204": + responses: + "200": + description: | + Successful export. Response contains the file. The + Content-Disposition header includes a suggested filename such as + Search [Date].csv. + "204": description: Search ID not found. No body returned. - "400": - description: Invalid input. Typically due to missing or malformed parameters. + "400": + description: Invalid input. Typically due to missing or malformed + parameters. /intelligent/search/result: servers: - url: 'https://2.intelx.io' @@ -445,7 +556,7 @@ paths: summary: Fetch intelligent search result operationId: intelligentSearchResult security: - - ApiKeyQueryParam: [ ] + - ApiKeyQueryParam: [] parameters: - $ref: '#/components/parameters/SearchIdParam' - $ref: '#/components/parameters/LimitParam' @@ -474,7 +585,9 @@ paths: type: string example: '2024-01-01 00:00:00' pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' - description: Date from of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) + description: + Date from of the result in `YYYY-mm-dd HH:ii:ss` format. (Not + RFC3339) - name: dateTo in: query required: false @@ -482,7 +595,8 @@ paths: type: string example: '2024-01-31 23:59:59' pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' - description: Date to of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) + description: + Date to of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) - name: reset in: query required: false @@ -496,8 +610,10 @@ paths: Intelligent search result page. The client is expected to poll this endpoint repeatedly and interpret the status code: - 0 = Success with results (continue polling, more results available) - 1 = No more results available (this response might still have results) + 0 = Success with results (continue polling, more results + available) + 1 = No more results available (this response might still have + results) 2 = Search ID not found 3 = No results yet available, keep trying 4 = Error @@ -539,7 +655,7 @@ paths: summary: Intelligent search statistics operationId: intelligentSearchStatistics security: - - ApiKeyQueryParam: [ ] + - ApiKeyQueryParam: [] parameters: - $ref: '#/components/parameters/SearchIdParam' /intelligent/search/terminate: @@ -549,16 +665,18 @@ paths: summary: Terminate previous search operationId: intelligentSearchTerminate security: - - ApiKeyQueryParam: [ ] + - ApiKeyQueryParam: [] parameters: - - $ref: '#/components/parameters/SearchIdParam' + - $ref: '#/components/parameters/SearchIdParam' /item/selector/list: get: summary: Lists all selectors for an item operationId: itemSelectorList - description: lists all selectors for an item from the first selector service that responds. + description: + lists all selectors for an item from the first selector service that + responds. security: - - ApiKeyAuth: [ ] + - ApiKeyAuth: [] parameters: - $ref: '#/components/parameters/SearchIdParam' responses: @@ -569,45 +687,54 @@ paths: '404': description: item not found /item/selector/list/human: - get: - operationId: selectorListHumanReadable - summary: Lists all selectors for an item with human translation - description: Lists all selectors for an item from the first selector service that responds with human translation. - security: - - ApiKeyQueryParam: [ ] - parameters: - - $ref: '#/components/parameters/SearchIdParam' - - $ref: '#/components/parameters/OneBucketParam' - responses: - '200': - description: with JSON structure SelectorLink - '400': - description: if invalid input id - '404': - description: item not found + get: + operationId: selectorListHumanReadable + summary: Lists all selectors for an item with human translation + description: + Lists all selectors for an item from the first selector service that + responds with human translation. + security: + - ApiKeyQueryParam: [] + parameters: + - $ref: '#/components/parameters/SearchIdParam' + - $ref: '#/components/parameters/OneBucketParam' + responses: + '200': + description: with JSON structure SelectorLink + '400': + description: if invalid input id + '404': + description: item not found /item/selector/list/export: get: summary: Exports all selectors for an item operationId: itemSelectorListExport description: | - Exports all selectors for an item from the first selector service that responds with human translation as CSV. + Exports all selectors for an item from the first selector service that + responds with human translation as CSV. CSV header: Item System ID, Selector, Selector Type, Selector Type Human - Filename: "Selectors [System ID].csv" + Filename: "Selectors [System ID].csv" security: - - ApiKeyQueryParam: [ ] + - ApiKeyQueryParam: [] parameters: - $ref: '#/components/parameters/SearchIdParam' responses: '200': description: Success, with data, Content-Disposition set '204': - description: Download with content-disposition but item not available. This prevents redirection of the user to error page when providing a direct download link. + description: + Download with content-disposition but item not available. This + prevents redirection of the user to error page when providing a + direct download link. '400': - description: Invalid input. Returned if the encoding is invalid or a required parameter is missing. + description: + Invalid input. Returned if the encoding is invalid or a required + parameter is missing. '401': - description: | - Unauthorized Authenticate: Access not authorized. - This may be due missing permission for API call or to selected buckets. + description: | + Unauthorized Authenticate: Access not authorized. + This may be due missing permission for API call or to selected + buckets. '402': description: "Payment Required Authenticate: No credits available." '404': @@ -617,7 +744,7 @@ paths: operationId: phonebookSearch summary: Phonebook search security: - - ApiKeyAuth: [ ] + - ApiKeyAuth: [] parameters: - name: term in: query @@ -634,7 +761,7 @@ paths: explode: true schema: type: integer - enum: [ 0, 1, 2, 3 ] + enum: [0, 1, 2, 3] description: | Search Phonebook: - 0: All @@ -651,7 +778,7 @@ paths: operationId: phonebookSearchExport summary: Export security: - - ApiKeyQueryParam: [ ] + - ApiKeyQueryParam: [] parameters: - $ref: '#/components/parameters/SearchIdParam' - $ref: '#/components/parameters/LAsLimitParam' @@ -660,7 +787,7 @@ paths: operationId: phonebookSearchResult summary: Fetch phonebook search result security: - - ApiKeyQueryParam: [ ] + - ApiKeyQueryParam: [] parameters: - $ref: '#/components/parameters/SearchIdParam' - $ref: '#/components/parameters/LAsLimitParam' @@ -669,7 +796,7 @@ paths: operationId: apiItemGet summary: Get item meta-data security: - - ApiKeyQueryParam: [ ] + - ApiKeyQueryParam: [] parameters: - $ref: '#/components/parameters/SearchIdParam' /api/item/get/human: @@ -677,7 +804,7 @@ paths: operationId: apiItemGetHumanReadable summary: Get item meta-data with human translation security: - - ApiKeyQueryParam: [ ] + - ApiKeyQueryParam: [] parameters: - $ref: '#/components/parameters/SearchIdParam' /live/search/internal: @@ -689,11 +816,23 @@ paths: description: | Initiates the search; will return status and search Id on success. Limit means max amount of records per bucket to return. Default 10. - - Selector: Must be an email address, domain, social security number (US based), or credit card number. - - Limit: In some cases, the API might return more results than specified in limit. If an upper hard limit is required, it must be enforced on the client side. - - Bucket: Optional filter for searching only in the target bucket. See Appendix 1 for list. - - In case a user makes a new search and the previous one shall be discarded; its search ID shall be specified in the “terminate” parameter to save system resources. Searches may consume Gigabytes of data, therefore any searches that are no longer required shall be terminated. Searches can also be manually terminated via the /live/search/terminate function. - - Dates: From/to dates may be used as filter. Note that item’s dates are set to when the original data was published if available, or otherwise when it was indexed. This means that newly indexed items are often backdated. + - Selector: Must be an email address, domain, social security number (US + based), or credit card number. + - Limit: In some cases, the API might return more results than specified + in limit. If an upper hard limit is required, it must be enforced on + the client side. + - Bucket: Optional filter for searching only in the target bucket. See + Appendix 1 for list. + - In case a user makes a new search and the previous one shall be + discarded; its search ID shall be specified in the “terminate” + parameter to save system resources. Searches may consume Gigabytes of + data, therefore any searches that are no longer required shall be + terminated. Searches can also be manually terminated via the + /live/search/terminate function. + - Dates: From/to dates may be used as filter. Note that item’s dates are + set to when the original data was published if available, or otherwise + when it was indexed. This means that newly indexed items are often + backdated. parameters: - name: selector in: query @@ -711,7 +850,8 @@ paths: schema: type: boolean default: false - description: Specify to skip invalid entries (recommended). Default false. + description: + Specify to skip invalid entries (recommended). Default false. - $ref: '#/components/parameters/LimitParam' - name: analyze in: query @@ -727,7 +867,9 @@ paths: type: string example: '2024-01-01 00:00:00' pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' - description: Date from of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) + description: + Date from of the result in `YYYY-mm-dd HH:ii:ss` format. (Not + RFC3339) - name: dateto in: query required: false @@ -735,7 +877,8 @@ paths: type: string example: '2024-01-31 23:59:59' pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' - description: Date to of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) + description: + Date to of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) - $ref: '#/components/parameters/TerminateParam' responses: '200': @@ -751,7 +894,8 @@ paths: id: type: string example: '61202067-543e-4e6a-8c23-11f9b8f008cf' - pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\ + [0-9a-f]{12}$" required: - status - id @@ -773,10 +917,12 @@ paths: summary: Terminating search operationId: liveSearchTerminate description: | - To terminate an active search or export, use this function. Terminating a search that is no longer needed saves system resources. - Since searches may read and process Gigabytes of data, it is highly appreciated if users terminate searches that are no longer needed. + To terminate an active search or export, use this function. Terminating + a search that is no longer needed saves system resources. + Since searches may read and process Gigabytes of data, it is highly + appreciated if users terminate searches that are no longer needed. - Terminating a search that is already terminated has no effect. + Terminating a search that is already terminated has no effect. parameters: - $ref: '#/components/parameters/SearchIdParam' security: @@ -796,20 +942,27 @@ paths: summary: Fetch results from internal live search operationId: liveSearchResult description: | - Initiates the search; will return status and search Id on success. - Status = 2 means end of search result. Good manners is to wait 1s before each new result fetch. - - Depending on the format parameter the response uses the text field, the records field, or both. - Text is HTML encoded and intended for direct visualization to the end-user. Use format=1 for machine processing. - - The response uses the following JSON structure. The status indicates whether results are available and whether the client - shall continue to fetch for results. - - Status - - 0 means there are results in the response, - - 1 means there is currently no result in the response, but the client should continue to fetch for results. - - 2 (Terminated) and - - 3 (Search ID Not Found) tell the client to stop querying for results. Note that with status 2 there might be the last results in the response. + Initiates the search; will return status and search Id on success. + Status = 2 means end of search result. Good manners is to wait 1s before + each new result fetch. + + Depending on the format parameter the response uses the text field, the + records field, or both. + Text is HTML encoded and intended for direct visualization to the + end-user. Use format=1 for machine processing. + + The response uses the following JSON structure. The status indicates + whether results are available and whether the client + shall continue to fetch for results. + + Status + - 0 means there are results in the response, + - 1 means there is currently no result in the response, but the client + should continue to fetch for results. + - 2 (Terminated) and + - 3 (Search ID Not Found) tell the client to stop querying for + results. Note that with status 2 there might be the last results in + the response. parameters: - $ref: '#/components/parameters/SearchIdParam' - name: format @@ -820,7 +973,8 @@ paths: enum: [0, 1, 2] default: 0 description: | - Return only specified fields: 0 = Text only, default 1 = Records only 2 = Return both fields + Return only specified fields: 0 = Text only, default 1 = Records + only 2 = Return both fields - $ref: '#/components/parameters/LimitParam' responses: @@ -833,7 +987,7 @@ paths: properties: status: type: integer - enum: [ 0, 1, 2 ] + enum: [0, 1, 2] description: Result status 0,1 - there is another result/s to fetch 2 - no more results @@ -853,11 +1007,17 @@ paths: summary: Fetch results from internal live search operationId: accountsCsv description: | - This is the API used by the “Export Leaked Accounts” tab of the Identity Portal. It only supports domains and email addresses as input. - - The response is the same as for /live/search/internal, returning a status and the search job ID. In case a user makes a new export and the previous one shall be discarded; - its search ID shall be specified in the “terminate” parameter to save system resources. Searches may consume Gigabytes of data, therefore any searches that are no longer - required shall be terminated. Searches can also be manually terminated via the /live/search/terminate function. + This is the API used by the “Export Leaked Accounts” tab of the Identity + Portal. It only supports domains and email addresses as input. + + The response is the same as for /live/search/internal, returning a + status and the search job ID. In case a user makes a new export and the + previous one shall be discarded; + its search ID shall be specified in the “terminate” parameter to save + system resources. Searches may consume Gigabytes of data, therefore any + searches that are no longer + required shall be terminated. Searches can also be manually terminated + via the /live/search/terminate function. parameters: - name: selector in: query @@ -869,7 +1029,7 @@ paths: example: info@intelx.io description: Search term - $ref: '#/components/parameters/OneBucketParam' - - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/LimitParam' - name: datefrom in: query required: false @@ -877,7 +1037,9 @@ paths: type: string example: '2024-01-01 00:00:00' pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' - description: Date from of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) + description: + Date from of the result in `YYYY-mm-dd HH:ii:ss` format. (Not + RFC3339) - name: dateto in: query required: false @@ -885,11 +1047,12 @@ paths: type: string example: '2024-01-31 23:59:59' pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' - description: Date to of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) - - $ref: '#/components/parameters/TerminateParam' + description: + Date to of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) + - $ref: '#/components/parameters/TerminateParam' responses: '200': - description: Success with JSON LiveSearchResponse + description: Success with JSON LiveSearchResponse '400': description: Invalid input /accounts/1: @@ -897,17 +1060,23 @@ paths: - url: 'https://3.intelx.io' get: security: - - ApiKeyAuth: [ ] - summary: Synchronous Export Leaked Accounts + - ApiKeyAuth: [] + summary: Synchronous Export Leaked Accounts operationId: accounts1 description: | - Use this function to query leaked accounts and return them immediately. - - Note: You should use the asynchronous function /accounts/csv as this one might miss results that are not available within the given timeout. - Searching for leaked accounts may take minutes, especially when searching for domains that have thousands of results. Internally the API - must fetch the entire data for each individual result which often results internally in Gigabytes of traffic and potentially causes delays. + Use this function to query leaked accounts and return them immediately. + + Note: You should use the asynchronous function /accounts/csv as this one + might miss results that are not available within the given timeout. + Searching for leaked accounts may take minutes, especially when + searching for domains that have thousands of results. Internally the API + must fetch the entire data for each individual result which often + results internally in Gigabytes of traffic and potentially causes + delays. - The default timeout is 10 minutes. The client must make sure to allow for such high HTTP timeouts on the client side. The timeout must not be higher than 1 hour, which is the HTTP server write timeout. + The default timeout is 10 minutes. The client must make sure to allow + for such high HTTP timeouts on the client side. The timeout must not be + higher than 1 hour, which is the HTTP server write timeout. parameters: - name: selector in: query @@ -919,7 +1088,7 @@ paths: example: info@intelx.io description: Search term - $ref: '#/components/parameters/OneBucketParam' - - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/LimitParam' - $ref: '#/components/parameters/TimeoutParam' - name: datefrom in: query @@ -928,7 +1097,9 @@ paths: type: string example: '2024-01-01 00:00:00' pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' - description: Date from of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) + description: + Date from of the result in `YYYY-mm-dd HH:ii:ss` format. (Not + RFC3339) - name: dateto in: query required: false @@ -936,11 +1107,12 @@ paths: type: string example: '2024-01-31 23:59:59' pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$' - description: Date to of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) + description: + Date to of the result in `YYYY-mm-dd HH:ii:ss` format. (Not RFC3339) - $ref: '#/components/parameters/TerminateParam' responses: '200': - description: JSON array of CSVRecord + description: JSON array of CSVRecord '400': description: Invalid input components: @@ -964,7 +1136,8 @@ components: type: integer example: 30 description: | - - Set a timeout value for the search. If omitted or set to 0, the default is used. + - Set a timeout value for the search. If omitted or set to 0, the + default is used. MediaParam: name: media in: query @@ -1011,7 +1184,9 @@ components: type: string format: uuid style: form - description: "Optional: ID of previous search to terminate to save system resources." + description: |- + Optional: ID of previous search to terminate to save + system resources. MaxresultsParam: name: maxresults in: query @@ -1077,7 +1252,8 @@ components: explode: true schema: type: string - pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\ + $" example: '61202067-543e-4e6a-8c23-11f9b8f008cf' description: 'Search id (e.g. "61202067-543e-4e6a-8c23-11f9b8f008cf")' schemas: @@ -1118,7 +1294,8 @@ components: description: Storage identifier, empty if not stored/available instore: type: boolean - description: Whether the data of the item is in store and the storageid is valid + description: + Whether the data of the item is in store and the storageid is valid size: type: integer format: int64 @@ -1195,7 +1372,8 @@ components: allOf: - $ref: '#/components/schemas/Item' - type: object - description: Search result record, extends Item with human-readable fields. + description: + Search result record, extends Item with human-readable fields. properties: accesslevelh: type: string