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