diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1c59e82..6a5b95c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,13 @@ version: 2 updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + - package-ecosystem: docker + directory: / + schedule: + interval: weekly - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 0c0cf02..9586a8e 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -14,6 +14,7 @@ permissions: env: CARGO_TERM_COLOR: always + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} jobs: build: @@ -42,6 +43,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Validate release tag and source + id: version + shell: bash + run: bash scripts/release-version.sh - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # stable (pinned) @@ -69,26 +78,17 @@ jobs: # Thin LTO for cross builds to avoid OOM during linking export CARGO_PROFILE_RELEASE_LTO=thin export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=4 - cross build --release --target ${{ matrix.target }} -p agentsniff -j2 + cross build --locked --release --target ${{ matrix.target }} -p agentsniff -j2 else - cargo build --release --target ${{ matrix.target }} -p agentsniff -j2 - fi - - - name: Determine version - id: version - shell: bash - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "version=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT - else - echo "version=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + cargo build --locked --release --target ${{ matrix.target }} -p agentsniff -j2 fi - name: Package (Unix) if: runner.os != 'Windows' shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} run: | - VERSION="${{ steps.version.outputs.version }}" ARCHIVE="agentsniff-${VERSION}-${{ matrix.target }}.tar.gz" mkdir -p staging cp target/${{ matrix.target }}/release/agentsniff staging/ @@ -101,8 +101,9 @@ jobs: - name: Package (Windows) if: runner.os == 'Windows' shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} run: | - VERSION="${{ steps.version.outputs.version }}" ARCHIVE="agentsniff-${VERSION}-${{ matrix.target }}.zip" mkdir -p staging cp target/${{ matrix.target }}/release/agentsniff.exe staging/ @@ -122,10 +123,21 @@ jobs: name: Checksums, signatures, and upload needs: build runs-on: ubuntu-latest + environment: release permissions: contents: write id-token: write steps: + - name: Checkout release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Validate release tag and source + id: tag + run: bash scripts/release-version.sh + - name: Download all artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -161,19 +173,10 @@ jobs: env: COSIGN_EXPERIMENTAL: "1" - - name: Determine tag - id: tag - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT - else - echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT - fi - - name: Upload to GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: - tag_name: ${{ steps.tag.outputs.tag }} + tag_name: ${{ steps.tag.outputs.version }} files: artifacts/* body: | ## Pre-Built Binaries diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..b12ae96 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,26 @@ +name: Rust security checks + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + CARGO_INCREMENTAL: "0" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy + - run: cargo fmt --all -- --check + - run: cargo test --workspace --locked + - run: cargo clippy --workspace --locked --lib --bins -- -D warnings + - run: cargo install cargo-audit --version 0.22.2 --locked + - run: cargo audit --deny warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba08c4..9162277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## Unreleased + +### Security and reliability + +- Replace unmaintained libyaml parsing with bounded safe-Rust YAML parsing and + secret-safe diagnostics; preserve numeric port keys, aliases, and defaults. +- Upgrade P-256 cryptography and require the compatible AgentPin 0.3.1 security + backport; preserve existing signed datasets, identity signatures, and pins. + +- Reject DNS-rebinding/cross-origin management requests; deny framing and MIME sniffing. +- Validate release tags as data and bind packaging/signing to the checked-out tag commit. +- Bound passive event retention, aggregate MCP evidence, scan storage, API pages, and backups. +- Cancel detector child tasks promptly and finish scans independently of slow notification servers. +- Restrict Unix SQLite database and sidecar permissions; preserve existing history on quota failure. +- Update TLS/runtime dependencies, lock release builds, pin container images, and schedule dependency updates. +- Honor YAML/environment/explicit CLI precedence, passive DNS duration, and container detector toggles. + +Release maintainers should configure the GitHub `release` environment with +required reviewers and protected release-tag rules. Dispatch releases from the +matching tag ref; branch-dispatched releases are rejected. + ## 2.4.0 ### Added diff --git a/Cargo.lock b/Cargo.lock index b360b45..96b9023 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,15 +4,14 @@ version = 4 [[package]] name = "agentpin" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b516254ba4260e3dafb9f6374e8b992ea4ee8c68a802efde668d83827c03ed7b" +version = "0.3.1" dependencies = [ "base64 0.21.7", "chrono", + "crypto-bigint", "hex", "p256", - "rand 0.8.8", + "rand_core 0.6.4", "serde", "serde_json", "sha2 0.10.9", @@ -37,6 +36,7 @@ dependencies = [ "chrono", "clap", "lettre", + "libc", "p256", "quick-xml", "redis", @@ -44,11 +44,12 @@ dependencies = [ "rusqlite", "rust-embed", "serde", + "serde-saphyr", "serde_json", - "serde_yaml", "sha2 0.10.9", "socket2 0.5.10", "sqlx", + "tempfile", "tokio", "tokio-stream", "tokio-util", @@ -86,6 +87,17 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstream" version = "0.6.21" @@ -138,9 +150,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" @@ -157,6 +169,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "assert_matches" version = "1.5.0" @@ -337,9 +355,9 @@ dependencies = [ [[package]] name = "base16ct" -version = "0.2.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base64" @@ -568,9 +586,9 @@ dependencies = [ [[package]] name = "const-oid" -version = "0.9.6" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "core-error" @@ -597,6 +615,18 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -656,12 +686,16 @@ checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-bigint" -version = "0.5.5" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" dependencies = [ - "generic-array", - "rand_core 0.6.4", + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", "subtle", "zeroize", ] @@ -682,23 +716,26 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.2", "hybrid-array", + "rand_core 0.10.1", ] [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", + "subtle", ] [[package]] name = "der" -version = "0.7.10" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" dependencies = [ "const-oid", "pem-rfc7468", @@ -712,9 +749,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", "crypto-common 0.1.6", - "subtle", ] [[package]] @@ -724,6 +759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid", "crypto-common 0.2.2", "ctutils", ] @@ -747,16 +783,17 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "ecdsa" -version = "0.16.9" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ "der", - "digest 0.10.7", + "digest 0.11.3", "elliptic-curve", "rfc6979", "signature", "spki", + "zeroize", ] [[package]] @@ -770,23 +807,21 @@ dependencies = [ [[package]] name = "elliptic-curve" -version = "0.13.8" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" dependencies = [ "base16ct", - "base64ct", "crypto-bigint", - "digest 0.10.7", + "crypto-common 0.2.2", + "digest 0.11.3", "ff", - "generic-array", "group", + "hybrid-array", "pem-rfc7468", "pkcs8", - "rand_core 0.6.4", + "rand_core 0.10.1", "sec1", - "serde_json", - "serdect", "subtle", "zeroize", ] @@ -807,6 +842,29 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -873,11 +931,11 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "ff" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] @@ -998,7 +1056,6 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -1014,20 +1071,6 @@ dependencies = [ "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.2" @@ -1035,11 +1078,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1055,14 +1100,24 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "granit-parser" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da55feca5d1625466bae068f9fd4b87892caeae70c3fe671ddb0f50d41aebf37" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] @@ -1115,16 +1170,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.13.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", + "hmac", ] [[package]] @@ -1183,11 +1229,13 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -1490,6 +1538,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1565,6 +1619,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "8.0.0" @@ -1645,14 +1711,15 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "p256" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ "ecdsa", "elliptic-curve", + "primefield", "primeorder", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] @@ -1686,9 +1753,9 @@ dependencies = [ [[package]] name = "pem-rfc7468" -version = "0.7.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" dependencies = [ "base64ct", ] @@ -1713,9 +1780,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkcs8" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der", "spki", @@ -1736,15 +1803,6 @@ 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 = "prettyplease" version = "0.2.37" @@ -1755,13 +1813,31 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "primeorder" -version = "0.13.6" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -1805,14 +1881,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.2", + "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -1853,39 +1930,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" -[[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.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -1897,26 +1947,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[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 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -1928,18 +1958,18 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_core" -version = "0.10.1" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] [[package]] name = "redis" @@ -2037,12 +2067,12 @@ dependencies = [ [[package]] name = "rfc6979" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "hmac 0.12.1", - "subtle", + "crypto-bigint", + "hmac", ] [[package]] @@ -2125,11 +2155,24 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -2164,9 +2207,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -2211,15 +2254,14 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sec1" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", + "ctutils", "der", - "generic-array", - "pkcs8", - "serdect", + "hybrid-array", "subtle", "zeroize", ] @@ -2267,6 +2309,23 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a253edfdea34032da21af84e16fcc94f4c7a6ba237f9f3bc2dbf43cea8535c44" +dependencies = [ + "annotate-snippets", + "base64 0.23.1", + "encoding_rs_io", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2323,24 +2382,11 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serdect" -version = "0.2.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ "base16ct", "serde", @@ -2406,14 +2452,20 @@ dependencies = [ [[package]] name = "signature" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", + "digest 0.11.3", + "rand_core 0.10.1", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -2460,9 +2512,9 @@ dependencies = [ [[package]] name = "spki" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ "base64ct", "der", @@ -2611,12 +2663,12 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac 0.13.0", + "hmac", "itoa", "log", "md-5", "memchr", - "rand 0.10.3", + "rand", "serde", "serde_json", "sha2 0.11.0", @@ -2723,6 +2775,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2799,9 +2864,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -2976,9 +3041,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-bidi" @@ -3008,16 +3073,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-width" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" [[package]] -name = "unsafe-libyaml" -version = "0.2.11" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" @@ -3237,9 +3302,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -3562,6 +3627,18 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.2" @@ -3597,26 +3674,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Dockerfile b/Dockerfile index 377d61c..138daee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.99-bookworm AS builder +FROM rust:1.99-bookworm@sha256:9181b56c2a21ab1d5e73cffe04fc762426d30aa7fac3c91141dd033f49a7fc8b AS builder WORKDIR /src COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ @@ -7,7 +7,7 @@ RUN cargo build --locked --release -p agentsniff # Enrichment tools (opt-in via --enrich). Permissive licenses only: # naabu and httpx are MIT, zgrab2 is Apache-2.0. The image does not include Nmap. -FROM golang:1.24.7-bookworm AS zgrab2 +FROM golang:1.24.7-bookworm@sha256:b8bae5bd9ba9b1f89b635c91c24cc75cea335a16fb5076310f38566fc674b1ec AS zgrab2 # go install verifies every module against go.sum and sum.golang.org. ENV CGO_ENABLED=0 GOTOOLCHAIN=local RUN go install github.com/zmap/zgrab2/cmd/zgrab2@v1.0.0 \ @@ -15,7 +15,7 @@ RUN go install github.com/zmap/zgrab2/cmd/zgrab2@v1.0.0 \ && cp /go/pkg/mod/github.com/zmap/zgrab2@v1.0.0/LICENSE /out/third-party/zgrab2/LICENSE \ && cp /go/bin/zgrab2 /out/zgrab2 -FROM debian:bookworm-slim AS pdtools +FROM debian:bookworm-slim@sha256:99c0b25c46d0214d5a2091092e7908907dcaecd6a962cde0ac700acee222b6f3 AS pdtools ARG TARGETARCH=amd64 ARG NAABU_VERSION=2.6.1 ARG NAABU_SHA256_AMD64=018c4c9884dea971eda860435ede3021d1150732f34cfd245498c6726d8cab90 @@ -46,7 +46,7 @@ RUN set -eu; \ # fails if any dependency has a forbidden or restricted (e.g. GPL, AGPL) license, # or if a module embedded in a shipped binary has no saved notice. # `save` copies notices, plus source for reciprocal (MPL) licenses. -FROM golang:1.26.0-bookworm AS licenses +FROM golang:1.26.0-bookworm@sha256:2a0ba12e116687098780d3ce700f9ce3cb340783779646aafbabed748fa6677c AS licenses ENV GOTOOLCHAIN=local RUN go install github.com/google/go-licenses/v2@v2.0.1 # The exact binaries that ship, so their embedded module lists can be checked. @@ -67,7 +67,7 @@ RUN set -eu; mkdir -p /src; \ verify-go-notices "/bin-check/$3" "/src/$3.csv"; \ done -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:99c0b25c46d0214d5a2091092e7908907dcaecd6a962cde0ac700acee222b6f3 LABEL maintainer="ThirdKey AI" LABEL description="AgentSniff - AI Agent Network Scanner" diff --git a/README.md b/README.md index e54928e..a4f9a13 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,12 @@ Passive capture is an explicit privileged profile: docker compose --profile passive up -d agentsniff-passive ``` -The passive profile uses host networking plus `NET_RAW` and `NET_ADMIN`. Use it only on a trusted scanner host. +The passive profile enables raw-socket SSE timing with host networking and +`NET_RAW` only. The standard image does not contain eBPF programs. For kernel +DNS/TLS/process capture, use a Linux host build with `--features ebpf` and set +`AGENTSNIFF_EBPF_INTERFACE`. Use passive capture only on an authorized scanner host. +The passive profile has its own root-owned state volume; it does not reuse or +change permissions on the standard service's non-root state. ## CLI diff --git a/crates/agentsniff/Cargo.toml b/crates/agentsniff/Cargo.toml index 9544732..6f89153 100644 --- a/crates/agentsniff/Cargo.toml +++ b/crates/agentsniff/Cargo.toml @@ -41,23 +41,25 @@ agentsniff-common = { path = "../agentsniff-common", version = "2.4.0", features tokio = { version = "1", features = ["full"] } clap = { version = "4", features = ["derive"] } anyhow = "1" -agentpin = "0.3.0" +agentpin = "0.3.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } -serde_yaml = "0.9" +serde-saphyr = { version = "2.0", default-features = false, features = ["serialize", "deserialize"] } async-trait = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -rusqlite = { version = "0.39", features = ["bundled"] } +rusqlite = { version = "0.39", features = ["bundled", "backup"] } sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "json"] } redis = { version = "1.7", default-features = false, features = ["tokio-rustls-comp", "tls-rustls-webpki-roots", "connection-manager"] } -tokio-util = "0.7" +tokio-util = { version = "0.7", features = ["io"] } +tempfile = "3" +libc = "0.2" axum = "0.7" tokio-stream = { version = "0.1", features = ["sync"] } -p256 = { version = "0.13", features = ["ecdsa"] } +p256 = { version = "0.14", features = ["ecdsa"] } sha2 = "0.10" base64 = "0.22" rust-embed = { version = "8", features = ["include-exclude"] } diff --git a/crates/agentsniff/assets/index.html b/crates/agentsniff/assets/index.html index 790232b..003177b 100644 --- a/crates/agentsniff/assets/index.html +++ b/crates/agentsniff/assets/index.html @@ -947,13 +947,13 @@

Database

if (s.status === 'completed' || s.status === 'cancelled' || s.status === 'failed') { clearInterval(poll); if (s.status === 'completed') { - const resultResp = await fetch('/api/scan/results'); + const resultResp = await fetchScanPages('/api/scan/results'); lastResult = await resultResp.json(); updateResultStats(lastResult); renderAgents(lastResult.agents || [], lastResult.diff); log('Scan complete: ' + (lastResult.agents || []).length + ' assets found'); } else if (s.status === 'cancelled') { - const resultResp = await fetch('/api/scan/results'); + const resultResp = await fetchScanPages('/api/scan/results'); lastResult = await resultResp.json(); updateResultStats(lastResult); renderAgents(lastResult.agents || [], lastResult.diff); @@ -966,7 +966,7 @@

Database

}, 2000); } else if (status.status === 'completed') { - const resultResp = await fetch('/api/scan/results'); + const resultResp = await fetchScanPages('/api/scan/results'); lastResult = await resultResp.json(); updateResultStats(lastResult); renderAgents(lastResult.agents || [], lastResult.diff); @@ -977,6 +977,25 @@

Database

} } +// Fetch bounded result pages, pinning subsequent requests to the first scan ID. +async function fetchScanPages(url) { + const response = await fetch(url); + if (!response.ok) return response; + const result = await response.json(); + let next = result.pagination && result.pagination.next_offset; + while (next !== null && next !== undefined) { + const pageResponse = await fetch('/api/scan/' + encodeURIComponent(result.scan_id) + '?limit=100&offset=' + next); + if (!pageResponse.ok) throw new Error('Unable to load result page'); + const page = await pageResponse.json(); + if (page.scan_id !== result.scan_id) throw new Error('Scan changed while loading results'); + result.agents.push(...page.agents); + const following = page.pagination && page.pagination.next_offset; + if (following != null && following <= next) throw new Error('Invalid result pagination'); + next = following; + } + return { ok: true, json: async () => result }; +} + function log(msg) { const panel = document.getElementById('logPanel'); const ts = new Date().toLocaleTimeString(); @@ -1352,7 +1371,7 @@

Scanning Network

if (s.status !== 'running') { clearInterval(poll); if (s.status === 'cancelled' || s.status === 'completed') { - const resultResp = await fetch('/api/scan/results'); + const resultResp = await fetchScanPages('/api/scan/results'); lastResult = await resultResp.json(); updateResultStats(lastResult); renderAgents(lastResult.agents || [], lastResult.diff); @@ -1458,7 +1477,7 @@

Scanning Network

if (data.event === 'scan_completed') { evtSource.close(); try { - const resultResp = await fetch('/api/scan/results'); + const resultResp = await fetchScanPages('/api/scan/results'); lastResult = resultResp.ok ? await resultResp.json() : { agents, summary: data.summary }; } catch (_) { lastResult = { agents, summary: data.summary }; @@ -1472,7 +1491,7 @@

Scanning Network

if (data.event === 'scan_cancelled') { evtSource.close(); try { - const resultResp = await fetch('/api/scan/results'); + const resultResp = await fetchScanPages('/api/scan/results'); lastResult = resultResp.ok ? await resultResp.json() : { agents, summary: data.summary }; } catch (_) { lastResult = { agents, summary: data.summary }; @@ -1505,7 +1524,7 @@

Scanning Network

if (status.status === 'completed' || status.status === 'cancelled') { clearInterval(poll); - const resultResp = await fetch('/api/scan/results'); + const resultResp = await fetchScanPages('/api/scan/results'); lastResult = await resultResp.json(); updateResultStats(lastResult); renderAgents(lastResult.agents || [], lastResult.diff); @@ -1815,7 +1834,7 @@

Scanning Network

async function loadHistoryScan(scanId) { try { log('Loading scan ' + scanId.substring(0, 8) + '...'); - const resp = await fetch('/api/scan/' + encodeURIComponent(scanId)); + const resp = await fetchScanPages('/api/scan/' + encodeURIComponent(scanId)); if (!resp.ok) { log('Scan not found'); return; } const data = await resp.json(); lastResult = data; diff --git a/crates/agentsniff/src/config.rs b/crates/agentsniff/src/config.rs index 422a0d3..508c44d 100644 --- a/crates/agentsniff/src/config.rs +++ b/crates/agentsniff/src/config.rs @@ -31,6 +31,9 @@ impl Default for StorageConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct ScanConfig { + #[serde(skip)] + #[doc(hidden)] + pub evidence_budget: std::sync::Arc, // ── Network targets ────────────────────────────────────────────── pub target_network: String, pub target_hosts: Vec, @@ -137,6 +140,7 @@ impl Default for ScanConfig { fn default() -> Self { Self { // Network targets + evidence_budget: std::sync::Arc::default(), target_network: "192.168.1.0/24".to_string(), target_hosts: Vec::new(), exclude_hosts: Vec::new(), @@ -175,7 +179,7 @@ impl Default for ScanConfig { api_host: "127.0.0.1".to_string(), api_port: 9090, api_token: String::new(), - api_cors_origins: vec!["*".to_string()], + api_cors_origins: Vec::new(), // AgentPin verifier agentpin_trust_bundle_path: String::new(), @@ -239,9 +243,28 @@ impl ScanConfig { /// Load configuration from a YAML file on disk. pub fn from_yaml_file(path: &str) -> anyhow::Result { let contents = std::fs::read_to_string(path)?; - let value: serde_yaml::Value = serde_yaml::from_str(&contents)?; + // Never expand merge keys or print source snippets (which can contain + // credentials). Keep parser/alias budgets and duplicate-key rejection. + let options = serde_saphyr::options! { + strict_booleans: true, + merge_keys: serde_saphyr::MergeKeyPolicy::AsOrdinary, + with_snippet: false, + }; + // Preserve scalar types before converting to config: direct typed YAML + // deserialization would accept quoted booleans/numbers as native values. + // JSON's map-key deserializer still supports custom_agent_ports' u16 keys. + let value: serde_json::Value = serde_saphyr::from_str_with_options(&contents, options) + .map_err(|error| match error.location() { + Some(location) => anyhow::anyhow!( + "invalid YAML configuration at line {}, column {}", + location.line(), + location.column() + ), + None => anyhow::anyhow!("invalid YAML configuration"), + })?; check_removed_nmap_keys(&value)?; - let config: Self = serde_yaml::from_value(value)?; + let config: Self = serde_json::from_value(value) + .map_err(|_| anyhow::anyhow!("invalid configuration value type; check booleans, numbers, lists, and quoted strings"))?; Ok(config) } @@ -270,49 +293,160 @@ impl ScanConfig { } /// Apply environment-variable overrides on top of an existing config. - pub fn from_env_with_defaults(mut config: Self) -> Self { - if let Ok(v) = std::env::var("AGENTSNIFF_TARGET_NETWORK") { - config.target_network = v; - } - if let Ok(v) = std::env::var("AGENTSNIFF_API_PORT") { - if let Ok(port) = v.parse::() { - config.api_port = port; + pub fn from_env_with_defaults(config: Self) -> anyhow::Result { + Self::apply_env(config, |name| std::env::var(name).ok()) + } + + fn apply_env(config: Self, lookup: impl Fn(&str) -> Option) -> anyhow::Result { + let mut values = serde_json::to_value(&config).expect("serializable configuration"); + for (key, current) in values.as_object_mut().expect("configuration object") { + if key == "storage" { + for (key, current) in current.as_object_mut().expect("storage object") { + let name = match key.as_str() { + "backend" => "STORAGE_BACKEND", + "sqlite_path" => "DB_PATH", + _ => key, + }; + if let Some(raw) = lookup(&format!("AGENTSNIFF_{}", name.to_ascii_uppercase())) + { + *current = serde_json::Value::String(raw); + } + } + continue; + } + let name = match key.as_str() { + "agentpin_trust_bundle_path" => "AGENTPIN_TRUST_BUNDLE", + "agentpin_pin_store_path" => "AGENTPIN_PIN_STORE", + "agentpin_expected_audience" => "AGENTPIN_AUDIENCE", + _ => key, + }; + let name = format!("AGENTSNIFF_{}", name.to_ascii_uppercase()); + let Some(raw) = lookup(&name) else { continue }; + let parsed = match current { + serde_json::Value::String(_) => Some(serde_json::Value::String(raw)), + serde_json::Value::Bool(_) => match raw.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(serde_json::Value::Bool(true)), + "0" | "false" | "no" | "off" => Some(serde_json::Value::Bool(false)), + _ => None, + }, + serde_json::Value::Array(_) => Some(serde_json::json!(raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect::>())), + _ => serde_json::from_str(&raw).ok(), + }; + if let Some(value) = parsed { + *current = value; + } else { + anyhow::bail!("invalid value for {name}"); } } - if let Ok(v) = std::env::var("AGENTSNIFF_API_HOST") { - config.api_host = v; - } - if let Ok(v) = std::env::var("AGENTSNIFF_API_TOKEN") { - config.api_token = v; - } - if let Ok(v) = std::env::var("AGENTSNIFF_AGENTPIN_TRUST_BUNDLE") { - config.agentpin_trust_bundle_path = v; - } - if let Ok(v) = std::env::var("AGENTSNIFF_AGENTPIN_PIN_STORE") { - config.agentpin_pin_store_path = v; - } - if let Ok(v) = std::env::var("AGENTSNIFF_AGENTPIN_AUDIENCE") { - config.agentpin_expected_audience = v; - } - if let Ok(v) = std::env::var("AGENTSNIFF_STORAGE_BACKEND") { - config.storage.backend = v; - } - if let Ok(v) = std::env::var("AGENTSNIFF_POSTGRES_URL") { - config.storage.postgres_url = v; - } - if let Ok(v) = std::env::var("AGENTSNIFF_REDIS_URL") { - config.storage.redis_url = v; - } - if let Ok(v) = std::env::var("AGENTSNIFF_REDIS_CHANNEL") { - config.storage.redis_channel = v; + let mut updated: Self = serde_json::from_value(values) + .map_err(|_| anyhow::anyhow!("invalid environment configuration; refusing to start"))?; + if updated.webhook_url != config.webhook_url + && lookup("AGENTSNIFF_WEBHOOK_HEADERS").is_none() + { + updated.webhook_headers.clear(); } - if let Ok(v) = std::env::var("AGENTSNIFF_VERBOSE") { - config.verbose = v == "1" || v.eq_ignore_ascii_case("true"); + if (updated.smtp_host != config.smtp_host + || updated.smtp_port != config.smtp_port + || updated.smtp_use_tls != config.smtp_use_tls) + && lookup("AGENTSNIFF_SMTP_PASSWORD").is_none() + { + updated.smtp_user.clear(); + updated.smtp_password.clear(); } - if let Ok(v) = std::env::var("AGENTSNIFF_ALERT_CHANGES_ONLY") { - config.alert_changes_only = v == "1" || v.eq_ignore_ascii_case("true"); + Ok(updated) + } + + pub fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.http_timeout.is_finite() && self.http_timeout > 0.0 && self.http_timeout <= 300.0, + "http_timeout must be between 0 and 300 seconds" + ); + anyhow::ensure!( + self.port_scan_timeout.is_finite() + && self.port_scan_timeout > 0.0 + && self.port_scan_timeout <= 300.0, + "port_scan_timeout must be between 0 and 300 seconds" + ); + anyhow::ensure!( + (1..=1024).contains(&self.http_concurrency) + && (1..=1024).contains(&self.port_scan_concurrency), + "concurrency must be between 1 and 1024" + ); + anyhow::ensure!( + self.dns_monitor_duration <= 3600, + "dns_monitor_duration cannot exceed 3600 seconds" + ); + Ok(()) + } +} + +#[cfg(test)] +mod environment_tests { + use super::*; + + #[test] + fn overrides_yaml_and_retains_unset_values() { + let base = ScanConfig { + target_network: "10.0.0.1/32".into(), + http_timeout: 2.0, + ..ScanConfig::default() + }; + let values = HashMap::from([ + ("AGENTSNIFF_ENABLE_DNS_MONITOR", "false"), + ("AGENTSNIFF_API_PORT", "9191"), + ("AGENTSNIFF_HTTP_TIMEOUT", "3.5"), + ("AGENTSNIFF_STORAGE_BACKEND", "postgres"), + ("AGENTSNIFF_POSTGRES_URL", "postgres://example/test"), + ("AGENTSNIFF_TARGET_HOSTS", "127.0.0.1, ::1"), + ]); + let config = + ScanConfig::apply_env(base, |key| values.get(key).map(|v| v.to_string())).unwrap(); + assert!(!config.enable_dns_monitor); + assert_eq!(config.api_port, 9191); + assert_eq!(config.http_timeout, 3.5); + assert_eq!(config.target_network, "10.0.0.1/32"); + assert_eq!(config.storage.backend, "postgres"); + assert_eq!(config.target_hosts, ["127.0.0.1", "::1"]); + } + + #[test] + fn invalid_environment_fails_closed_without_exposing_secrets() { + for (name, raw) in [ + ("AGENTSNIFF_API_PORT", "not-a-number"), + ("AGENTSNIFF_ENABLE_DNS_MONITOR", "maybe"), + ("AGENTSNIFF_WEBHOOK_HEADERS", "{secret broken"), + ] { + let error = ScanConfig::apply_env(ScanConfig::default(), |key| { + (key == name).then(|| raw.into()) + }) + .unwrap_err(); + assert!(!error.to_string().contains(raw)); } - config + } + + #[test] + fn destination_environment_changes_clear_retained_credentials() { + let base = ScanConfig { + webhook_url: "https://old.example".into(), + webhook_headers: HashMap::from([("Authorization".into(), "secret".into())]), + smtp_host: "old.example".into(), + smtp_user: "user".into(), + smtp_password: "secret".into(), + ..ScanConfig::default() + }; + let config = ScanConfig::apply_env(base, |key| match key { + "AGENTSNIFF_WEBHOOK_URL" => Some("https://new.example".into()), + "AGENTSNIFF_SMTP_HOST" => Some("new.example".into()), + _ => None, + }) + .unwrap(); + assert!(config.webhook_headers.is_empty()); + assert!(config.smtp_password.is_empty()); + assert!(config.smtp_user.is_empty()); } } @@ -320,10 +454,9 @@ impl ScanConfig { /// Keys removed in 2.3.0 when live Nmap execution was dropped. const REMOVED_NMAP_KEYS: &[&str] = &["nmap_enabled", "nmap_scan_args", "nmap_timeout"]; -/// Refuse configs that ask for live Nmap (`nmap_enabled: true`); warn about -/// and ignore the other removed keys so configs that disabled Nmap still load. -pub fn check_removed_nmap_keys(value: &serde_yaml::Value) -> anyhow::Result<()> { - if value.get("nmap_enabled").and_then(|v| v.as_bool()) == Some(true) { +/// Refuse live Nmap requests; warn about other removed settings. +pub fn check_removed_nmap_keys(value: &serde_json::Value) -> anyhow::Result<()> { + if value.get("nmap_enabled").and_then(|value| value.as_bool()) == Some(true) { anyhow::bail!( "nmap_enabled was removed in 2.3.0: AgentSniff no longer runs Nmap. \ Use enrich_enabled (naabu, httpx, zgrab2) or import your own results \ @@ -379,8 +512,7 @@ dns_monitor_duration: 60 # seconds to monitor DNS # api_host: "127.0.0.1" # api_port: 9090 # api_token: "" # required for non-loopback; prefer AGENTSNIFF_API_TOKEN -# api_cors_origins: -# - "*" +# Cross-origin management requests are not supported. # ── AgentPin credential verifier (REST + MCP) ─────────────────────── # Configure all three values to expose the verifier. The trust bundle must diff --git a/crates/agentsniff/src/detectors/a2a_detector.rs b/crates/agentsniff/src/detectors/a2a_detector.rs index a41d3a1..4ca5d02 100644 --- a/crates/agentsniff/src/detectors/a2a_detector.rs +++ b/crates/agentsniff/src/detectors/a2a_detector.rs @@ -150,6 +150,7 @@ pub fn validate_agent_card( } pub struct A2aDetector { + budget: std::sync::Arc, timeout: Duration, concurrency: usize, client: Client, @@ -167,8 +168,9 @@ impl A2aDetector { .build() .expect("failed to build reqwest client"); Self { + budget: config.evidence_budget.clone(), timeout, - concurrency: config.http_concurrency, + concurrency: config.http_concurrency.clamp(1, 32), client, trusted_keys: config.a2a_trusted_keys.clone(), ports: config.ports_or(A2A_PORTS), @@ -281,7 +283,7 @@ impl Detector for A2aDetector { for &port in &self.ports { let permit = semaphore.clone().acquire_owned().await?; let timeout = self.timeout; - connect_handles.push(tokio::spawn(async move { + connect_handles.push(crate::limits::spawn(async move { let _permit = permit; (ip, port, Self::is_port_open(ip, port, timeout).await) })); @@ -297,14 +299,18 @@ impl Detector for A2aDetector { let mut handles = Vec::new(); for (ip, port) in open_pairs { + self.budget.check()?; for path in [A2A_PATH, LEGACY_A2A_PATH] { let permit = semaphore.clone().acquire_owned().await?; let client = self.client.clone(); let trusted_keys = self.trusted_keys.clone(); - handles.push(tokio::spawn(async move { - let _permit = permit; - Self::probe_path(&client, &trusted_keys, ip, port, path).await - })); + handles.push(crate::limits::spawn_signal( + self.budget.clone(), + async move { + let _permit = permit; + Self::probe_path(&client, &trusted_keys, ip, port, path).await + }, + )); } } @@ -314,6 +320,7 @@ impl Detector for A2aDetector { signals.push(signal); } } + self.budget.check()?; Ok(signals) } diff --git a/crates/agentsniff/src/detectors/agentpin_prober.rs b/crates/agentsniff/src/detectors/agentpin_prober.rs index fe6fc2f..419223b 100644 --- a/crates/agentsniff/src/detectors/agentpin_prober.rs +++ b/crates/agentsniff/src/detectors/agentpin_prober.rs @@ -100,6 +100,7 @@ pub fn validate_agentpin_document(doc: &Value) -> bool { /// AgentPin prober that discovers AI agent identities via the AgentPin protocol. pub struct AgentpinProber { + budget: std::sync::Arc, #[allow(dead_code)] timeout: Duration, client: reqwest::Client, @@ -109,6 +110,11 @@ pub struct AgentpinProber { http_80: bool, } +fn retain_document(doc: Option, budget: &crate::limits::EvidenceBudget) -> Option { + doc.filter(validate_agentpin_document) + .filter(|doc| budget.reserve(doc)) +} + impl AgentpinProber { /// Create a new AgentPin prober from scan configuration. pub fn new(config: &ScanConfig) -> Self { @@ -121,6 +127,7 @@ impl AgentpinProber { .expect("failed to build reqwest client"); Self { + budget: config.evidence_budget.clone(), timeout, client, ports: config.ports_or(AGENTPIN_PORTS), @@ -211,13 +218,16 @@ impl Detector for AgentpinProber { let sem = std::sync::Arc::new(Semaphore::new(128)); let client = self.client.clone(); - let mut handles: Vec)>> = - Vec::new(); + let mut handles: Vec< + crate::limits::ScopedTask<(IpAddr, u16, &'static str, Option)>, + > = Vec::new(); for &ip in targets { + self.budget.check()?; for &port in &self.ports { let permit = sem.clone().acquire_owned().await?; + let budget = self.budget.clone(); let client = client.clone(); - handles.push(tokio::spawn(async move { + handles.push(crate::limits::spawn(async move { let _permit = permit; let addr = std::net::SocketAddr::new(ip, port); let url = format!("https://{addr}{AGENTPIN_PATH}"); @@ -231,7 +241,7 @@ impl Detector for AgentpinProber { } _ => None, }; - let doc = doc.filter(validate_agentpin_document); + let doc = retain_document(doc, &budget); (ip, port, "https", doc) })); } @@ -240,8 +250,9 @@ impl Detector for AgentpinProber { continue; } let permit = sem.clone().acquire_owned().await?; + let budget = self.budget.clone(); let client = client.clone(); - handles.push(tokio::spawn(async move { + handles.push(crate::limits::spawn(async move { let _permit = permit; let addr = std::net::SocketAddr::new(ip, 80); let url = format!("http://{addr}{AGENTPIN_PATH}"); @@ -255,7 +266,7 @@ impl Detector for AgentpinProber { } _ => None, }; - let doc = doc.filter(validate_agentpin_document); + let doc = retain_document(doc, &budget); (ip, 80u16, "http", doc) })); } @@ -266,6 +277,7 @@ impl Detector for AgentpinProber { signals.extend(self.signals_from_document(ip, port, scheme, &doc)); } } + self.budget.check()?; Ok(signals) } @@ -306,6 +318,19 @@ mod tests { let signals = detector.signals_from_document("192.0.2.10".parse().unwrap(), 443, "https", &doc); + let mut padded = doc.clone(); + padded["padding"] = Value::String("x".repeat(200_000)); + let budget = crate::limits::EvidenceBudget::default(); + assert!(retain_document(Some(doc), &budget).is_some()); + let mut accepted = 0; + for _ in 0..100 { + if retain_document(Some(padded.clone()), &budget).is_some() { + accepted += 1; + } + } + assert!(accepted < 42); + assert!(budget.check().is_err()); + assert_eq!(signals.len(), 1); assert_eq!(signals[0].confidence, Confidence::Medium); assert_eq!(signals[0].signal_type, "agentpin_unsigned_document"); diff --git a/crates/agentsniff/src/detectors/dns_monitor.rs b/crates/agentsniff/src/detectors/dns_monitor.rs index 7561f65..0d0ce40 100644 --- a/crates/agentsniff/src/detectors/dns_monitor.rs +++ b/crates/agentsniff/src/detectors/dns_monitor.rs @@ -66,9 +66,10 @@ impl Detector for DnsMonitorDetector { // Try eBPF passive mode first if let Some(ref channels) = self.ebpf_channels { let mut rx = channels.dns_tx.subscribe(); - let duration = std::time::Duration::from_secs(5); + let duration = std::time::Duration::from_secs(self.config.dns_monitor_duration); let deadline = tokio::time::Instant::now() + duration; let mut signals = Vec::new(); + let mut per_host = std::collections::HashMap::::new(); let target_set: std::collections::HashSet = targets.iter().copied().collect(); loop { @@ -78,10 +79,12 @@ impl Detector for DnsMonitorDetector { if !target_set.contains(&IpAddr::V4(src_ip)) { continue; } - let domain = - std::str::from_utf8(&event.query_name[..event.query_len as usize]) - .unwrap_or("") - .to_lowercase(); + let domain = std::str::from_utf8( + &event.query_name + [..(event.query_len as usize).min(event.query_name.len())], + ) + .unwrap_or("") + .to_lowercase(); if domain.is_empty() { continue; } @@ -96,7 +99,13 @@ impl Detector for DnsMonitorDetector { } else { ("agent_infrastructure_domain_query", Confidence::Medium) }; - signals.push(Signal::new( + let count = per_host.entry(IpAddr::V4(src_ip)).or_default(); + anyhow::ensure!( + *count < crate::limits::MAX_AGENT_SIGNALS, + "DNS event budget exceeded; capture incomplete" + ); + *count += 1; + let signal = Signal::new( DetectorType::DnsMonitor, signal_type.into(), format!("DNS query matching {} signature: {}", category, domain), @@ -107,11 +116,19 @@ impl Detector for DnsMonitorDetector { "category": category, "source": "ebpf", }), - )); + ); + anyhow::ensure!( + self.config.evidence_budget.reserve(&signal), + "DNS evidence budget exceeded; capture incomplete" + ); + signals.push(signal); } } - Ok(Err(_)) => break, // channel closed - Err(_) => break, // timeout + Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => { + anyhow::bail!("DNS events dropped; capture incomplete") + } + Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break, + Err(_) => break, // timeout } } diff --git a/crates/agentsniff/src/detectors/endpoint_prober.rs b/crates/agentsniff/src/detectors/endpoint_prober.rs index fc2922b..a99dd55 100644 --- a/crates/agentsniff/src/detectors/endpoint_prober.rs +++ b/crates/agentsniff/src/detectors/endpoint_prober.rs @@ -251,6 +251,7 @@ pub fn match_request_user_agent(frameworks: &Value, user_agent: &str) -> Vec, timeout: Duration, concurrency: usize, client: reqwest::Client, @@ -272,6 +273,7 @@ impl EndpointProberDetector { let frameworks = Self::load_frameworks(config); Self { + budget: config.evidence_budget.clone(), timeout, concurrency: config.http_concurrency, client, @@ -755,11 +757,11 @@ impl Detector for EndpointProberDetector { // Probe all (host, port) pairs concurrently — sequential per-port // probing across a /24 turned this detector into a 25-minute loop. - let mut probe_handles: Vec> = Vec::new(); + let mut probe_handles: Vec> = Vec::new(); for &ip in targets { for &port in &self.ports { let permit = semaphore.clone().acquire_owned().await?; - probe_handles.push(tokio::spawn(async move { + probe_handles.push(crate::limits::spawn(async move { let _permit = permit; (ip, port, Self::is_port_open(ip, port).await) })); @@ -776,6 +778,7 @@ impl Detector for EndpointProberDetector { } for (ip, open_ports) in open_by_host { + self.budget.check()?; if open_ports.is_empty() { continue; } @@ -787,10 +790,12 @@ impl Detector for EndpointProberDetector { let concurrency = self.concurrency; let frameworks = self.frameworks.clone(); let ports = self.ports.clone(); + let budget = self.budget.clone(); - let handle = tokio::spawn(async move { + let handle = crate::limits::spawn_signals(self.budget.clone(), async move { let _permit = permit; let detector = EndpointProberDetector { + budget, timeout, concurrency, client, @@ -811,6 +816,7 @@ impl Detector for EndpointProberDetector { } } + self.budget.check()?; Ok(deduplicate(signals)) } diff --git a/crates/agentsniff/src/detectors/mcp_detector.rs b/crates/agentsniff/src/detectors/mcp_detector.rs index 9d71711..84405c3 100644 --- a/crates/agentsniff/src/detectors/mcp_detector.rs +++ b/crates/agentsniff/src/detectors/mcp_detector.rs @@ -142,6 +142,8 @@ fn load_mcp_ports(signatures: &SignatureData) -> Vec { #[derive(Clone)] pub struct McpDetector { + budget: std::sync::Arc, + concurrency: usize, client: Client, protocols: Vec, ports: Vec, @@ -166,6 +168,8 @@ impl McpDetector { let ports = config.ports_or(&load_mcp_ports(&signatures)); let agentpin_binding = load_agentpin_binding(&signatures.mcp_methods); Self { + budget: config.evidence_budget.clone(), + concurrency: config.http_concurrency.clamp(1, 32), client, protocols, ports, @@ -305,16 +309,15 @@ impl McpDetector { .await; } - let result = body.get("result").cloned().unwrap_or(Value::Null); - let server_info = result - .get("serverInfo") - .or_else(|| result.get("server")) - .cloned() - .unwrap_or(Value::Null); - let capabilities = result - .get("capabilities") - .cloned() - .unwrap_or_else(|| serde_json::json!({})); + let result = body.get("result").unwrap_or(&Value::Null); + let (server_info, server_info_truncated) = crate::limits::sample_json( + result + .get("serverInfo") + .or_else(|| result.get("server")) + .unwrap_or(&Value::Null), + ); + let (capabilities, capabilities_truncated) = + crate::limits::sample_json(result.get("capabilities").unwrap_or(&Value::Null)); let mut signals = vec![Signal::new( DetectorType::McpDetector, @@ -332,13 +335,14 @@ impl McpDetector { "discovery_method": protocol.discovery_method, "server_info": server_info, "capabilities": capabilities, + "evidence_truncated": server_info_truncated || capabilities_truncated, "version_confirmed_by_server": version_confirmed, "session_established": session_id.is_some(), }), )]; if let Some(binding) = &self.agentpin_binding { - if let Some(advertised) = agentpin_capability(&result, &binding.capability_path) { + if let Some(advertised) = agentpin_capability(result, &binding.capability_path) { let advertised_version = advertised.get("version").and_then(Value::as_str); let required = advertised .get("required") @@ -353,7 +357,7 @@ impl McpDetector { port, path, advertised_version - .map(|version| format!(" version {version}")) + .map(|version| format!(" version {}", version.chars().take(128).collect::())) .unwrap_or_default() ), Confidence::High, @@ -365,7 +369,8 @@ impl McpDetector { "capability_path": binding.capability_path, "credential_meta_key": binding.credential_meta_key, "expected_binding_version": binding.version, - "advertised_binding": advertised, + "advertised_binding": crate::limits::sample_json(advertised).0, + "evidence_truncated": crate::limits::sample_json(advertised).1, "binding_version_matches": advertised_version == Some(binding.version.as_str()), "required": required, "identity_verified": false, @@ -387,7 +392,11 @@ impl McpDetector { ) .await, ); - return signals; + // Reserve before a completed task can retain its output. + if signals.iter().all(|signal| self.budget.reserve(signal)) { + return signals; + } + return Vec::new(); } Vec::new() } @@ -436,22 +445,18 @@ impl McpDetector { if !is_valid_jsonrpc_response(&body) { continue; } - let items = body + let raw_items = body .get("result") .and_then(|result| result.get(result_key)) - .cloned() - .unwrap_or_else(|| serde_json::json!([])); + .unwrap_or(&Value::Null); + let item_count = raw_items.as_array().map_or(0, Vec::len); + let (items, truncated) = crate::limits::sample_json(raw_items); signals.push(Signal::new( DetectorType::McpDetector, signal_type.to_string(), format!( "MCP {} enumerated on {}:{}{} ({} {})", - label, - ip, - port, - path, - items.as_array().map_or(0, Vec::len), - label + label, ip, port, path, item_count, label ), Confidence::High, serde_json::json!({ @@ -459,6 +464,8 @@ impl McpDetector { "port": port, "path": path, "protocol_version": protocol.version, + "item_count": item_count, + "evidence_truncated": truncated, result_key: items, }), )); @@ -500,7 +507,7 @@ impl Detector for McpDetector { for &ip in targets { for &port in &self.ports { let permit = connect_sem.clone().acquire_owned().await?; - connect_handles.push(tokio::spawn(async move { + connect_handles.push(crate::limits::spawn(async move { let _permit = permit; (ip, port, Self::is_port_open(ip, port).await) })); @@ -517,13 +524,17 @@ impl Detector for McpDetector { return Ok(Vec::new()); } - let rpc_sem = std::sync::Arc::new(Semaphore::new(64)); - let mut rpc_handles = Vec::new(); + let mut rpc_handles = tokio::task::JoinSet::new(); + let mut signals = Vec::new(); for (ip, port) in open_pairs { - let permit = rpc_sem.clone().acquire_owned().await?; + self.budget.check()?; + if rpc_handles.len() >= self.concurrency { + if let Some(Ok(mut found)) = rpc_handles.join_next().await { + signals.append(&mut found); + } + } let detector = self.clone(); - rpc_handles.push(tokio::spawn(async move { - let _permit = permit; + rpc_handles.spawn(async move { for &path in MCP_PATHS { let found = detector.probe_one(ip, port, path).await; if !found.is_empty() { @@ -531,14 +542,14 @@ impl Detector for McpDetector { } } Vec::new() - })); + }); } - let mut signals = Vec::new(); - for handle in rpc_handles { - if let Ok(mut found) = handle.await { + while let Some(handle) = rpc_handles.join_next().await { + if let Ok(mut found) = handle { signals.append(&mut found); } } + self.budget.check()?; Ok(signals) } diff --git a/crates/agentsniff/src/detectors/port_scanner.rs b/crates/agentsniff/src/detectors/port_scanner.rs index adf0923..c8a26f3 100644 --- a/crates/agentsniff/src/detectors/port_scanner.rs +++ b/crates/agentsniff/src/detectors/port_scanner.rs @@ -43,6 +43,7 @@ pub fn classify_banner(banner: &str) -> Option<&'static str> { } pub struct PortScannerDetector { + budget: Arc, ports: Vec, services: Arc>>, custom_ports: Arc>, @@ -72,6 +73,7 @@ impl PortScannerDetector { .collect(); Self { + budget: config.evidence_budget.clone(), ports, services: Arc::new(services), custom_ports: Arc::new(custom_ports), @@ -104,12 +106,13 @@ impl Detector for PortScannerDetector { let mut handles = Vec::new(); for &ip in targets { + self.budget.check()?; for &port in &self.ports { let permit = semaphore.clone().acquire_owned().await?; let timeout = self.timeout; let services = Arc::clone(&self.services); let custom_ports = Arc::clone(&self.custom_ports); - handles.push(tokio::spawn(async move { + handles.push(crate::limits::spawn_signal(self.budget.clone(), async move { let _permit = permit; let addr = std::net::SocketAddr::new(ip, port); crate::ratelimit::acquire().await; @@ -181,6 +184,7 @@ impl Detector for PortScannerDetector { signals.push(signal); } } + self.budget.check()?; Ok(signals) } diff --git a/crates/agentsniff/src/detectors/sse_detector.rs b/crates/agentsniff/src/detectors/sse_detector.rs index 839d24e..74f43ab 100644 --- a/crates/agentsniff/src/detectors/sse_detector.rs +++ b/crates/agentsniff/src/detectors/sse_detector.rs @@ -80,10 +80,68 @@ struct FlowData { timestamps_ns: Vec, } +#[derive(Default)] +struct FlowSamples { + flows: HashMap, + count: usize, +} + +impl FlowSamples { + fn record(&mut self, key: FlowKey, size: f64, timestamp: u64) -> anyhow::Result<()> { + anyhow::ensure!( + self.count < 32_768, + "SSE total sample budget exceeded; capture incomplete" + ); + anyhow::ensure!( + self.flows.contains_key(&key) || self.flows.len() < 1024, + "SSE flow budget exceeded; capture incomplete" + ); + let flow = self.flows.entry(key).or_default(); + anyhow::ensure!( + flow.sizes.len() < 2048, + "SSE per-flow sample budget exceeded; capture incomplete" + ); + flow.sizes.push(size); + flow.timestamps_ns.push(timestamp); + self.count += 1; + Ok(()) + } +} + // --------------------------------------------------------------------------- // SSE Detector // --------------------------------------------------------------------------- +#[cfg(test)] +mod budget_tests { + use super::*; + fn key(n: u32) -> FlowKey { + FlowKey { + src: std::net::Ipv4Addr::from(n).into(), + dst: "127.0.0.1".parse().unwrap(), + } + } + #[test] + fn bounds_individual_flows_and_aggregate_samples() { + let mut samples = FlowSamples::default(); + for i in 0..2048 { + samples.record(key(1), 100.0, i).unwrap(); + } + assert!(samples.record(key(1), 100.0, 2048).is_err()); + let mut samples = FlowSamples::default(); + for i in 0..1024 { + samples.record(key(i), 100.0, 1).unwrap(); + } + assert!(samples.record(key(1024), 100.0, 1).is_err()); + let mut samples = FlowSamples::default(); + for i in 0..32768 { + samples.record(key(i % 32), 100.0, i as u64).unwrap(); + } + assert!(samples.record(key(0), 100.0, 1).is_err()); + assert_eq!(samples.count, 32768); + } +} + /// SSE/LLM streaming detector. /// /// Detects SSE streaming patterns by analyzing packet timing. Uses eBPF @@ -148,7 +206,7 @@ impl SseDetector { let target_set: HashSet = targets.iter().copied().collect(); let llm_ips = self.resolve_llm_ips().await; - let mut flows: HashMap = HashMap::new(); + let mut samples = FlowSamples::default(); loop { match tokio::time::timeout_at(deadline, rx.recv()).await { @@ -168,16 +226,17 @@ impl SseDetector { src: src_ip, dst: dst_ip, }; - let flow = flows.entry(key).or_default(); - flow.sizes.push(event.pkt_len as f64); - flow.timestamps_ns.push(event.timestamp_ns); + samples.record(key, event.pkt_len as f64, event.timestamp_ns)?; + } + Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => { + anyhow::bail!("SSE events dropped; capture incomplete") } - Ok(Err(_)) => break, + Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break, Err(_) => break, // timeout } } - self.analyze_flows(&flows) + self.analyze_flows(&samples.flows) } /// Raw socket fallback: capture packets for 30 seconds. @@ -193,10 +252,12 @@ impl SseDetector { let duration = std::time::Duration::from_secs(30); let start = std::time::Instant::now(); - let mut flows: HashMap = HashMap::new(); + let mut samples = FlowSamples::default(); let mut buf = [0u8; 65535]; while start.elapsed() < duration { + // A busy raw socket must still yield for cancellation. + tokio::task::yield_now().await; // SAFETY: `buf` is a fully-initialized [u8; 65535] on the stack. // Casting to [MaybeUninit] is sound because MaybeUninit has // the same size and alignment as u8, and initialized memory is valid @@ -253,17 +314,15 @@ impl SseDetector { src: src_ip, dst: dst_ip, }; - let flow = flows.entry(key).or_default(); - flow.sizes.push(payload_len as f64); // Use monotonic clock since raw sockets don't give us kernel timestamps let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() as u64; - flow.timestamps_ns.push(ts); + samples.record(key, payload_len as f64, ts)?; } - self.analyze_flows(&flows) + self.analyze_flows(&samples.flows) } /// Analyze collected flows and emit signals. diff --git a/crates/agentsniff/src/detectors/tls_fingerprint.rs b/crates/agentsniff/src/detectors/tls_fingerprint.rs index 0e2ad77..e497813 100644 --- a/crates/agentsniff/src/detectors/tls_fingerprint.rs +++ b/crates/agentsniff/src/detectors/tls_fingerprint.rs @@ -156,6 +156,7 @@ impl Detector for TlsFingerprintDetector { let duration = Duration::from_secs(5); let deadline = tokio::time::Instant::now() + duration; let mut signals = Vec::new(); + let mut per_host = std::collections::HashMap::::new(); let target_set: std::collections::HashSet = targets.iter().copied().collect(); loop { @@ -167,11 +168,19 @@ impl Detector for TlsFingerprintDetector { } let ja3 = compute_ja3_string( event.tls_version, - &event.cipher_suites[..event.cipher_count as usize], - &event.extensions[..event.extension_count as usize], + &event.cipher_suites + [..(event.cipher_count as usize).min(event.cipher_suites.len())], + &event.extensions + [..(event.extension_count as usize).min(event.extensions.len())], ); let dst_ip = std::net::Ipv4Addr::from(event.dst_addr); - signals.push(Signal::new( + let count = per_host.entry(IpAddr::V4(src_ip)).or_default(); + anyhow::ensure!( + *count < crate::limits::MAX_AGENT_SIGNALS, + "TLS event budget exceeded; capture incomplete" + ); + *count += 1; + let signal = Signal::new( DetectorType::TlsFingerprint, "tls_fingerprint_captured".to_string(), format!( @@ -187,9 +196,17 @@ impl Detector for TlsFingerprintDetector { "tls_version": event.tls_version, "source": "ebpf", }), - )); + ); + anyhow::ensure!( + self.config.evidence_budget.reserve(&signal), + "TLS evidence budget exceeded; capture incomplete" + ); + signals.push(signal); + } + Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => { + anyhow::bail!("TLS events dropped; capture incomplete") } - Ok(Err(_)) => break, + Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break, Err(_) => break, } } @@ -205,36 +222,40 @@ impl Detector for TlsFingerprintDetector { let timeout = self.timeout; let client = self.client.clone(); - let mut handles: Vec>> = Vec::new(); + let mut handles: Vec>> = Vec::new(); for &ip in targets { + self.config.evidence_budget.check()?; for port in self.config.ports_or(TLS_PROBE_PORTS) { let permit = sem.clone().acquire_owned().await?; let client = client.clone(); - handles.push(tokio::spawn(async move { - let _permit = permit; - if !Self::is_port_open(ip, port, timeout).await { - return None; - } - match probe_tls(&client, ip, port).await { - Ok(info) => info.tls_version.map(|v| { - Signal::new( - DetectorType::TlsFingerprint, - "tls_endpoint_detected".to_string(), - format!("TLS endpoint at {}:{} ({})", ip, port, v), - Confidence::Low, - serde_json::json!({ - "ip": ip.to_string(), - "port": port, - "tls_version": v, - }), - ) - }), - Err(e) => { - tracing::trace!("TLS probe failed for {}:{}: {}", ip, port, e); - None + handles.push(crate::limits::spawn_signal( + self.config.evidence_budget.clone(), + async move { + let _permit = permit; + if !Self::is_port_open(ip, port, timeout).await { + return None; } - } - })); + match probe_tls(&client, ip, port).await { + Ok(info) => info.tls_version.map(|v| { + Signal::new( + DetectorType::TlsFingerprint, + "tls_endpoint_detected".to_string(), + format!("TLS endpoint at {}:{} ({})", ip, port, v), + Confidence::Low, + serde_json::json!({ + "ip": ip.to_string(), + "port": port, + "tls_version": v, + }), + ) + }), + Err(e) => { + tracing::trace!("TLS probe failed for {}:{}: {}", ip, port, e); + None + } + } + }, + )); } } @@ -245,6 +266,7 @@ impl Detector for TlsFingerprintDetector { } } + self.config.evidence_budget.check()?; Ok(signals) } diff --git a/crates/agentsniff/src/lib.rs b/crates/agentsniff/src/lib.rs index 29c235c..bb32823 100644 --- a/crates/agentsniff/src/lib.rs +++ b/crates/agentsniff/src/lib.rs @@ -11,6 +11,7 @@ pub mod ebpf; pub mod fusion; pub(crate) mod http; pub mod integrations; +pub(crate) mod limits; pub mod models; pub mod notifier; pub mod ratelimit; diff --git a/crates/agentsniff/src/limits.rs b/crates/agentsniff/src/limits.rs new file mode 100644 index 0000000..610c551 --- /dev/null +++ b/crates/agentsniff/src/limits.rs @@ -0,0 +1,196 @@ +//! Retained evidence limits are independent of HTTP body and concurrency limits. +use std::future::Future; +use std::io::{self, Write}; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::task::{Context, Poll}; + +use serde::Serialize; +use serde_json::Value; + +pub const MAX_SCAN_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_SIGNALS: usize = 16_384; +pub const MAX_AGENT_SIGNALS: usize = 256; +pub const MAX_HISTORY_PAGE: u32 = 100; + +/// Count serialized bytes without allocating a second copy of the value. +pub fn serialized_size(value: &impl Serialize, limit: usize) -> anyhow::Result { + struct Counter { + bytes: usize, + limit: usize, + } + impl Write for Counter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if buf.len() > self.limit.saturating_sub(self.bytes) { + return Err(io::Error::other("serialized evidence budget exceeded")); + } + self.bytes += buf.len(); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut writer = Counter { bytes: 0, limit }; + serde_json::to_writer(&mut writer, value)?; + Ok(writer.bytes) +} + +#[derive(Debug, Default)] +pub struct EvidenceBudget { + bytes: AtomicUsize, + signals: AtomicUsize, + exceeded: AtomicBool, +} + +impl EvidenceBudget { + pub fn reserve(&self, value: &impl Serialize) -> bool { + if self.exceeded.load(Ordering::Relaxed) { + return false; + } + let size = serialized_size(value, MAX_SCAN_BYTES / 2); + let accepted = size.is_ok_and(|size| { + self.bytes + .try_update(Ordering::Relaxed, Ordering::Relaxed, |used| { + used.checked_add(size) + .filter(|next| *next <= MAX_SCAN_BYTES / 2) + }) + .is_ok() + }) && self.signals.fetch_add(1, Ordering::Relaxed) < MAX_SIGNALS; + if !accepted { + self.exceeded.store(true, Ordering::Relaxed); + } + accepted + } + pub fn check(&self) -> anyhow::Result<()> { + anyhow::ensure!( + !self.exceeded.load(Ordering::Relaxed), + "scan evidence budget exceeded; results are incomplete" + ); + Ok(()) + } +} + +/// Preserve small catalogs exactly. Large values become a bounded structural +/// sample; callers attach explicit truncation/count metadata. +pub fn sample_json(value: &Value) -> (Value, bool) { + if serialized_size(value, 4096).is_ok() { + return (value.clone(), false); + } + fn visit(value: &Value, nodes: &mut usize, depth: usize) -> Value { + if *nodes == 0 || depth == 0 { + return Value::Null; + } + *nodes -= 1; + match value { + Value::String(s) => Value::String(s.chars().take(128).collect()), + Value::Array(items) => { + let mut out = Vec::new(); + for item in items.iter().take(16) { + if *nodes == 0 { + break; + } + out.push(visit(item, nodes, depth - 1)); + } + Value::Array(out) + } + Value::Object(items) => { + let mut out = serde_json::Map::new(); + for (key, item) in items.iter().take(16) { + if *nodes == 0 { + break; + } + if key.len() <= 128 { + out.insert(key.clone(), visit(item, nodes, depth - 1)); + } + } + Value::Object(out) + } + other => other.clone(), + } + } + (visit(value, &mut 32, 4), true) +} + +/// Child network tasks are aborted when their owning detector is cancelled. +pub struct ScopedTask(tokio::task::JoinHandle); +impl Drop for ScopedTask { + fn drop(&mut self) { + self.0.abort(); + } +} +impl Future for ScopedTask { + type Output = Result; + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + Pin::new(&mut self.0).poll(cx) + } +} +pub fn spawn(future: F) -> ScopedTask +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + ScopedTask(tokio::spawn(future)) +} + +/// Charge evidence before a completed task can retain it in a join handle. +pub fn spawn_signal( + budget: std::sync::Arc, + future: F, +) -> ScopedTask> +where + F: Future> + Send + 'static, +{ + spawn(async move { future.await.filter(|signal| budget.reserve(signal)) }) +} + +pub fn spawn_signals( + budget: std::sync::Arc, + future: F, +) -> ScopedTask> +where + F: Future> + Send + 'static, +{ + spawn(async move { + let signals = future.await; + if signals.iter().all(|signal| budget.reserve(signal)) { + signals + } else { + Vec::new() + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_aggregate_evidence_and_samples_large_json() { + let budget = EvidenceBudget::default(); + let value = "x".repeat(1024 * 1024); + for _ in 0..7 { + assert!(budget.reserve(&value)); + } + assert!(!budget.reserve(&value)); + assert!(budget.check().is_err()); + let (sample, truncated) = sample_json(&serde_json::json!({"items": vec![value; 10]})); + assert!(truncated); + assert!(serialized_size(&sample, 16 * 1024).is_ok()); + assert_eq!(sample_json(&serde_json::json!({"name":"ok"})).1, false); + } + #[tokio::test] + async fn dropping_detector_aborts_child_tasks() { + let (send, receive) = tokio::sync::oneshot::channel::<()>(); + let child = spawn(async move { + let _send = send; + std::future::pending::<()>().await + }); + drop(child); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(1), receive) + .await + .unwrap() + .is_err() + ); + } +} diff --git a/crates/agentsniff/src/main.rs b/crates/agentsniff/src/main.rs index 1f07a34..a3a53be 100644 --- a/crates/agentsniff/src/main.rs +++ b/crates/agentsniff/src/main.rs @@ -56,8 +56,7 @@ enum Cli { /// Scan a network for AI agents Scan { /// Target network in CIDR notation (e.g., 192.168.1.0/24) - #[arg(default_value = "192.168.1.0/24")] - network: String, + network: Option, /// Comma-separated list of specific hosts to scan #[arg(long)] @@ -68,8 +67,8 @@ enum Cli { exclude: Option, /// Output format - #[arg(long, short = 'f', value_enum, default_value = "table")] - format: OutputFormat, + #[arg(long, short = 'f', value_enum)] + format: Option, /// Optional path to a YAML config file #[arg(long)] @@ -84,16 +83,16 @@ enum Cli { detectors: Option, /// HTTP request timeout in seconds - #[arg(long, default_value_t = 5.0)] - timeout: f64, + #[arg(long)] + timeout: Option, /// Maximum concurrent connections - #[arg(long, default_value_t = 100)] - concurrency: usize, + #[arg(long)] + concurrency: Option, /// Continuous scanning interval in seconds (0 = one-shot) - #[arg(long, default_value_t = 0)] - continuous: u64, + #[arg(long)] + continuous: Option, /// Webhook URL for alert notifications (auto-enables alerting) #[arg(long = "webhook-url")] @@ -142,12 +141,12 @@ enum Cli { /// Start the web dashboard server Serve { /// Port to listen on - #[arg(long, default_value_t = 9090)] - port: u16, + #[arg(long)] + port: Option, /// Host address to bind to - #[arg(long, default_value = "127.0.0.1")] - host: String, + #[arg(long)] + host: Option, /// Default scan target network #[arg(long)] @@ -294,16 +293,34 @@ async fn main() -> anyhow::Result<()> { ScanConfig::default() }; - scan_config.target_network = network; - scan_config.output_format = format_to_string(format); + scan_config = ScanConfig::from_env_with_defaults(scan_config)?; + if let Some(network) = network { + scan_config.target_network = network; + } + if let Some(format) = format { + scan_config.output_format = format_to_string(format); + } if let Some(o) = output { scan_config.output_file = o; } - scan_config.verbose = verbose; - scan_config.quiet = quiet; - scan_config.http_timeout = timeout; - scan_config.port_scan_concurrency = concurrency; - scan_config.scan_interval = continuous; + if verbose { + scan_config.verbose = true; + scan_config.quiet = false; + } + if quiet { + scan_config.quiet = true; + scan_config.verbose = false; + } + if let Some(timeout) = timeout { + scan_config.http_timeout = timeout; + } + if let Some(concurrency) = concurrency { + scan_config.port_scan_concurrency = concurrency; + scan_config.http_concurrency = concurrency; + } + if let Some(continuous) = continuous { + scan_config.scan_interval = continuous; + } if let Some(lf) = log_file { scan_config.log_file = lf; @@ -338,7 +355,9 @@ async fn main() -> anyhow::Result<()> { if let Some(d) = detectors { apply_detector_filter(&mut scan_config, &d); } - let scan_config = ScanConfig::from_env_with_defaults(scan_config); + scan_config.validate()?; + let format = OutputFormat::from_str(&scan_config.output_format, true) + .map_err(anyhow::Error::msg)?; // 2. Initialize logging now (after CLI parse + env-var overrides). init_logging( @@ -348,7 +367,7 @@ async fn main() -> anyhow::Result<()> { )?; // 3. Banner + signature status (unless quiet). - if !quiet { + if !scan_config.quiet { print_banner(); print_signature_status(); } @@ -379,8 +398,13 @@ async fn main() -> anyhow::Result<()> { ScanConfig::default() }; - scan_config.api_port = port; - scan_config.api_host = host; + scan_config = ScanConfig::from_env_with_defaults(scan_config)?; + if let Some(port) = port { + scan_config.api_port = port; + } + if let Some(host) = host { + scan_config.api_host = host; + } if let Some(n) = network { scan_config.target_network = n; } @@ -390,10 +414,10 @@ async fn main() -> anyhow::Result<()> { if let Some(lf) = log_file { scan_config.log_file = lf; } - scan_config.verbose = verbose; - let scan_config = ScanConfig::from_env_with_defaults(scan_config); + scan_config.verbose |= verbose; + scan_config.validate()?; - init_logging(verbose, false, &scan_config.log_file)?; + init_logging(scan_config.verbose, false, &scan_config.log_file)?; tracing::info!( "Starting server on {}:{}", scan_config.api_host, diff --git a/crates/agentsniff/src/models.rs b/crates/agentsniff/src/models.rs index 7fb733f..a691939 100644 --- a/crates/agentsniff/src/models.rs +++ b/crates/agentsniff/src/models.rs @@ -216,6 +216,34 @@ pub struct DetectedAgent { } impl DetectedAgent { + /// Bounded live progress record; full evidence is fetched through result pages. + pub fn progress_summary(&self) -> Self { + Self { + id: self.id.clone(), + host: self.host.clone(), + ip_address: self.ip_address, + port: self.port, + transport: self.transport.clone(), + agent_type: self.agent_type.clone(), + framework: self.framework.clone(), + status: self.status, + presence_status: self.presence_status, + identity_assurance: self.identity_assurance, + risk_level: self.risk_level, + change_status: self.change_status, + confidence_score: self.confidence_score, + confidence_level: self.confidence_level.clone(), + signal_count: self.signal_count, + signals: Vec::new(), + agentpin_identity: None, + mcp_capabilities: None, + tls_fingerprint: None, + metadata: serde_json::json!({"evidence_in_result_pages":true}), + fingerprint: self.fingerprint.clone(), + first_seen: self.first_seen, + last_seen: self.last_seen, + } + } pub fn new(host: String, ip_address: IpAddr) -> Self { Self::for_endpoint(host, ip_address, None, "network") } diff --git a/crates/agentsniff/src/notifier.rs b/crates/agentsniff/src/notifier.rs index 0c0ef03..3197745 100644 --- a/crates/agentsniff/src/notifier.rs +++ b/crates/agentsniff/src/notifier.rs @@ -1,4 +1,4 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use lettre::message::{header::ContentType, Mailbox, MultiPart, SinglePart}; use lettre::transport::smtp::authentication::Credentials; @@ -36,23 +36,28 @@ impl Notifier { /// Check if alert conditions are met and send notifications. pub async fn check_and_notify(&mut self, result: &ScanResult) -> anyhow::Result<()> { + if let Some(payload) = self.prepare(result) { + send_payload(&payload, &self.config).await; + } + Ok(()) + } + + /// Reserve the cooldown before dispatch, so slow or failed destinations + /// cannot create an unbounded queue of retries. + pub(crate) fn prepare(&mut self, result: &ScanResult) -> Option { if !self.should_notify(result) { - return Ok(()); + return None; } // Check cooldown if let Some(last) = self.last_alert { if last.elapsed().as_secs() < self.config.alert_cooldown { - return Ok(()); + return None; } } - let outcomes = send_alerts(result, &self.config).await; - if outcomes.iter().any(|o| o.contains("ok")) { - self.last_alert = Some(Instant::now()); - } - - Ok(()) + self.last_alert = Some(Instant::now()); + Some(build_payload(result, &self.config)) } } @@ -131,22 +136,38 @@ fn build_payload(result: &ScanResult, config: &ScanConfig) -> serde_json::Value /// Dispatch alerts to all configured channels. Returns outcome strings. pub async fn send_alerts(result: &ScanResult, config: &ScanConfig) -> Vec { - let mut outcomes = Vec::new(); let payload = build_payload(result, config); + send_payload(&payload, config).await +} + +pub(crate) async fn send_payload(payload: &serde_json::Value, config: &ScanConfig) -> Vec { + let mut outcomes = Vec::new(); if !config.webhook_url.is_empty() { - outcomes.push(send_webhook(&payload, config).await); + outcomes.push(send_webhook(payload, config).await); } if !config.smtp_to.is_empty() { - outcomes.push(send_email(&payload, config).await); + outcomes.push( + tokio::time::timeout(Duration::from_secs(20), send_email(payload, config)) + .await + .unwrap_or_else(|_| "email:failed:timeout".into()), + ); } outcomes } async fn send_webhook(payload: &serde_json::Value, config: &ScanConfig) -> String { - let client = reqwest::Client::new(); + let client = match reqwest::Client::builder() + .connect_timeout(Duration::from_secs(3)) + .timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + { + Ok(client) => client, + Err(error) => return format!("webhook:failed:{error}"), + }; let mut req = client .post(&config.webhook_url) .header("Content-Type", "application/json") @@ -159,7 +180,7 @@ async fn send_webhook(payload: &serde_json::Value, config: &ScanConfig) -> Strin match req.send().await { Ok(resp) => { let status = resp.status().as_u16(); - if status < 400 { + if resp.status().is_success() { tracing::info!("Webhook alert sent ({})", status); format!("webhook:ok:{}", status) } else { diff --git a/crates/agentsniff/src/scanner.rs b/crates/agentsniff/src/scanner.rs index 51e41e0..3647a15 100644 --- a/crates/agentsniff/src/scanner.rs +++ b/crates/agentsniff/src/scanner.rs @@ -261,10 +261,22 @@ fn correlate_signals( signals: Vec, agents_by_asset: &mut HashMap, on_agent: &Option, -) { +) -> anyhow::Result<()> { let mut updated_assets = Vec::new(); + let mut retained_bytes = + crate::limits::serialized_size(agents_by_asset, crate::limits::MAX_SCAN_BYTES)?; + let mut signal_count: usize = agents_by_asset.values().map(|a| a.signals.len()).sum(); for signal in signals { + // Allow for promoted metadata/fingerprint copies before adding a signal. + let size = crate::limits::serialized_size(&signal, 256 * 1024)?; + retained_bytes = retained_bytes.saturating_add(size * 3 + 4096); + signal_count += 1; + anyhow::ensure!( + retained_bytes <= crate::limits::MAX_SCAN_BYTES + && signal_count <= crate::limits::MAX_SIGNALS, + "scan evidence budget exceeded; results are incomplete" + ); let ip_str = signal .evidence .get("ip") @@ -294,6 +306,10 @@ fn correlate_signals( let agent = agents_by_asset .entry(asset_id.clone()) .or_insert_with(|| DetectedAgent::for_endpoint(ip_str.clone(), ip, port, transport)); + anyhow::ensure!( + agent.signals.len() < crate::limits::MAX_AGENT_SIGNALS, + "per-asset signal budget exceeded; results are incomplete" + ); agent.add_signal(signal); if agent.signal_count > prev_count && !updated_assets.contains(&asset_id) { @@ -308,6 +324,7 @@ fn correlate_signals( } } } + Ok(()) } /// Detectors `agentsniff probe` accepts: the active ones that send traffic to @@ -334,6 +351,7 @@ pub async fn run_probe( port: u16, detector_names: &[String], ) -> anyhow::Result { + config.validate()?; for name in detector_names { if !PROBE_DETECTORS.contains(&name.as_str()) { anyhow::bail!( @@ -343,6 +361,7 @@ pub async fn run_probe( } } let mut config = config.clone(); + config.evidence_budget = Arc::default(); config.probe_port = Some(port); for (name, enabled) in [ ("port_scanner", &mut config.enable_port_scanner), @@ -368,7 +387,7 @@ pub async fn run_probe( } let mut agents_by_asset = HashMap::new(); - correlate_signals(signals, &mut agents_by_asset, &None); + correlate_signals(signals, &mut agents_by_asset, &None)?; let asset_id = crate::models::stable_asset_id(ip, Some(port), "tcp"); let mut agent = apply_fusion_rules(agents_by_asset.into_values().collect()) .into_iter() @@ -389,6 +408,11 @@ pub async fn run_scan( cancel: Option, on_agent: Option, ) -> anyhow::Result { + config.validate()?; + let mut config = config.clone(); + config.evidence_budget = Arc::default(); + let config = &config; + let cancel = cancel.unwrap_or_default(); let mut result = ScanResult::new(); result.target_network = config.target_network.clone(); @@ -397,8 +421,8 @@ pub async fn run_scan( tracing::info!("Resolved {} target(s)", targets.len()); // Check cancellation - if let Some(ref token) = cancel { - if token.is_cancelled() { + { + if cancel.is_cancelled() { result.completed_at = Some(Utc::now()); result.cancelled = true; return Ok(result); @@ -425,8 +449,8 @@ pub async fn run_scan( } // Check cancellation - if let Some(ref token) = cancel { - if token.is_cancelled() { + { + if cancel.is_cancelled() { result.completed_at = Some(Utc::now()); result.cancelled = true; return Ok(result); @@ -448,17 +472,19 @@ pub async fn run_scan( // 5. Process results incrementally as each detector completes let mut agents_by_asset: HashMap = HashMap::new(); - while let Some(task_result) = join_set.join_next().await { - // Check cancellation between detectors - if let Some(ref token) = cancel { - if token.is_cancelled() { + loop { + let task_result = tokio::select! { + biased; + _ = cancel.cancelled() => { + join_set.shutdown().await; result.completed_at = Some(Utc::now()); result.cancelled = true; // Return whatever we have so far result.agents = agents_by_asset.into_values().collect(); return Ok(result); - } - } + }, + item = join_set.join_next() => match item { Some(item) => item, None => break }, + }; match task_result { Ok((detector_type, Ok(signals))) => { @@ -469,7 +495,9 @@ pub async fn run_scan( signals.len() ); // Incrementally correlate and stream to dashboard - correlate_signals(signals, &mut agents_by_asset, &on_agent); + if let Err(error) = correlate_signals(signals, &mut agents_by_asset, &on_agent) { + result.errors.push(error.to_string()); + } } Ok((detector_type, Err(error))) => { result.detectors_run.push(detector_type); @@ -493,7 +521,17 @@ pub async fn run_scan( let sigs = crate::signatures::SignatureData::load_embedded(); // Convert Zeek DNS records to signals - match zeek.load_dns(&targets, time_window).await { + let dns_records = tokio::select! { + biased; + _ = cancel.cancelled() => { + result.agents = agents_by_asset.into_values().collect(); + result.completed_at = Some(Utc::now()); + result.cancelled = true; + return Ok(result); + }, + records = zeek.load_dns(&targets, time_window) => records, + }; + match dns_records { Ok(dns_records) => { let mut infra_domains = sigs.agent_infra_domains.clone(); infra_domains.extend(sigs.verified_mcp_remote_domains()); @@ -540,7 +578,11 @@ pub async fn run_scan( zeek_signals.len() ); result.detectors_run.push(DetectorType::Zeek); - correlate_signals(zeek_signals, &mut agents_by_asset, &on_agent); + if let Err(error) = + correlate_signals(zeek_signals, &mut agents_by_asset, &on_agent) + { + result.errors.push(error.to_string()); + } } } Err(error) => { @@ -551,7 +593,17 @@ pub async fn run_scan( } // Convert Zeek TLS records to signals (only matching fingerprints or LLM domains) - match zeek.load_tls(&targets, time_window).await { + let tls_records = tokio::select! { + biased; + _ = cancel.cancelled() => { + result.agents = agents_by_asset.into_values().collect(); + result.completed_at = Some(Utc::now()); + result.cancelled = true; + return Ok(result); + }, + records = zeek.load_tls(&targets, time_window) => records, + }; + match tls_records { Ok(tls_records) => { let mut zeek_tls_signals = Vec::new(); for rec in &tls_records { @@ -600,7 +652,11 @@ pub async fn run_scan( } } if !zeek_tls_signals.is_empty() { - correlate_signals(zeek_tls_signals, &mut agents_by_asset, &on_agent); + if let Err(error) = + correlate_signals(zeek_tls_signals, &mut agents_by_asset, &on_agent) + { + result.errors.push(error.to_string()); + } } } Err(error) => { @@ -619,7 +675,17 @@ pub async fn run_scan( if config.enrich_enabled { tracing::info!("Running service enrichment"); let enricher = ServiceEnricher::new(config.enrich_timeout); - match enricher.enrich(agents.clone()).await { + let enriched = tokio::select! { + biased; + _ = cancel.cancelled() => { + result.agents = agents; + result.completed_at = Some(Utc::now()); + result.cancelled = true; + return Ok(result); + }, + enriched = enricher.enrich(agents.clone()) => enriched, + }; + match enriched { Ok(enriched) => { result.detectors_run.push(DetectorType::ServiceEnricher); agents = enriched; @@ -659,6 +725,7 @@ pub async fn run_scan( result.agents = final_agents; result.completed_at = Some(Utc::now()); + crate::limits::serialized_size(&result, crate::limits::MAX_SCAN_BYTES)?; Ok(result) } @@ -687,7 +754,8 @@ mod correlation_tests { ], &mut assets, &None, - ); + ) + .unwrap(); assert_eq!(assets.len(), 2); assert!(assets.values().any(|agent| agent.port == Some(3000))); @@ -704,7 +772,8 @@ mod correlation_tests { ], &mut assets, &None, - ); + ) + .unwrap(); assert_eq!(assets.len(), 1); let asset = assets.values().next().unwrap(); diff --git a/crates/agentsniff/src/server.rs b/crates/agentsniff/src/server.rs index c93da4e..c2dadd1 100644 --- a/crates/agentsniff/src/server.rs +++ b/crates/agentsniff/src/server.rs @@ -5,7 +5,7 @@ use std::time::Duration; use axum::body::Bytes; use axum::extract::{DefaultBodyLimit, Path, Query, Request, State}; -use axum::http::header::{AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE}; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE, HOST, ORIGIN, WWW_AUTHENTICATE}; use axum::http::{HeaderMap, HeaderValue, Method, StatusCode}; use axum::middleware::{self, Next}; use axum::response::sse::{Event, KeepAlive, Sse}; @@ -15,7 +15,7 @@ use axum::Router; use base64::Engine; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use tokio::sync::{broadcast, Mutex}; +use tokio::sync::{broadcast, Mutex, Semaphore}; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::StreamExt; use tokio_util::sync::CancellationToken; @@ -29,7 +29,7 @@ use crate::baseline::apply_scan_diff; use crate::config::ScanConfig; use crate::ebpf::EbpfChannels; use crate::models::{DetectedAgent, IdentityAssurance, ScanResult}; -use crate::notifier::{send_alerts, Notifier}; +use crate::notifier::{send_alerts, send_payload, Notifier}; use crate::scanner::{resolve_targets, run_scan}; use crate::storage::{create_storage, StorageBackend}; use crate::storage_redis::RedisEventBus; @@ -168,6 +168,7 @@ pub struct AppState { pub scan_status: Mutex, pub latest_result: Mutex>, pub notifier: Mutex, + notification_slot: Arc, pub cancel_token: Mutex>, pub sse_tx: broadcast::Sender, pub event_bus: Option>, @@ -774,7 +775,13 @@ async fn require_api_auth( request: Request, next: Next, ) -> Response { - let expected = state.config.lock().await.api_token.clone(); + let config = state.config.lock().await; + if validate_api_security(&config).is_err() || !config.api_enabled { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + let expected = config.api_token.clone(); + let port = config.api_port; + drop(config); if !expected.is_empty() && !matches!( supplied_api_token(request.headers()), @@ -784,6 +791,21 @@ async fn require_api_auth( return unauthorized_response(); } + // Check the HTTP authority, not its resolved IP. DNS rebinding keeps the + // browser origin unchanged while sending the connection to loopback. + let authority = request_authority(&request); + if expected.is_empty() + && !authority.as_ref().is_some_and(|authority| { + let host = authority + .host() + .trim_start_matches('[') + .trim_end_matches(']'); + is_loopback_host(host) && authority.port_u16().unwrap_or(80) == port + }) + { + return StatusCode::FORBIDDEN.into_response(); + } + // Loopback may run without a token, and browsers may cache Basic auth. // Requiring a non-simple content type on every mutation prevents ordinary // cross-origin forms from reaching handlers; hostile script requests are @@ -808,9 +830,79 @@ async fn require_api_auth( .into_response(); } + // Browser requests (including cached Basic credentials) must come from + // the same authority. Non-browser clients need not supply an Origin. + if request.headers().contains_key(ORIGIN) { + let valid_origin = request.headers().get_all(ORIGIN).iter().count() == 1 + && request + .headers() + .get(ORIGIN) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|origin| { + matches!(origin.scheme_str(), Some("http" | "https")) + && origin.path() == "/" + && origin.query().is_none() + && origin + .authority() + .zip(authority.as_ref()) + .is_some_and(|(a, b)| { + !a.as_str().contains('@') + && a.as_str().eq_ignore_ascii_case(b.as_str()) + }) + }); + if !valid_origin { + return StatusCode::FORBIDDEN.into_response(); + } + } + next.run(request).await } +fn request_authority(request: &Request) -> Option { + let hosts = request.headers().get_all(HOST); + if hosts.iter().count() > 1 { + return None; + } + let header = hosts.iter().next().map(|host| { + host.to_str() + .ok()? + .parse::() + .ok() + }); + let header = match header { + Some(Some(host)) => Some(host), + Some(None) => return None, + None => None, + }; + let uri = request.uri().authority(); + if let (Some(header), Some(uri)) = (&header, uri) { + if !header.as_str().eq_ignore_ascii_case(uri.as_str()) { + return None; + } + } + let authority = header.or_else(|| uri.cloned())?; + if authority.as_str().contains('@') { + return None; + } + Some(authority) +} + +async fn security_headers(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert( + "content-security-policy", + HeaderValue::from_static("frame-ancestors 'none'"), + ); + headers.insert("x-frame-options", HeaderValue::from_static("DENY")); + headers.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + response +} + /// Apply detector toggle overrides from a comma-separated list of detector names. fn apply_detector_filter(config: &mut ScanConfig, detectors_csv: &str) { let enabled: Vec<&str> = detectors_csv.split(',').map(|s| s.trim()).collect(); @@ -913,7 +1005,7 @@ fn spawn_scan( &on_agent_tx, on_agent_bus.as_ref(), SseEvent::AgentDetected { - agent: Box::new(agent.clone()), + agent: Box::new(agent.progress_summary()), }, ); }); @@ -942,27 +1034,39 @@ fn spawn_scan( apply_scan_diff(&mut result, previous.as_ref()); } let agent_count = result.agents.len(); - let summary = ScanSummary::from_result(&result); // Save to storage if let Err(e) = state.storage.save_scan(&result).await { tracing::error!("Failed to save scan result: {}", e); + result + .errors + .push(format!("Failed to save scan result: {e}")); } + let summary = ScanSummary::from_result(&result); - if !was_cancelled { + let notification = if !was_cancelled { + let slot = state.notification_slot.clone().try_acquire_owned().ok(); let alert_config = state.config.lock().await.clone(); let mut notifier = state.notifier.lock().await; notifier.set_config(&alert_config); - if let Err(error) = notifier.check_and_notify(&result).await { - tracing::error!("Failed to dispatch scan alert: {}", error); - } - } + slot.and_then(|slot| { + notifier + .prepare(&result) + .map(|payload| (slot, payload, alert_config)) + }) + } else { + None + }; // Update state { let mut latest = state.latest_result.lock().await; *latest = Some(result); } + { + let mut token = state.cancel_token.lock().await; + *token = None; + } { let mut status = state.scan_status.lock().await; *status = if was_cancelled { @@ -975,10 +1079,6 @@ fn spawn_scan( } }; } - { - let mut token = state.cancel_token.lock().await; - *token = None; - } let event = if was_cancelled { SseEvent::ScanCancelled { @@ -994,19 +1094,25 @@ fn spawn_scan( } }; emit_sse_event(&sse_tx, event_bus.as_ref(), event); + if let Some((slot, payload, config)) = notification { + tokio::spawn(async move { + let _slot = slot; + send_payload(&payload, &config).await; + }); + } } Err(e) => { let msg = format!("Scan failed: {}", e); tracing::error!("{}", msg); - { - let mut status = state.scan_status.lock().await; - *status = ScanStatus::Idle; - } { let mut token = state.cancel_token.lock().await; *token = None; } + { + let mut status = state.scan_status.lock().await; + *status = ScanStatus::Idle; + } emit_sse_event( &sse_tx, @@ -1045,14 +1151,12 @@ async fn start_scan_handler( if matches!(*status, ScanStatus::Running { .. }) { return Err(StatusCode::CONFLICT); } + let mut token = state.cancel_token.lock().await; + *token = Some(cancel_token.clone()); *status = ScanStatus::Running { scan_id: scan_id.clone(), }; } - { - let mut token = state.cancel_token.lock().await; - *token = Some(cancel_token.clone()); - } spawn_scan( Arc::clone(&state), @@ -1074,14 +1178,30 @@ async fn scan_status_handler(State(state): State>) -> Json>, -) -> Result, StatusCode> { + Query(query): Query, +) -> Result, StatusCode> { let latest = state.latest_result.lock().await; match latest.as_ref() { - Some(result) => Ok(Json(result.clone())), - None => Ok(Json(ScanResult::new())), + Some(result) => Ok(Json(result_page(result, &query))), + None => Ok(Json(result_page(&ScanResult::new(), &query))), } } +fn result_page(result: &ScanResult, query: &HistoryQuery) -> Value { + let offset = query.offset.unwrap_or(0) as usize; + let limit = query.limit.unwrap_or(100).clamp(1, 100) as usize; + let agents: Vec<&DetectedAgent> = result.agents.iter().skip(offset).take(limit).collect(); + let next = offset.saturating_add(agents.len()); + json!({ + "scan_id": result.scan_id, "scope_id": result.scope_id, "target_network": result.target_network, + "agents": agents, "detectors_run": result.detectors_run, "errors": result.errors, + "started_at": result.started_at, "completed_at": result.completed_at, "cancelled": result.cancelled, + "diff": result.diff, "summary": ScanSummary::from_result(result), + "pagination": {"total": result.agents.len(), "offset": offset, "limit": limit, + "next_offset": if next < result.agents.len() { Some(next) } else { None }}, + }) +} + async fn scan_sarif_handler( State(state): State>, ) -> Result, StatusCode> { @@ -1129,7 +1249,7 @@ async fn scan_history_handler( State(state): State>, Query(query): Query, ) -> Result { - let limit = query.limit.unwrap_or(10); + let limit = query.limit.unwrap_or(10).clamp(1, 100); let offset = query.offset.unwrap_or(0); match state.storage.list_scans(limit, offset).await { @@ -1144,23 +1264,16 @@ async fn scan_history_handler( async fn get_scan_handler( State(state): State>, Path(scan_id): Path, + Query(query): Query, ) -> Result { - match state.storage.get_scan(&scan_id).await { - Ok(Some(scan)) => { - let summary = ScanSummary::from_result(&scan); - Ok(Json(serde_json::json!({ - "scan_id": scan.scan_id, - "scope_id": scan.scope_id, - "target_network": scan.target_network, - "agents": scan.agents, - "diff": scan.diff, - "started_at": scan.started_at, - "completed_at": scan.completed_at, - "cancelled": scan.cancelled, - "detectors_run": scan.detectors_run, - "summary": summary, - }))) + { + let latest = state.latest_result.lock().await; + if let Some(result) = latest.as_ref().filter(|result| result.scan_id == scan_id) { + return Ok(Json(result_page(result, &query))); } + } + match state.storage.get_scan(&scan_id).await { + Ok(Some(scan)) => Ok(Json(result_page(&scan, &query))), Ok(None) => Err(StatusCode::NOT_FOUND), Err(e) => { tracing::error!("Failed to load scan {}: {}", scan_id, e); @@ -1169,10 +1282,21 @@ async fn get_scan_handler( } } -async fn agents_handler(State(state): State>) -> Json> { +async fn agents_handler( + State(state): State>, + Query(query): Query, +) -> Json> { let latest = state.latest_result.lock().await; match latest.as_ref() { - Some(result) => Json(result.agents.clone()), + Some(result) => Json( + result + .agents + .iter() + .skip(query.offset.unwrap_or(0) as usize) + .take(query.limit.unwrap_or(100).clamp(1, 100) as usize) + .cloned() + .collect(), + ), None => Json(Vec::new()), } } @@ -1388,7 +1512,8 @@ async fn db_backup_handler( format!("attachment; filename=\"{}\"", backup.filename), ), ]; - Ok((headers, backup.data)) + let stream = tokio_util::io::ReaderStream::new(tokio::fs::File::from_std(backup.file)); + Ok((headers, axum::body::Body::from_stream(stream))) } Err(e) => { tracing::error!("Database backup failed: {}", e); @@ -1434,6 +1559,12 @@ pub async fn create_router( config: ScanConfig, ebpf_channels: Arc, ) -> anyhow::Result { + validate_api_security(&config)?; + validate_agentpin_server_config(&config)?; + anyhow::ensure!( + config.api_enabled, + "API server is disabled by configuration" + ); let storage = create_storage(&config.storage).await?; let event_bus = if config.storage.redis_url.trim().is_empty() { None @@ -1481,6 +1612,7 @@ fn create_router_with_components( scan_status: Mutex::new(ScanStatus::Idle), latest_result: Mutex::new(None), notifier: Mutex::new(notifier), + notification_slot: Arc::new(Semaphore::new(1)), cancel_token: Mutex::new(None), sse_tx, event_bus, @@ -1523,6 +1655,7 @@ fn create_router_with_components( Router::new() .route("/api/health", get(health_handler)) .merge(protected_routes) + .layer(middleware::from_fn(security_headers)) .with_state(state) } diff --git a/crates/agentsniff/src/signatures/mod.rs b/crates/agentsniff/src/signatures/mod.rs index a1c238d..7e3381c 100644 --- a/crates/agentsniff/src/signatures/mod.rs +++ b/crates/agentsniff/src/signatures/mod.rs @@ -134,7 +134,7 @@ pub fn verify_signature( let Some(supplied_key) = load_verifying_key_from_pem(pem) else { return VerificationStatus::Invalid; }; - if supplied_key.to_encoded_point(false) != verifying_key.to_encoded_point(false) { + if supplied_key.to_sec1_point(false) != verifying_key.to_sec1_point(false) { return VerificationStatus::Invalid; } } diff --git a/crates/agentsniff/src/storage.rs b/crates/agentsniff/src/storage.rs index d6c98a2..69161a2 100644 --- a/crates/agentsniff/src/storage.rs +++ b/crates/agentsniff/src/storage.rs @@ -31,7 +31,90 @@ pub struct ScanSummary { pub struct StorageBackup { pub filename: String, pub content_type: String, - pub data: Vec, + pub file: std::fs::File, +} + +pub const MAX_HISTORY_BYTES: u64 = 512 * 1024 * 1024; +pub const MAX_HISTORY_SCANS: u64 = 1000; + +fn check_sqlite_scan_size(conn: &Connection, scan_id: &str) -> Result<()> { + let (agent_bytes, agents): (i64, i64) = conn.query_row( + "SELECT COALESCE(SUM(length(COALESCE(host,'')) + length(COALESCE(ip_address,'')) + + length(COALESCE(framework,'')) + length(COALESCE(agentpin_identity,'')) + + length(COALESCE(mcp_capabilities,'')) + length(COALESCE(metadata,'')) + + length(COALESCE(tls_fingerprint,''))),0), COUNT(*) FROM agents WHERE scan_id = ?1", + [scan_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let (signal_bytes, signals): (i64, i64) = conn.query_row( + "SELECT COALESCE(SUM(length(COALESCE(evidence,'')) + length(COALESCE(description,'')) + + length(COALESCE(signal_type,''))),0), COUNT(*) FROM signals WHERE scan_id = ?1", + [scan_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + anyhow::ensure!( + signals <= crate::limits::MAX_SIGNALS as i64 + && agent_bytes + .saturating_add(signal_bytes) + .saturating_add((agents + signals) * 1024) + <= crate::limits::MAX_SCAN_BYTES as i64, + "stored scan exceeds evidence budget; export/reset this history" + ); + Ok(()) +} + +/// Only tighten the application's own directory, never arbitrary existing parents. +fn prepare_private_database(path: &std::path::Path) -> Result<()> { + if path == std::path::Path::new(":memory:") { + return Ok(()); + } + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(parent)?; + #[cfg(unix)] + if parent.file_name().is_some_and(|name| name == ".agentsniff") { + anyhow::ensure!( + !std::fs::symlink_metadata(parent)?.file_type().is_symlink(), + "SQLite state directory must not be a symlink" + ); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + } + for suffix in ["", "-wal", "-shm"] { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + let path = std::path::Path::new(&name); + if !suffix.is_empty() && !path.exists() { + continue; + } + if let Ok(meta) = std::fs::symlink_metadata(path) { + anyhow::ensure!( + !meta.file_type().is_symlink(), + "SQLite state must not be a symlink" + ); + } + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create(suffix.is_empty()); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = options.open(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + } + Ok(()) } /// Async storage backend trait. @@ -113,16 +196,14 @@ impl SqliteBackend { path.to_string() }; - // Create parent directories if needed. - if let Some(parent) = std::path::Path::new(&expanded).parent() { - std::fs::create_dir_all(parent)?; - } + prepare_private_database(std::path::Path::new(&expanded))?; let conn = Connection::open(&expanded)?; let backend = Self { conn: Mutex::new(conn), }; backend.create_tables()?; + prepare_private_database(std::path::Path::new(&expanded))?; Ok(backend) } @@ -138,6 +219,9 @@ impl SqliteBackend { fn create_tables(&self) -> Result<()> { let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + let page_size: i64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?; + conn.pragma_update(None, "max_page_count", MAX_HISTORY_BYTES as i64 / page_size)?; + conn.pragma_update(None, "journal_size_limit", 16 * 1024 * 1024)?; conn.execute_batch("PRAGMA journal_mode=WAL;")?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS scans ( @@ -215,7 +299,18 @@ impl SqliteBackend { #[async_trait] impl StorageBackend for SqliteBackend { async fn save_scan(&self, result: &ScanResult) -> Result<()> { - let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + crate::limits::serialized_size(result, crate::limits::MAX_SCAN_BYTES)?; + let mut guard = self.conn.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + let conn = guard.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM scans WHERE scan_id != ?1", + [&result.scan_id], + |row| row.get(0), + )?; + anyhow::ensure!( + count < MAX_HISTORY_SCANS as i64, + "scan history quota reached; export/reset history before writing more scans" + ); let completed_at = result.completed_at.map(|dt| dt.to_rfc3339()); @@ -262,14 +357,19 @@ impl StorageBackend for SqliteBackend { )?; // Save all agents and their signals. + conn.execute("DELETE FROM signals WHERE scan_id = ?1", [&result.scan_id])?; + conn.execute("DELETE FROM agents WHERE scan_id = ?1", [&result.scan_id])?; for agent in &result.agents { self.save_agent_inner(&conn, &result.scan_id, agent)?; } + check_sqlite_scan_size(&conn, &result.scan_id)?; + conn.commit()?; Ok(()) } async fn list_scans(&self, limit: u32, offset: u32) -> Result> { + let limit = limit.clamp(1, crate::limits::MAX_HISTORY_PAGE); let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("{e}"))?; let mut stmt = conn.prepare( "SELECT scan_id, scope_id, target_network, started_at, completed_at, agent_count, status, detectors_run, diff @@ -317,13 +417,18 @@ impl StorageBackend for SqliteBackend { } async fn save_agent(&self, scan_id: &str, agent: &DetectedAgent) -> Result<()> { - let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + crate::limits::serialized_size(agent, crate::limits::MAX_SCAN_BYTES)?; + let mut guard = self.conn.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + let conn = guard.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; self.save_agent_inner(&conn, scan_id, agent)?; + check_sqlite_scan_size(&conn, scan_id)?; + conn.commit()?; Ok(()) } async fn get_agents(&self, scan_id: &str) -> Result> { let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("{e}"))?; + check_sqlite_scan_size(&conn, scan_id)?; let mut agent_stmt = conn.prepare( "SELECT id, asset_id, host, ip_address, port, transport, agent_type, framework, status, @@ -366,9 +471,8 @@ impl StorageBackend for SqliteBackend { let mut agents = Vec::new(); // Collect rows first to release the borrow on agent_stmt. - let rows: Vec = agent_rows.collect::, _>>()?; - - for arow in rows { + for arow in agent_rows { + let arow = arow?; let ip_addr: std::net::IpAddr = arow .ip_address .parse() @@ -594,15 +698,17 @@ impl StorageBackend for SqliteBackend { anyhow::bail!("Cannot backup in-memory database"); } - // Checkpoint WAL to ensure all data is in the main file - let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);"); - drop(conn); - - let data = std::fs::read(&db_path)?; + let snapshot = tempfile::NamedTempFile::new()?; + let mut destination = Connection::open(snapshot.path())?; + { + let backup = rusqlite::backup::Backup::new(&conn, &mut destination)?; + backup.run_to_completion(128, std::time::Duration::from_millis(1), None)?; + } + drop(destination); Ok(StorageBackup { filename: "agentsniff-backup.db".to_string(), content_type: "application/x-sqlite3".to_string(), - data, + file: snapshot.into_file(), }) } diff --git a/crates/agentsniff/src/storage_postgres.rs b/crates/agentsniff/src/storage_postgres.rs index 4d58ba0..97a16ac 100644 --- a/crates/agentsniff/src/storage_postgres.rs +++ b/crates/agentsniff/src/storage_postgres.rs @@ -4,10 +4,11 @@ use std::time::Duration; use anyhow::{Context, Result}; use async_trait::async_trait; -use serde::Serialize; use sqlx::postgres::PgPoolOptions; use sqlx::types::Json; use sqlx::{PgPool, Postgres, Row, Transaction}; +use std::io::{Seek, Write}; +use tokio_stream::StreamExt; use crate::models::{DetectedAgent, DetectorType, ScanDiff, ScanResult}; use crate::storage::{ScanSummary, StorageBackend, StorageBackup}; @@ -45,7 +46,7 @@ impl PostgresBackend { } async fn load_scan(&self, scan_id: &str) -> Result> { - let row = sqlx::query("SELECT result FROM scans WHERE scan_id = $1") + let row = sqlx::query("SELECT CASE WHEN octet_length(result::text) <= 16777216 THEN result END AS result FROM scans WHERE scan_id = $1") .bind(scan_id) .fetch_optional(&self.pool) .await?; @@ -56,7 +57,7 @@ impl PostgresBackend { tx: &mut Transaction<'_, Postgres>, scan_id: &str, ) -> Result> { - let row = sqlx::query("SELECT result FROM scans WHERE scan_id = $1 FOR UPDATE") + let row = sqlx::query("SELECT CASE WHEN octet_length(result::text) <= 16777216 THEN result END AS result FROM scans WHERE scan_id = $1 FOR UPDATE") .bind(scan_id) .fetch_optional(&mut **tx) .await?; @@ -67,10 +68,17 @@ impl PostgresBackend { #[async_trait] impl StorageBackend for PostgresBackend { async fn save_scan(&self, result: &ScanResult) -> Result<()> { - upsert_scan(&self.pool, result).await + let mut tx = self.pool.begin().await?; + sqlx::query("LOCK TABLE scans IN SHARE ROW EXCLUSIVE MODE") + .execute(&mut *tx) + .await?; + upsert_scan(&mut tx, result).await?; + tx.commit().await?; + Ok(()) } async fn list_scans(&self, limit: u32, offset: u32) -> Result> { + let limit = limit.clamp(1, crate::limits::MAX_HISTORY_PAGE); let rows = sqlx::query( "SELECT scan_id, scope_id, target_network, started_at, completed_at, agent_count, status, detectors_run, diff @@ -123,6 +131,9 @@ impl StorageBackend for PostgresBackend { async fn save_agent(&self, scan_id: &str, agent: &DetectedAgent) -> Result<()> { let mut tx = self.pool.begin().await?; + sqlx::query("LOCK TABLE scans IN SHARE ROW EXCLUSIVE MODE") + .execute(&mut *tx) + .await?; let mut scan = Self::load_scan_for_update(&mut tx, scan_id) .await? .ok_or_else(|| anyhow::anyhow!("scan '{scan_id}' does not exist"))?; @@ -131,7 +142,7 @@ impl StorageBackend for PostgresBackend { } else { scan.agents.push(agent.clone()); } - upsert_scan(&mut *tx, &scan).await?; + upsert_scan(&mut tx, &scan).await?; tx.commit().await?; Ok(()) } @@ -150,7 +161,7 @@ impl StorageBackend for PostgresBackend { async fn get_latest_scan(&self, scope_id: &str) -> Result> { let row = sqlx::query( - "SELECT result FROM scans + "SELECT CASE WHEN octet_length(result::text) <= 16777216 THEN result END AS result FROM scans WHERE scope_id = $1 AND status = 'completed' ORDER BY started_at DESC, scan_id DESC LIMIT 1", @@ -162,23 +173,39 @@ impl StorageBackend for PostgresBackend { } async fn backup(&self) -> Result { - let rows = sqlx::query("SELECT result FROM scans ORDER BY started_at, scan_id") - .fetch_all(&self.pool) - .await?; - let scans = rows - .into_iter() - .map(|row| row.try_get::, _>("result").map(|v| v.0)) - .collect::, _>>()?; - let export = PortableBackup { - format: "agentsniff-scan-export-v1", - backend: "postgres", - created_at: chrono::Utc::now().to_rfc3339(), - scans, - }; + let mut file = tempfile::tempfile()?; + write!(file, "{{\"format\":\"agentsniff-scan-export-v1\",\"backend\":\"postgres\",\"created_at\":{},\"scans\":[", + serde_json::to_string(&chrono::Utc::now().to_rfc3339())?)?; + // Export legacy oversized rows too, without materializing a whole row + // in this process. JSONB::text is valid JSON; byte chunks concatenate + // to the original UTF-8 without reserializing the row for each chunk. + // One statement provides a consistent snapshot. + let mut rows = sqlx::query( + "WITH payloads AS MATERIALIZED ( + SELECT started_at, scan_id, convert_to(result::text, 'UTF8') AS payload FROM scans) + SELECT part, substring(payload FROM part FOR 65536) AS chunk + FROM payloads + CROSS JOIN LATERAL generate_series(1, octet_length(payload), 65536) AS part + ORDER BY started_at, scan_id, part", + ) + .fetch(&self.pool); + let mut first = true; + while let Some(row) = rows.next().await { + let row = row?; + if row.try_get::("part")? == 1 { + if !first { + file.write_all(b",")?; + } + first = false; + } + file.write_all(&row.try_get::, _>("chunk")?)?; + } + file.write_all(b"]}")?; + file.rewind()?; Ok(StorageBackup { filename: "agentsniff-backup.json".to_string(), content_type: "application/json".to_string(), - data: serde_json::to_vec_pretty(&export)?, + file, }) } @@ -188,14 +215,6 @@ impl StorageBackend for PostgresBackend { } } -#[derive(Serialize)] -struct PortableBackup { - format: &'static str, - backend: &'static str, - created_at: String, - scans: Vec, -} - fn decode_scan(row: Option) -> Result> { row.map(|row| { row.try_get::, _>("result") @@ -205,10 +224,16 @@ fn decode_scan(row: Option) -> Result> .transpose() } -async fn upsert_scan<'e, E>(executor: E, result: &ScanResult) -> Result<()> -where - E: sqlx::Executor<'e, Database = Postgres>, -{ +async fn upsert_scan(tx: &mut Transaction<'_, Postgres>, result: &ScanResult) -> Result<()> { + let bytes = crate::limits::serialized_size(result, crate::limits::MAX_SCAN_BYTES)?; + let row = sqlx::query("SELECT COUNT(*) AS count, COALESCE(SUM(octet_length(result::text)), 0)::bigint AS bytes FROM scans WHERE scan_id != $1") + .bind(&result.scan_id).fetch_one(&mut **tx).await?; + anyhow::ensure!( + row.try_get::("count")? < crate::storage::MAX_HISTORY_SCANS as i64 + && row.try_get::("bytes")?.saturating_add(bytes as i64) + <= crate::storage::MAX_HISTORY_BYTES as i64, + "scan history quota reached; export/reset history before writing more scans" + ); let detectors_run = result .detectors_run .iter() @@ -242,8 +267,22 @@ where .bind(Json(&result.errors)) .bind(Json(&result.diff)) .bind(Json(result)) - .execute(executor) + .execute(&mut **tx) + .await?; + // JSONB text includes whitespace absent from serde's compact encoding. + // Check the representation used by read guards/quotas before committing. + let stored_bytes: i64 = sqlx::query_scalar( + "SELECT octet_length(result::text)::bigint FROM scans WHERE scan_id = $1", + ) + .bind(&result.scan_id) + .fetch_one(&mut **tx) .await?; + anyhow::ensure!( + stored_bytes <= crate::limits::MAX_SCAN_BYTES as i64 + && row.try_get::("bytes")?.saturating_add(stored_bytes) + <= crate::storage::MAX_HISTORY_BYTES as i64, + "stored scan exceeds history evidence budget" + ); Ok(()) } diff --git a/crates/agentsniff/tests/test_agentpin_mcp.rs b/crates/agentsniff/tests/test_agentpin_mcp.rs index 2e9e10f..ca002aa 100644 --- a/crates/agentsniff/tests/test_agentpin_mcp.rs +++ b/crates/agentsniff/tests/test_agentpin_mcp.rs @@ -115,6 +115,7 @@ fn test_router(config: ScanConfig) -> axum::Router { fn modern_request(method: &str, name: Option<&str>, params: Value) -> Request { let mut request = Request::builder() + .header("Host", "localhost:9090") .method("POST") .uri("/mcp") .header("Content-Type", "application/json") @@ -226,6 +227,7 @@ async fn rest_verifier_enforces_the_operator_configured_audience() { let response = test_router(fixture.config.clone()) .oneshot( Request::builder() + .header("Host", "localhost:9090") .method("POST") .uri("/api/agentpin/verify") .header("Content-Type", "application/json") @@ -253,6 +255,7 @@ async fn modern_mcp_rejects_missing_routing_headers() { let response = test_router(fixture().config.clone()) .oneshot( Request::builder() + .header("Host", "localhost:9090") .method("POST") .uri("/mcp") .header("Content-Type", "application/json") @@ -273,7 +276,7 @@ async fn legacy_mcp_clients_can_initialize_and_list_tools() { let initialize = app .clone() .oneshot( - Request::builder() + Request::builder().header("Host", "localhost:9090") .method("POST") .uri("/mcp") .header("Content-Type", "application/json") @@ -291,6 +294,7 @@ async fn legacy_mcp_clients_can_initialize_and_list_tools() { let list = app .oneshot( Request::builder() + .header("Host", "localhost:9090") .method("POST") .uri("/mcp") .header("Content-Type", "application/json") @@ -330,7 +334,9 @@ async fn live_http_mcp_listener_verifies_a_real_credential() { let fixture = fixture(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); - let app = test_router(fixture.config.clone()); + let mut config = fixture.config.clone(); + config.api_port = address.port(); + let app = test_router(config); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); diff --git a/crates/agentsniff/tests/test_cli.rs b/crates/agentsniff/tests/test_cli.rs index f91f67c..66125d8 100644 --- a/crates/agentsniff/tests/test_cli.rs +++ b/crates/agentsniff/tests/test_cli.rs @@ -8,7 +8,101 @@ fn test_default_config_yaml_is_valid() { .filter(|l| !l.trim_start().starts_with('#')) .collect::>() .join("\n"); - let config: agentsniff::config::ScanConfig = serde_yaml::from_str(&uncommented).unwrap(); + let config: agentsniff::config::ScanConfig = serde_saphyr::from_str(&uncommented).unwrap(); assert_eq!(config.target_network, "192.168.1.0/24"); assert!(config.enable_dns_monitor); } + +#[test] +fn yaml_environment_and_explicit_cli_precedence() { + let directory = tempfile::tempdir().unwrap(); + let config = directory.path().join("config.yaml"); + std::fs::write( + &config, + "target_network: 127.0.0.2/32\noutput_format: json\nquiet: true\n", + ) + .unwrap(); + let run = |network: Option<&str>| { + let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_agentsniff")); + command + .args(["scan", "--config"]) + .arg(&config) + .args(["--detectors", "", "--db", ":memory:"]) + .env("AGENTSNIFF_TARGET_NETWORK", "127.0.0.3/32"); + if let Some(network) = network { + command.arg(network); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice::(&output.stdout).unwrap() + }; + assert_eq!(run(None)["target_network"], "127.0.0.3/32"); + assert_eq!(run(Some("127.0.0.4/32"))["target_network"], "127.0.0.4/32"); +} + +#[cfg(unix)] +#[test] +fn release_tag_input_is_data_and_must_match_source() { + use std::process::Command; + let directory = tempfile::tempdir().unwrap(); + let git = |args: &[&str]| { + let out = Command::new("git") + .current_dir(directory.path()) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap().trim().to_owned() + }; + git(&["init", "-q"]); + git(&[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + "commit", + "-qm", + "fixture", + "--allow-empty", + ]); + git(&["tag", "v1.2.3"]); + let sha = git(&["rev-parse", "HEAD"]); + let output = directory.path().join("output"); + let script = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/release-version.sh"); + let check = |tag: &str, reference: &str| { + Command::new("bash") + .arg(&script) + .current_dir(directory.path()) + .env("RELEASE_TAG", tag) + .env("GITHUB_REF", reference) + .env("GITHUB_SHA", &sha) + .env("GITHUB_OUTPUT", &output) + .output() + .unwrap() + .status + .success() + }; + assert!(check("v1.2.3", "refs/tags/v1.2.3")); + assert!(!check("v1.2.3", "refs/heads/main")); + assert!(!check("v9.9.9", "refs/tags/v9.9.9")); + assert!(!check( + "v1.2.3$(touch injected)", + "refs/tags/v1.2.3$(touch injected)" + )); + assert!(!check("v1.2.3\nversion=evil", "refs/tags/v1.2.3")); + assert!(!directory.path().join("injected").exists()); + assert_eq!(std::fs::read_to_string(output).unwrap(), "version=v1.2.3\n"); +} diff --git a/crates/agentsniff/tests/test_config.rs b/crates/agentsniff/tests/test_config.rs index 1df3f31..4be4f86 100644 --- a/crates/agentsniff/tests/test_config.rs +++ b/crates/agentsniff/tests/test_config.rs @@ -29,7 +29,7 @@ port_scan_timeout: 5.0 custom_llm_domains: - "my-llm.example.com" "#; - let config: ScanConfig = serde_yaml::from_str(yaml).unwrap(); + let config = ScanConfig::from_yaml_file(&write_temp_yaml("basic", yaml)).unwrap(); assert_eq!(config.target_network, "10.0.0.0/16"); assert!(!config.enable_dns_monitor); assert_eq!(config.api_port, 8080); @@ -40,8 +40,8 @@ custom_llm_domains: #[test] fn test_config_serialization_roundtrip() { let config = ScanConfig::default(); - let yaml = serde_yaml::to_string(&config).unwrap(); - let deserialized: ScanConfig = serde_yaml::from_str(&yaml).unwrap(); + let yaml = serde_saphyr::to_string(&config).unwrap(); + let deserialized = ScanConfig::from_yaml_file(&write_temp_yaml("roundtrip", &yaml)).unwrap(); assert_eq!(config.target_network, deserialized.target_network); assert_eq!(config.api_port, deserialized.api_port); } @@ -56,7 +56,7 @@ fn test_storage_config_defaults() { #[test] fn test_config_env_override() { std::env::set_var("AGENTSNIFF_TARGET_NETWORK", "172.16.0.0/12"); - let config = ScanConfig::from_env_with_defaults(ScanConfig::default()); + let config = ScanConfig::from_env_with_defaults(ScanConfig::default()).unwrap(); assert_eq!(config.target_network, "172.16.0.0/12"); std::env::remove_var("AGENTSNIFF_TARGET_NETWORK"); } @@ -84,7 +84,7 @@ zeek_enabled: true zeek_log_path: "/opt/zeek/logs/current" zeek_time_window: 600 "#; - let config: ScanConfig = serde_yaml::from_str(yaml).unwrap(); + let config = ScanConfig::from_yaml_file(&write_temp_yaml("zeek", yaml)).unwrap(); assert!(config.zeek_enabled); assert_eq!(config.zeek_log_path, "/opt/zeek/logs/current"); assert_eq!(config.zeek_time_window, 600); @@ -167,7 +167,109 @@ fn test_probe_port_overrides_detector_port_lists() { config.probe_port = Some(8765); assert_eq!(config.ports_or(&[80, 443]), vec![8765]); // Runtime-only: never read from or written to YAML. - assert!(!serde_yaml::to_string(&config) + assert!(!serde_saphyr::to_string(&config) .unwrap() .contains("probe_port")); } + +#[test] +fn yaml_preserves_ports_aliases_pem_and_defaults() { + let config = ScanConfig::from_yaml_file(&write_temp_yaml( + "yaml-types", + r#" +target_hosts: &hosts ["127.0.0.1"] +exclude_hosts: *hosts +custom_agent_ports: + 8443: custom-agent +agentpin_trust_bundle_path: bundle.json +storage: + backend: postgres +webhook_headers: + X-Certificate: | + -----BEGIN PUBLIC KEY----- + example + -----END PUBLIC KEY----- +custom_framework_signatures: + example: + ports: [8443] + enabled: true +probe_port: 9999 +"#, + )) + .unwrap(); + assert_eq!( + config.custom_agent_ports.get(&8443).unwrap(), + "custom-agent" + ); + assert_eq!(config.target_hosts, config.exclude_hosts); + assert_eq!(config.storage.backend, "postgres"); + assert_eq!(config.storage.redis_channel, "agentsniff:events:v1"); + assert!(config.webhook_headers["X-Certificate"].ends_with("-----END PUBLIC KEY-----\n")); + assert!(config.probe_port.is_none()); + assert_eq!( + config.custom_framework_signatures["example"]["ports"][0], + 8443 + ); + assert_eq!( + config.custom_framework_signatures["example"]["enabled"], + true + ); +} + +#[test] +fn yaml_rejects_ambiguous_or_malformed_config_without_secret_snippets() { + for (index, invalid) in [ + "api_port: 1\napi_port: 2\n", + "enable_dns_monitor: yes\n", + "enable_dns_monitor: on\n", + "enable_dns_monitor: \"false\"\n", + "api_port: \"8123\"\n", + "api_token: 12345\n", + "target_network: true\n", + "api_port: [\n", + "api_port: 9090\n---\napi_port: 8080\n", + ] + .iter() + .enumerate() + { + let input = format!("smtp_password: nearby-secret-marker\n{invalid}"); + let error = ScanConfig::from_yaml_file(&write_temp_yaml(&format!("bad-{index}"), &input)) + .unwrap_err() + .to_string(); + assert!(!error.contains("nearby-secret-marker"), "{error}"); + } +} + +#[test] +fn yaml_does_not_activate_merge_keys() { + let config = + ScanConfig::from_yaml_file(&write_temp_yaml("merge", "<<: {api_port: 8123}\n")).unwrap(); + assert_eq!(config.api_port, ScanConfig::default().api_port); +} + +#[test] +fn yaml_preserves_boolean_case_and_redacts_invalid_secret_values() { + for spelling in ["true", "True", "TRUE"] { + let path = write_temp_yaml( + &format!("nmap-{spelling}"), + &format!("nmap_enabled: {spelling}\n"), + ); + assert!(ScanConfig::from_yaml_file(&path) + .unwrap_err() + .to_string() + .contains("enrich_enabled")); + } + for (index, yaml) in [ + "smtp_password: 12345678901234567890\n", + "api_token: [secret-scalar-marker]\n", + ] + .iter() + .enumerate() + { + let error = ScanConfig::from_yaml_file(&write_temp_yaml(&format!("secret-{index}"), yaml)) + .unwrap_err(); + let rendered = format!("{error:#}"); + assert!(!rendered.contains("12345678901234567890")); + assert!(!rendered.contains("secret-scalar-marker")); + } +} diff --git a/crates/agentsniff/tests/test_security_limits.rs b/crates/agentsniff/tests/test_security_limits.rs new file mode 100644 index 0000000..ef0a5a2 --- /dev/null +++ b/crates/agentsniff/tests/test_security_limits.rs @@ -0,0 +1,585 @@ +use std::{net::IpAddr, sync::Arc, time::Duration}; + +use agentsniff::{ + config::ScanConfig, + detectors::{dns_monitor::DnsMonitorDetector, mcp_detector::McpDetector, Detector}, + ebpf::EbpfChannels, + models::{DetectedAgent, ScanResult}, + server::create_router_with_storage, + storage::{SqliteBackend, StorageBackend}, +}; +use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::post, + Json, Router, +}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +fn router(config: ScanConfig) -> Router { + create_router_with_storage( + config, + Box::new(SqliteBackend::in_memory().unwrap()), + Arc::new(EbpfChannels::new()), + ) +} + +#[tokio::test] +async fn loopback_authority_and_origin_are_enforced() { + for host in [ + None, + Some("attacker.example:9090"), + Some("localhost.evil:9090"), + Some("attacker@localhost:9090"), + Some("127.0.0.1:80"), + ] { + let mut request = Request::builder().uri("/api/settings"); + if let Some(host) = host { + request = request.header("Host", host); + } + let response = router(ScanConfig::default()) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{host:?}"); + assert_eq!(response.headers()["x-frame-options"], "DENY"); + assert_eq!( + response.headers()["content-security-policy"], + "frame-ancestors 'none'" + ); + } + for host in ["localhost:9090", "127.0.0.1:9090", "[::1]:9090"] { + let response = router(ScanConfig::default()) + .oneshot( + Request::builder() + .uri("/api/settings") + .header("Host", host) + .header("Origin", format!("http://{host}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{host}"); + } + for origin in [ + "null", + "http://evil.example:9090", + "http://localhost:9090/evil", + "http://localhost:9091", + ] { + let response = router(ScanConfig::default()) + .oneshot( + Request::builder() + .uri("/api/settings") + .header("Host", "localhost:9090") + .header("Origin", origin) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{origin}"); + } + let response = router(ScanConfig::default()) + .oneshot( + Request::builder() + .uri("/api/settings") + .header("Host", "localhost:9090") + .header("Host", "evil.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = router(ScanConfig::default()) + .oneshot( + Request::builder() + .uri("http://evil.example/api/settings") + .header("Host", "localhost:9090") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn authenticated_reverse_proxy_still_works_and_invalid_router_fails_closed() { + let token = "0123456789abcdef0123456789abcdef"; + let config = ScanConfig { + api_host: "0.0.0.0".into(), + api_token: token.into(), + ..ScanConfig::default() + }; + let response = router(config) + .oneshot( + Request::builder() + .uri("/api/settings") + .header("Host", "scanner.example") + .header("Origin", "https://scanner.example") + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let config = ScanConfig { + api_host: "0.0.0.0".into(), + ..ScanConfig::default() + }; + let response = router(config) + .oneshot( + Request::builder() + .uri("/api/settings") + .header("Host", "localhost:9090") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +async fn start_fixture(app: Router) -> (u16, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (port, task) +} + +#[tokio::test] +async fn large_mcp_catalogs_and_discovery_are_sampled() { + let app = Router::new().route("/mcp", post(|Json(req): Json| async move { + let large = "x".repeat(200_000); + let result = match req["method"].as_str().unwrap_or("") { + "initialize" => json!({"protocolVersion":"2025-11-25", "serverInfo":{"name":large}, + "capabilities":{"tools":{}, "padding":large, "experimental":{"org.agentpin/credential-binding":{"padding":large}}}}), + "tools/list" => json!({"tools":[{"name":"large", "description":large}]}), + "resources/list" => json!({"resources":[{"uri":"test://resource", "description":large}]}), + "prompts/list" => json!({"prompts":[{"name":"prompt", "description":large}]}), + _ => json!({}), + }; + Json(json!({"jsonrpc":"2.0", "id":1, "result":result})) + })); + let (port, task) = start_fixture(app).await; + let config = ScanConfig { + probe_port: Some(port), + http_timeout: 1.0, + ..ScanConfig::default() + }; + let detector = McpDetector::new(&config); + let signals = detector + .scan(&["127.0.0.1".parse().unwrap()]) + .await + .unwrap(); + task.abort(); + assert!(!signals.is_empty()); + let retained = serde_json::to_vec(&signals).unwrap(); + assert!( + retained.len() < 64 * 1024, + "retained {} bytes", + retained.len() + ); + assert!(String::from_utf8(retained).unwrap().contains("truncated")); +} + +#[tokio::test] +async fn dns_flood_stops_with_an_explicit_incomplete_error() { + for (count, expected) in [(257, "budget exceeded"), (1025, "events dropped")] { + let channels = Arc::new(EbpfChannels::new()); + let producer = channels.clone(); + let config = ScanConfig { + dns_monitor_duration: 2, + ..ScanConfig::default() + }; + let detector = DnsMonitorDetector::new(&config, Some(channels)); + let task = tokio::spawn(async move { + let mut event = agentsniff_common::DnsEvent { + src_addr: u32::from(std::net::Ipv4Addr::LOCALHOST), + dst_addr: 0, + query_name: [0; 256], + query_len: 14, + timestamp_ns: 0, + }; + event.query_name[..14].copy_from_slice(b"api.openai.com"); + while producer.dns_tx.receiver_count() == 0 { + tokio::task::yield_now().await; + } + for _ in 0..count { + assert!(producer.dns_tx.send(event).is_ok()); + } + }); + let error = detector + .scan(&["127.0.0.1".parse::().unwrap()]) + .await + .unwrap_err(); + task.await.unwrap(); + assert!(error.to_string().contains(expected)); + } +} + +#[tokio::test] +async fn a2a_retained_evidence_is_bounded_before_task_collection() { + use agentsniff::detectors::a2a_detector::A2aDetector; + let app = Router::new().fallback(|| async { + Json( + json!({"name":"x".repeat(200_000), "version":"1", "description":"large fixture", + "capabilities":{}, "skills":[], "defaultInputModes":[], "defaultOutputModes":[], + "url":"https://fixture.invalid/"}), + ) + }); + let (port, server) = start_fixture(app).await; + let config = ScanConfig { + probe_port: Some(port), + http_timeout: 2.0, + ..ScanConfig::default() + }; + let detector = A2aDetector::new(&config); + let error = detector + .scan(&vec!["127.0.0.1".parse().unwrap(); 64]) + .await + .unwrap_err(); + server.abort(); + assert!(error.to_string().contains("evidence budget exceeded")); +} + +#[tokio::test] +async fn passive_dns_honors_configured_duration() { + let channels = Arc::new(EbpfChannels::new()); + let producer = channels.clone(); + let detector = DnsMonitorDetector::new( + &ScanConfig { + dns_monitor_duration: 1, + ..ScanConfig::default() + }, + Some(channels), + ); + let task = tokio::spawn(async move { + let mut event = agentsniff_common::DnsEvent { + src_addr: 0x7f000001, + dst_addr: 0, + query_name: [0; 256], + query_len: 14, + timestamp_ns: 0, + }; + event.query_name[..14].copy_from_slice(b"api.openai.com"); + while producer.dns_tx.receiver_count() == 0 { + tokio::task::yield_now().await; + } + assert!(producer.dns_tx.send(event).is_ok()); + }); + let started = std::time::Instant::now(); + let signals = tokio::time::timeout( + Duration::from_secs(3), + detector.scan(&["127.0.0.1".parse().unwrap()]), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(signals.len(), 1); + assert!(started.elapsed() >= Duration::from_millis(900)); + task.await.unwrap(); +} + +#[tokio::test] +async fn passive_tls_flood_and_lag_fail_explicitly() { + use agentsniff::detectors::tls_fingerprint::TlsFingerprintDetector; + for (count, expected) in [(257, "budget exceeded"), (513, "events dropped")] { + let channels = Arc::new(EbpfChannels::new()); + let producer = channels.clone(); + let detector = TlsFingerprintDetector::new(&ScanConfig::default(), Some(channels)); + let task = tokio::spawn(async move { + let event = agentsniff_common::TlsEvent { + src_addr: 0x7f000001, + dst_addr: 0, + dst_port: 443, + tls_version: 0x303, + cipher_suites: [0; 64], + cipher_count: 0, + extensions: [0; 64], + extension_count: 0, + timestamp_ns: 0, + }; + while producer.tls_tx.receiver_count() == 0 { + tokio::task::yield_now().await; + } + for _ in 0..count { + assert!(producer.tls_tx.send(event).is_ok()); + } + }); + let error = detector + .scan(&["127.0.0.1".parse().unwrap()]) + .await + .unwrap_err(); + task.await.unwrap(); + assert!(error.to_string().contains(expected)); + } +} + +#[tokio::test] +async fn cancellation_interrupts_stalled_detector_and_releases_connection() { + use tokio::io::AsyncReadExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (started, receive) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + // MCP probes TCP reachability before opening its HTTP request. + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut data = [0; 4096]; + let n = stream.read(&mut data).await.unwrap(); + if n == 0 { + continue; + } + started.send(()).unwrap(); + return stream.read(&mut data).await.unwrap_or(0); + } + }); + let config = ScanConfig { + target_network: "127.0.0.1/32".into(), + probe_port: Some(port), + http_timeout: 30.0, + enable_dns_monitor: false, + enable_port_scanner: false, + enable_agentpin_prober: false, + enable_a2a_detector: false, + enable_endpoint_prober: false, + enable_tls_fingerprint: false, + enable_traffic_analyzer: false, + enable_sse_detector: false, + ..ScanConfig::default() + }; + let cancel = tokio_util::sync::CancellationToken::new(); + let token = cancel.clone(); + let scan = tokio::spawn(async move { + agentsniff::scanner::run_scan(&config, None, Some(token), None).await + }); + tokio::time::timeout(Duration::from_secs(2), receive) + .await + .unwrap() + .unwrap(); + cancel.cancel(); + let result = tokio::time::timeout(Duration::from_secs(1), scan) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(result.cancelled); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), server) + .await + .unwrap() + .unwrap(), + 0 + ); +} + +#[tokio::test] +async fn storage_rejects_oversized_updates_and_backups_are_consistent() { + let directory = tempfile::tempdir().unwrap(); + let storage = SqliteBackend::new(directory.path().join("state.db").to_str().unwrap()).unwrap(); + let mut result = ScanResult::new(); + result.scan_id = "bounded-storage".into(); + storage.save_scan(&result).await.unwrap(); + let mut agent = DetectedAgent::new("host".into(), "127.0.0.1".parse().unwrap()); + agent.metadata = json!({"large": "x".repeat(17 * 1024 * 1024)}); + assert!(storage.save_agent(&result.scan_id, &agent).await.is_err()); + result.agents.push(agent); + assert!(storage.save_scan(&result).await.is_err()); + assert!(storage + .get_scan(&result.scan_id) + .await + .unwrap() + .unwrap() + .agents + .is_empty()); + let mut backup = storage.backup().await.unwrap(); + let mut destination = tempfile::NamedTempFile::new().unwrap(); + std::io::copy(&mut backup.file, &mut destination).unwrap(); + let conn = rusqlite::Connection::open(destination.path()).unwrap(); + let integrity: String = conn + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .unwrap(); + assert_eq!(integrity, "ok"); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM scans", [], |row| row.get::<_, i64>(0)) + .unwrap(), + 1 + ); +} + +#[cfg(unix)] +#[test] +fn sqlite_and_sidecars_are_private_and_existing_modes_are_repaired() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".agentsniff/state.db"); + let backend = SqliteBackend::new(path.to_str().unwrap()).unwrap(); + for name in ["state.db", "state.db-wal", "state.db-shm"] { + let meta = std::fs::metadata(path.parent().unwrap().join(name)).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600, "{name}"); + } + assert_eq!( + std::fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + drop(backend); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap(); + let _reopened = SqliteBackend::new(path.to_str().unwrap()).unwrap(); + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + +#[tokio::test] +async fn slow_webhook_does_not_block_scan_completion_or_next_scan() { + let entered = Arc::new(tokio::sync::Notify::new()); + let notify = entered.clone(); + let webhook = Router::new().route( + "/", + post(move || { + let notify = notify.clone(); + async move { + notify.notify_one(); + std::future::pending::().await + } + }), + ); + let (port, server) = start_fixture(webhook).await; + let config = ScanConfig { + alert_enabled: true, + alert_min_agents: 0, + alert_changes_only: false, + webhook_url: format!("http://127.0.0.1:{port}/"), + ..ScanConfig::default() + }; + let app = router(config); + let start = || { + Request::builder() + .method("POST") + .uri("/api/scan") + .header("Host", "localhost:9090") + .header("Content-Type", "application/json") + .body(Body::from(r#"{"network":"127.0.0.1/32","detectors":""}"#)) + .unwrap() + }; + assert_eq!( + app.clone().oneshot(start()).await.unwrap().status(), + StatusCode::OK + ); + tokio::time::timeout(Duration::from_secs(2), entered.notified()) + .await + .unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/scan/status") + .header("Host", "localhost:9090") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + assert!(String::from_utf8_lossy(&body).contains("completed")); + assert_eq!(app.oneshot(start()).await.unwrap().status(), StatusCode::OK); + server.abort(); +} + +#[tokio::test] +async fn webhook_timeout_and_redirect_policy_have_healthy_control() { + let (port, server) = start_fixture( + Router::new() + .route("/ok", post(|| async { StatusCode::NO_CONTENT })) + .route( + "/redirect", + post(|| async { (StatusCode::TEMPORARY_REDIRECT, [("location", "/ok")]) }), + ) + .route("/slow", post(|| std::future::pending::())), + ) + .await; + let result = ScanResult::new(); + for (path, expected) in [ + ("ok", "webhook:ok:204"), + ("redirect", "webhook:failed:307"), + ("slow", "webhook:failed:"), + ] { + let config = ScanConfig { + webhook_url: format!("http://127.0.0.1:{port}/{path}"), + ..ScanConfig::default() + }; + let outcome = tokio::time::timeout( + Duration::from_secs(12), + agentsniff::notifier::send_alerts(&result, &config), + ) + .await + .unwrap(); + assert!(outcome[0].starts_with(expected), "{outcome:?}"); + } + server.abort(); +} + +#[tokio::test] +async fn history_quota_and_result_paging_preserve_existing_scans() { + let storage = SqliteBackend::in_memory().unwrap(); + for i in 0..agentsniff::storage::MAX_HISTORY_SCANS { + let mut result = ScanResult::new(); + result.scan_id = format!("scan-{i}"); + storage.save_scan(&result).await.unwrap(); + } + assert!(storage.save_scan(&ScanResult::new()).await.is_err()); + assert_eq!(storage.get_scan_count().await.unwrap(), 1000); + assert_eq!(storage.list_scans(u32::MAX, 0).await.unwrap().len(), 100); + let mut result = ScanResult::new(); + result.scan_id = "scan-0".into(); + for port in 1..=101 { + result.agents.push(DetectedAgent::for_endpoint( + "local".into(), + "127.0.0.1".parse().unwrap(), + Some(port), + "tcp", + )); + } + storage.save_scan(&result).await.unwrap(); // replacing a scan does not consume a new slot + let app = create_router_with_storage( + ScanConfig::default(), + Box::new(storage), + Arc::new(EbpfChannels::new()), + ); + for (offset, expected) in [(0, 100), (100, 1)] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/scan/scan-0?limit=4294967295&offset={offset}")) + .header("Host", "localhost:9090") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["agents"].as_array().unwrap().len(), expected); + assert_eq!(body["pagination"]["total"], 101); + assert_eq!(body["pagination"]["limit"], 100); + } +} diff --git a/crates/agentsniff/tests/test_server.rs b/crates/agentsniff/tests/test_server.rs index 24d3f32..cc1c931 100644 --- a/crates/agentsniff/tests/test_server.rs +++ b/crates/agentsniff/tests/test_server.rs @@ -22,6 +22,7 @@ fn test_router_with_config(config: ScanConfig) -> axum::Router { fn bearer_request(method: &str, uri: &str, token: &str) -> Request { Request::builder() + .header("Host", "localhost:9090") .method(method) .uri(uri) .header("Authorization", format!("Bearer {token}")) @@ -46,6 +47,7 @@ async fn test_health_endpoint() { let response = app .oneshot( Request::builder() + .header("Host", "localhost:9090") .uri("/api/health") .body(Body::empty()) .unwrap(), @@ -61,6 +63,7 @@ async fn test_scan_status_idle() { let response = app .oneshot( Request::builder() + .header("Host", "localhost:9090") .uri("/api/scan/status") .body(Body::empty()) .unwrap(), @@ -76,6 +79,7 @@ async fn test_scan_history_empty() { let response = app .oneshot( Request::builder() + .header("Host", "localhost:9090") .uri("/api/scan/history?limit=10&offset=0") .body(Body::empty()) .unwrap(), @@ -91,6 +95,7 @@ async fn test_agents_empty() { let response = app .oneshot( Request::builder() + .header("Host", "localhost:9090") .uri("/api/agents") .body(Body::empty()) .unwrap(), @@ -104,7 +109,13 @@ async fn test_agents_empty() { async fn test_dashboard_serves() { let app = test_router(); let response = app - .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .header("Host", "localhost:9090") + .uri("/") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); @@ -123,6 +134,7 @@ async fn test_cross_origin_requests_are_not_enabled() { let response = test_router() .oneshot( Request::builder() + .header("Host", "localhost:9090") .uri("/api/health") .header("Origin", "https://attacker.example") .body(Body::empty()) @@ -141,6 +153,7 @@ async fn test_scan_start_requires_json_body() { let response = test_router() .oneshot( Request::builder() + .header("Host", "localhost:9090") .method("POST") .uri("/api/scan?network=192.0.2.1/32") .body(Body::empty()) @@ -157,6 +170,7 @@ async fn test_local_mutations_reject_simple_cross_origin_forms() { let response = test_router() .oneshot( Request::builder() + .header("Host", "localhost:9090") .method("POST") .uri(uri) .header("Origin", "https://attacker.example") @@ -185,6 +199,7 @@ async fn test_api_token_protects_every_non_health_route_class() { let public_health = test_router_with_config(config.clone()) .oneshot( Request::builder() + .header("Host", "localhost:9090") .uri("/api/health") .body(Body::empty()) .unwrap(), @@ -208,6 +223,7 @@ async fn test_api_token_protects_every_non_health_route_class() { let response = test_router_with_config(config.clone()) .oneshot( Request::builder() + .header("Host", "localhost:9090") .method(method) .uri(uri) .body(Body::empty()) @@ -233,6 +249,7 @@ async fn test_api_token_protects_every_non_health_route_class() { let authorized = test_router_with_config(config) .oneshot( Request::builder() + .header("Host", "localhost:9090") .uri("/") .header("Authorization", format!("Basic {basic}")) .body(Body::empty()) @@ -258,6 +275,7 @@ async fn test_api_token_rejects_malformed_or_wrong_credentials() { let response = test_router_with_config(config.clone()) .oneshot( Request::builder() + .header("Host", "localhost:9090") .uri("/api/scan/status") .header("Authorization", authorization) .body(Body::empty()) @@ -307,6 +325,7 @@ async fn test_destination_changes_clear_retained_credentials() { let response = test_router_with_config(config) .oneshot( Request::builder() + .header("Host", "localhost:9090") .method("PUT") .uri("/api/settings") .header("Content-Type", "application/json") @@ -345,7 +364,7 @@ async fn test_destination_change_accepts_replacement_smtp_credentials() { let response = app .clone() .oneshot( - Request::builder() + Request::builder().header("Host", "localhost:9090") .method("PUT") .uri("/api/settings") .header("Content-Type", "application/json") @@ -361,6 +380,7 @@ async fn test_destination_change_accepts_replacement_smtp_credentials() { let response = app .oneshot( Request::builder() + .header("Host", "localhost:9090") .uri("/api/settings") .body(Body::empty()) .unwrap(), diff --git a/crates/agentsniff/tests/test_storage_backends.rs b/crates/agentsniff/tests/test_storage_backends.rs index 44060f1..ed67d41 100644 --- a/crates/agentsniff/tests/test_storage_backends.rs +++ b/crates/agentsniff/tests/test_storage_backends.rs @@ -72,9 +72,47 @@ async fn postgres_storage_contract_when_test_database_is_configured() { .scan_id, "postgres-contract" ); - let backup = backend.backup().await.unwrap(); + let mut backup = backend.backup().await.unwrap(); assert_eq!(backup.content_type, "application/json"); - assert!(backup.data.starts_with(b"{")); + let mut byte = [0; 1]; + std::io::Read::read_exact(&mut backup.file, &mut byte).unwrap(); + assert_eq!(&byte, b"{"); + let mut oversized = + agentsniff::models::DetectedAgent::new("large".into(), "127.0.0.1".parse().unwrap()); + oversized.metadata = serde_json::json!({"data":"x".repeat(17 * 1024 * 1024)}); + assert!(backend + .save_agent(&result.scan_id, &oversized) + .await + .is_err()); + result.agents.push(oversized); + assert!(backend.save_scan(&result).await.is_err()); + assert!(backend + .get_scan(&result.scan_id) + .await + .unwrap() + .unwrap() + .agents + .is_empty()); + // Simulate oversized history written by a pre-budget release. Recovery + // export must preserve it even though normal reads reject it. + let pool = sqlx::PgPool::connect(&url).await.unwrap(); + sqlx::query("UPDATE scans SET result = $1 WHERE scan_id = $2") + .bind(sqlx::types::Json(&result)) + .bind(&result.scan_id) + .execute(&pool) + .await + .unwrap(); + assert!(backend.get_scan(&result.scan_id).await.is_err()); + let backup = backend.backup().await.unwrap(); + let exported: serde_json::Value = serde_json::from_reader(backup.file).unwrap(); + assert_eq!( + exported["scans"][0]["agents"][0]["metadata"]["data"] + .as_str() + .unwrap() + .len(), + 17 * 1024 * 1024 + ); + pool.close().await; backend.reset().await.unwrap(); } diff --git a/docker-compose.yml b/docker-compose.yml index fad4127..107f209 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,29 +13,34 @@ services: AGENTSNIFF_ENABLE_DNS_MONITOR: "false" AGENTSNIFF_ENABLE_TLS_FINGERPRINT: "false" AGENTSNIFF_ENABLE_TRAFFIC_ANALYZER: "false" + AGENTSNIFF_ENABLE_SSE_DETECTOR: "false" restart: unless-stopped volumes: - agentsniff-state:/home/agentsniff/.agentsniff - # Opt-in host-network profile for passive capture. The dashboard still binds - # only to host loopback; NET_RAW/NET_ADMIN are not granted to the default service. + # Opt-in raw-socket SSE capture. This image does not contain eBPF programs. + # For kernel DNS/TLS/process capture, use a host build with --features ebpf + # and set AGENTSNIFF_EBPF_INTERFACE to the interface to attach. agentsniff-passive: profiles: ["passive"] build: . container_name: agentsniff-passive network_mode: host - # Root is isolated to this explicit profile and receives only the two - # network capabilities required by passive capture. + # Root and NET_RAW are isolated to this explicit profile. user: root command: ["serve", "--host", "127.0.0.1", "--port", "9090"] environment: + HOME: /var/lib/agentsniff AGENTSNIFF_TARGET_NETWORK: 192.168.1.0/24 cap_add: - NET_RAW - - NET_ADMIN + cap_drop: + - ALL restart: unless-stopped volumes: - - agentsniff-state:/home/agentsniff/.agentsniff + # Separate root-owned state; never chmod/chown the standard service's volume. + - agentsniff-passive-state:/var/lib/agentsniff volumes: agentsniff-state: + agentsniff-passive-state: diff --git a/docs/api-reference.md b/docs/api-reference.md index f1ee26f..51f1684 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -6,6 +6,19 @@ For API clients, send the token as a Bearer credential. Browsers can use HTTP Ba ## Endpoints +Credential-free requests must address a literal loopback IP or `localhost` at +the configured listener port. DNS aliases are rejected to prevent DNS rebinding. +For a reverse proxy or custom hostname, configure an API token. Browser requests +must have a same-authority Origin. Dashboard/API responses cannot be framed. + +Result endpoints (`/api/scan/results` and `/api/scan/{scan_id}`) accept +`?limit=100&offset=0` and return at most 100 agents plus +`pagination: {total, offset, limit, next_offset}`. Follow `next_offset` using the +returned scan ID to keep pages consistent. `/api/agents` accepts the same page +parameters and returns an array. History pages are also capped at 100 entries. +SSE events carry compact progress summaries; fetch result pages for full evidence. +The dashboard loads all result pages automatically. + | Endpoint | Method | Description | |---|---|---| | `GET /` | — | Web dashboard | diff --git a/docs/configuration.md b/docs/configuration.md index eb752b2..8f703c0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,8 +2,45 @@ AgentSniff can be configured via YAML file, environment variables, or CLI flags. +For `scan` and `serve`, precedence is defaults → YAML → environment → explicitly +supplied CLI flags. Omitting a flag preserves the YAML/environment value. +Environment names are `AGENTSNIFF_` plus the upper-case field name; booleans accept +`true/false`, `yes/no`, `on/off`, or `1/0`; lists are comma-separated and maps +are JSON. Invalid environment values refuse startup without printing their contents. +Storage aliases are `AGENTSNIFF_STORAGE_BACKEND`, `AGENTSNIFF_DB_PATH`, +`AGENTSNIFF_POSTGRES_URL`, `AGENTSNIFF_REDIS_URL`, and `AGENTSNIFF_REDIS_CHANNEL`. +The AgentPin path/audience aliases remain `AGENTSNIFF_AGENTPIN_TRUST_BUNDLE`, +`AGENTSNIFF_AGENTPIN_PIN_STORE`, and `AGENTSNIFF_AGENTPIN_AUDIENCE`. + +Changing a webhook/SMTP destination via environment overrides clears inherited +credentials unless replacement credentials are explicitly provided. +`dns_monitor_duration` controls the passive DNS observation window (maximum +3600 seconds). HTTP/port timeouts must be positive and no greater than 300 seconds; +concurrency must be between 1 and 1024. + +### Resource and history limits + +Scans retain at most 16 MiB of serialized result evidence, 16,384 signals total, +and 256 signals per asset. Large MCP catalogs and discovery metadata retain a +bounded sample with explicit truncation metadata. Passive timing capture is +limited to 1,024 flows, 2,048 samples per flow, and 32,768 samples overall. +Exceeding a capture/evidence budget marks results incomplete; incomplete results +cannot produce removal alerts. + +History accepts at most 1,000 scans and a 512 MiB storage budget (SQLite database +pages; PostgreSQL serialized result payloads, excluding indexes and server WAL). +Oversized writes fail transactionally; existing history is not automatically +deleted. Export and reset history explicitly when the quota is reached. SQLite +files and sidecars use owner-only permissions on Unix. Backups use a consistent +SQLite snapshot or a streamed PostgreSQL JSON export, not an in-memory database copy. + ## YAML Configuration +YAML uses a bounded, safe-Rust parser. Duplicate keys are rejected, merge keys +(`<<`) are not expanded, and booleans use `true`/`false` (not `yes`/`on`). +Quote numeric-looking strings such as tokens. Parser diagnostics do not include +surrounding source lines, which may contain credentials. + Generate a default config: ```bash @@ -50,7 +87,7 @@ output_file: "" verbose: false # Web dashboard API -api_enabled: false +api_enabled: true api_host: "127.0.0.1" api_port: 9090 api_token: "" # required for non-loopback; prefer AGENTSNIFF_API_TOKEN diff --git a/scripts/release-version.sh b/scripts/release-version.sh new file mode 100644 index 0000000..b4653db --- /dev/null +++ b/scripts/release-version.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +# Context values arrive as environment data, never as shell source. +tag="${RELEASE_TAG:?release tag is required}" +if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z][0-9A-Za-z.+-]*)?$ ]]; then + echo "Invalid release tag" >&2 + exit 1 +fi +if [[ "${GITHUB_REF:?}" != "refs/tags/$tag" ]]; then + echo "Run releases from the matching tag ref" >&2 + exit 1 +fi +tag_commit="$(git rev-parse --verify "refs/tags/${tag}^{commit}")" +head_commit="$(git rev-parse HEAD)" +if [[ "$tag_commit" != "$head_commit" || "$head_commit" != "${GITHUB_SHA:?}" ]]; then + echo "Release tag, event commit, and checkout must match" >&2 + exit 1 +fi +printf 'version=%s\n' "$tag" >> "${GITHUB_OUTPUT:?}"