diff --git a/.github/workflows/ci-rust-python-package.yaml b/.github/workflows/ci-rust-python-package.yaml index 9a5e00c..897bd87 100644 --- a/.github/workflows/ci-rust-python-package.yaml +++ b/.github/workflows/ci-rust-python-package.yaml @@ -117,6 +117,10 @@ jobs: release-validation: if: github.event_name == 'pull_request' needs: validate-and-detect + permissions: + contents: read + pull-requests: read + id-token: write uses: ./.github/workflows/release-rust-python-package.yaml with: tag: rate-limiter-v0.0.3 diff --git a/.github/workflows/release-rust-python-package.yaml b/.github/workflows/release-rust-python-package.yaml index f3c20d6..eda9040 100644 --- a/.github/workflows/release-rust-python-package.yaml +++ b/.github/workflows/release-rust-python-package.yaml @@ -46,6 +46,7 @@ jobs: wheel_matrix: ${{ steps.resolve.outputs.wheel_matrix }} publish_env: ${{ steps.resolve.outputs.publish_env }} checkout_ref: ${{ steps.resolve.outputs.checkout_ref }} + tag_on_main: ${{ steps.resolve.outputs.tag_on_main }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 @@ -63,22 +64,31 @@ jobs: REPOSITORY_INPUT: ${{ inputs.repository }} run: | set -euo pipefail - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" || "${GITHUB_EVENT_NAME}" == "workflow_call" ]]; then + git fetch --force origin "refs/heads/main:refs/remotes/origin/main" + if [[ -n "${TAG_INPUT}" ]]; then tag="${TAG_INPUT}" repository="${REPOSITORY_INPUT}" git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}" git show-ref --verify --quiet "refs/tags/${tag}" checkout_ref="refs/tags/${tag}" + tag_ref="refs/tags/${tag}" else tag="${GITHUB_REF_NAME}" repository="pypi" checkout_ref="${GITHUB_REF}" + tag_ref="${GITHUB_REF}" + fi + + if git merge-base --is-ancestor "${tag_ref}" "refs/remotes/origin/main"; then + tag_on_main=true + else + tag_on_main=false fi release_info="$(python3 tools/plugin_catalog.py release-info . "${tag}")" plugin="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["slug"])')" plugin_path="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["path"])')" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" || "${GITHUB_EVENT_NAME}" == "workflow_call" ]]; then + if [[ -n "${TAG_INPUT}" ]]; then wheel_matrix="$(python3 -c 'import json; print(json.dumps([{"runner":"ubuntu-latest","platform":"linux-x86_64"},{"runner":"ubuntu-24.04-arm","platform":"linux-aarch64"},{"runner":"ubuntu-24.04-s390x","platform":"linux-s390x"},{"runner":"ubuntu-24.04-ppc64le","platform":"linux-ppc64le"},{"runner":"macos-latest","platform":"macos-arm64"},{"runner":"windows-latest","platform":"windows-x86_64"}]))')" else wheel_matrix="$(printf '%s' "${release_info}" | python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["release_wheel_matrix"]))')" @@ -89,6 +99,7 @@ jobs: echo "plugin_path=${plugin_path}" echo "wheel_matrix=${wheel_matrix}" echo "checkout_ref=${checkout_ref}" + echo "tag_on_main=${tag_on_main}" if [[ "${repository}" == "testpypi" ]]; then echo "publish_env=testpypi" else @@ -240,7 +251,7 @@ jobs: "${tmpdir}/tests" -v publish: - if: ${{ github.event_name != 'workflow_call' || inputs.publish_enabled }} + if: ${{ (github.event_name != 'workflow_call' || inputs.publish_enabled) && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }} needs: [resolve, build-wheel, build-sdist] runs-on: ubuntu-latest environment: ${{ needs.resolve.outputs.publish_env }} diff --git a/Cargo.lock b/Cargo.lock index ed8076e..9499641 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1358,7 +1358,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "retry_with_backoff" -version = "0.1.0" +version = "0.1.1" dependencies = [ "log", "pyo3", diff --git a/plugins/rust/python-package/encoded_exfil_detection/Cargo.toml b/plugins/rust/python-package/encoded_exfil_detection/Cargo.toml index c696689..392fe33 100644 --- a/plugins/rust/python-package/encoded_exfil_detection/Cargo.toml +++ b/plugins/rust/python-package/encoded_exfil_detection/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "Rust-backed encoded exfiltration detection plugin for MCP Gateway" +description = "High-performance encoded exfiltration detection for MCP Gateway" [lib] name = "encoded_exfil_detection_rust" diff --git a/plugins/rust/python-package/encoded_exfil_detection/README.md b/plugins/rust/python-package/encoded_exfil_detection/README.md index 0008443..db0fa53 100644 --- a/plugins/rust/python-package/encoded_exfil_detection/README.md +++ b/plugins/rust/python-package/encoded_exfil_detection/README.md @@ -1,3 +1,96 @@ -# cpex-encoded-exfil-detection +# Encoded Exfiltration Detection (Rust) -Rust-backed encoded exfiltration detection plugin for MCP Gateway / CPEX. +High-performance encoded exfiltration detection for ContextForge and MCP Gateway. + +## Features + +- Detects suspicious encoded payloads in prompt args, tool outputs, and resource content +- Scans common exfil encodings: + - base64 + - base64url + - hex + - percent-encoding + - escaped hex +- Scores candidates using decoded length, entropy, printable ratio, sensitive keywords, and egress hints +- Optional redaction instead of hard blocking +- Recursive scanning of nested dicts, lists, and JSON-like string payloads +- Allowlist regex support for known-safe encoded strings +- Decode-depth and recursion-depth guardrails + +## Build + +```bash +make install +``` + +## Usage + +The plugin scans these hooks: + +- `prompt_pre_fetch` +- `tool_post_invoke` +- `resource_post_fetch` + +Typical uses: + +- block suspicious encoded payloads before they leave the gateway +- redact encoded secrets or staged exfil fragments from tool results +- surface findings metadata for review and tuning + +## Detection Model + +Each candidate encoded segment is decoded and scored. The detector looks for combinations of: + +- sufficient decoded length +- suspicious entropy +- printable decoded content +- sensitive markers such as `password`, `secret`, `token`, `authorization`, or `private key` +- egress hints such as `curl`, `wget`, `webhook`, `upload`, `socket`, or `pastebin` + +The plugin can also inspect JSON strings recursively so encoded content nested inside serialized blobs is still visible to the detector. + +## Configuration + +Important settings include: + +- `enabled`: per-encoding enable flags +- `min_encoded_length` +- `min_decoded_length` +- `min_entropy` +- `min_printable_ratio` +- `min_suspicion_score` +- `max_scan_string_length` +- `max_findings_per_value` +- `redact` +- `redaction_text` +- `block_on_detection` +- `min_findings_to_block` +- `allowlist_patterns` +- `extra_sensitive_keywords` +- `extra_egress_hints` +- `max_decode_depth` +- `max_recursion_depth` +- `parse_json_strings` + +## Returned Metadata + +When detections occur, the plugin can emit: + +- `encoded_exfil_count` +- `encoded_exfil_findings` +- `encoded_exfil_redacted` +- `implementation` + +Blocking responses use the `ENCODED_EXFIL_DETECTED` violation code. + +## Security Notes + +- Guardrails reject invalid allowlist regexes at configuration time. +- Scan and recursion caps exist to keep detection bounded on large payloads. +- Detailed findings can be reduced or sanitized before metadata emission depending on configuration. + +## Testing + +```bash +make ci +``` diff --git a/plugins/rust/python-package/encoded_exfil_detection/cpex_encoded_exfil_detection/__init__.pyi b/plugins/rust/python-package/encoded_exfil_detection/cpex_encoded_exfil_detection/__init__.pyi index b16dbb7..c181254 100644 --- a/plugins/rust/python-package/encoded_exfil_detection/cpex_encoded_exfil_detection/__init__.pyi +++ b/plugins/rust/python-package/encoded_exfil_detection/cpex_encoded_exfil_detection/__init__.pyi @@ -1,10 +1,11 @@ # This file is automatically generated by pyo3_stub_gen # ruff: noqa: E501, F401, F403, F405 -from .encoded_exfil_detection import EncodedExfiltrationDetectionPlugin +from .encoded_exfil_detection import EncodedExfilDetectorConfig, EncodedExfilDetectorPlugin from .encoded_exfil_detection_rust import py_scan_container __all__ = [ - "EncodedExfiltrationDetectionPlugin", + "EncodedExfilDetectorConfig", + "EncodedExfilDetectorPlugin", "py_scan_container", ] diff --git a/plugins/rust/python-package/encoded_exfil_detection/cpex_encoded_exfil_detection/plugin-manifest.yaml b/plugins/rust/python-package/encoded_exfil_detection/cpex_encoded_exfil_detection/plugin-manifest.yaml index 225ac50..13615a3 100644 --- a/plugins/rust/python-package/encoded_exfil_detection/cpex_encoded_exfil_detection/plugin-manifest.yaml +++ b/plugins/rust/python-package/encoded_exfil_detection/cpex_encoded_exfil_detection/plugin-manifest.yaml @@ -1,6 +1,7 @@ description: "Detect suspicious encoded payload exfiltration patterns in prompts, tool outputs, and resources" author: "ContextForge Contributors" version: "0.2.0" +kind: "cpex_encoded_exfil_detection.encoded_exfil_detection.EncodedExfilDetectorPlugin" available_hooks: - "prompt_pre_fetch" - "tool_post_invoke" diff --git a/plugins/rust/python-package/encoded_exfil_detection/pyproject.toml b/plugins/rust/python-package/encoded_exfil_detection/pyproject.toml index 9b3e051..be77ee6 100644 --- a/plugins/rust/python-package/encoded_exfil_detection/pyproject.toml +++ b/plugins/rust/python-package/encoded_exfil_detection/pyproject.toml @@ -5,11 +5,14 @@ build-backend = "maturin" [project] name = "cpex-encoded-exfil-detection" dynamic = ["version"] -description = "Rust-backed encoded exfiltration detection plugin for MCP Gateway" +description = "High-performance encoded exfiltration detection for MCP Gateway" authors = [{ name = "ContextForge Contributors" }] license = { text = "Apache-2.0" } readme = "README.md" requires-python = ">=3.11" +dependencies = [ + "pydantic>=2,<3", +] classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", @@ -18,6 +21,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] +[project.entry-points."cpex.plugins"] +encoded_exfil_detection = "cpex_encoded_exfil_detection.encoded_exfil_detection:EncodedExfilDetectorPlugin" + [tool.maturin] module-name = "cpex_encoded_exfil_detection.encoded_exfil_detection_rust" python-source = "." diff --git a/plugins/rust/python-package/encoded_exfil_detection/src/bin/stub_gen.rs b/plugins/rust/python-package/encoded_exfil_detection/src/bin/stub_gen.rs index 236b06e..969635b 100644 --- a/plugins/rust/python-package/encoded_exfil_detection/src/bin/stub_gen.rs +++ b/plugins/rust/python-package/encoded_exfil_detection/src/bin/stub_gen.rs @@ -8,13 +8,22 @@ use encoded_exfil_detection_rust::stub_info; fn curate_top_level_stub() { let stub_path = Path::new("cpex_encoded_exfil_detection/__init__.pyi"); - let content = "# This file is automatically generated by pyo3_stub_gen\n# ruff: noqa: E501, F401, F403, F405\n\nfrom .encoded_exfil_detection import EncodedExfiltrationDetectionPlugin\nfrom .encoded_exfil_detection_rust import py_scan_container\n\n__all__ = [\n \"EncodedExfiltrationDetectionPlugin\",\n \"py_scan_container\",\n]\n"; + let content = "# This file is automatically generated by pyo3_stub_gen\n# ruff: noqa: E501, F401, F403, F405\n\nfrom .encoded_exfil_detection import EncodedExfilDetectorConfig, EncodedExfilDetectorPlugin\nfrom .encoded_exfil_detection_rust import py_scan_container\n\n__all__ = [\n \"EncodedExfilDetectorConfig\",\n \"EncodedExfilDetectorPlugin\",\n \"py_scan_container\",\n]\n"; fs::write(stub_path, content).expect("Failed to write curated top-level stub file"); } +fn curate_extension_stub() { + let stub_path = + Path::new("cpex_encoded_exfil_detection/encoded_exfil_detection_rust/__init__.pyi"); + let content = fs::read_to_string(stub_path).expect("Failed to read generated stub file"); + let content = content.trim_end().to_string() + "\n"; + fs::write(stub_path, content).expect("Failed to write curated extension stub file"); +} + fn main() { let stub_info = stub_info().expect("Failed to get stub info"); stub_info.generate().expect("Failed to generate stub file"); curate_top_level_stub(); + curate_extension_stub(); println!("✓ Generated stub files successfully"); } diff --git a/plugins/rust/python-package/encoded_exfil_detection/uv.lock b/plugins/rust/python-package/encoded_exfil_detection/uv.lock index 3a9dd11..26f5b4a 100644 --- a/plugins/rust/python-package/encoded_exfil_detection/uv.lock +++ b/plugins/rust/python-package/encoded_exfil_detection/uv.lock @@ -23,6 +23,9 @@ wheels = [ [[package]] name = "cpex-encoded-exfil-detection" source = { editable = "." } +dependencies = [ + { name = "pydantic" }, +] [package.dev-dependencies] dev = [ @@ -33,6 +36,7 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "pydantic", specifier = ">=2,<3" }] [package.metadata.requires-dev] dev = [ diff --git a/plugins/rust/python-package/retry_with_backoff/Cargo.toml b/plugins/rust/python-package/retry_with_backoff/Cargo.toml index 8ad9921..6a97da9 100644 --- a/plugins/rust/python-package/retry_with_backoff/Cargo.toml +++ b/plugins/rust/python-package/retry_with_backoff/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "retry_with_backoff" -version = "0.1.0" +version = "0.1.1" edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "Rust-backed retry and backoff policy plugin for MCP Gateway" +description = "High-performance retry policy engine for MCP Gateway with exponential backoff, jitter, per-tool overrides, and retry metadata" [lib] name = "retry_with_backoff_rust" diff --git a/plugins/rust/python-package/retry_with_backoff/README.md b/plugins/rust/python-package/retry_with_backoff/README.md index 7b93ab5..f3b0191 100644 --- a/plugins/rust/python-package/retry_with_backoff/README.md +++ b/plugins/rust/python-package/retry_with_backoff/README.md @@ -1,3 +1,77 @@ -# Retry With Backoff +# Retry With Backoff (Rust) -Rust-backed retry and backoff policy plugin for MCP Gateway. +High-performance retry and backoff policy engine for ContextForge and MCP Gateway. + +## Features + +- Rust-backed retry state tracking for tool invocations +- Exponential backoff with optional jitter +- Per-tool policy overrides without duplicating whole plugin configs +- Retry decisions based on `isError`, structured `status_code`, or optional parsed text payloads +- Automatic state eviction for stale request entries +- Gateway ceiling enforcement for `max_retries` +- Retry policy metadata returned on tool and resource hooks + +## Build + +```bash +make install +``` + +## Usage + +The plugin runs on `tool_post_invoke` and `resource_post_fetch`. + +Typical uses: + +- Retry transient upstream failures such as `429`, `500`, `502`, `503`, and `504` +- Clamp aggressive plugin settings to the gateway-wide retry ceiling +- Apply stricter retry budgets to fragile or expensive tools + +## Configuration + +### Core settings + +- `max_retries`: maximum retry attempts before giving up +- `backoff_base_ms`: base delay for exponential backoff +- `max_backoff_ms`: upper bound for computed retry delays +- `retry_on_status`: HTTP or structured status codes treated as retriable +- `jitter`: randomize delay within the current exponential ceiling +- `check_text_content`: inspect text content for JSON-encoded error payloads when structured content is absent + +### Per-tool overrides + +Use `tool_overrides` to change retry behavior for a specific tool: + +- `max_retries` +- `backoff_base_ms` +- `max_backoff_ms` +- `retry_on_status` +- `jitter` + +## Behavior Notes + +- Successful responses clear retry state for the `(tool, request_id)` pair. +- Retry state expires after a short TTL so abandoned request state does not accumulate indefinitely. +- If `check_text_content` is disabled, the hot path uses the Rust state manager directly. +- If `check_text_content` is enabled, the plugin falls back to Python-side payload inspection before applying retry policy. + +## Returned Metadata + +Both tool and resource hooks emit retry policy metadata so downstream systems can observe the active policy: + +- `max_retries` +- `backoff_base_ms` +- `max_backoff_ms` +- `retry_on_status` + +## Testing + +```bash +# Full plugin CI +make ci +``` + +## Performance + +The retry state manager is implemented in Rust so the common retry decision path avoids Python bookkeeping overhead for normal structured tool results. diff --git a/plugins/rust/python-package/retry_with_backoff/cpex_retry_with_backoff/plugin-manifest.yaml b/plugins/rust/python-package/retry_with_backoff/cpex_retry_with_backoff/plugin-manifest.yaml index 7b039c9..cb1e038 100644 --- a/plugins/rust/python-package/retry_with_backoff/cpex_retry_with_backoff/plugin-manifest.yaml +++ b/plugins/rust/python-package/retry_with_backoff/cpex_retry_with_backoff/plugin-manifest.yaml @@ -1,6 +1,7 @@ -description: "Rust-backed retry and backoff policy plugin for tool results" +description: "High-performance retry policy engine with exponential backoff, jitter, per-tool overrides, and retry metadata for transient tool and resource failures" author: "ContextForge Contributors" -version: "0.1.0" +version: "0.1.1" +kind: "cpex_retry_with_backoff.retry_with_backoff.RetryWithBackoffPlugin" available_hooks: - "tool_post_invoke" - "resource_post_fetch" diff --git a/plugins/rust/python-package/retry_with_backoff/pyproject.toml b/plugins/rust/python-package/retry_with_backoff/pyproject.toml index 356d360..f410ba1 100644 --- a/plugins/rust/python-package/retry_with_backoff/pyproject.toml +++ b/plugins/rust/python-package/retry_with_backoff/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "maturin" [project] name = "cpex-retry-with-backoff" dynamic = ["version"] -description = "Rust-backed retry and backoff policy plugin for MCP Gateway" +description = "High-performance retry and backoff policy engine for MCP Gateway" authors = [{ name = "ContextForge Contributors" }] license = { text = "Apache-2.0" } readme = "README.md" @@ -21,6 +21,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] +[project.entry-points."cpex.plugins"] +retry_with_backoff = "cpex_retry_with_backoff.retry_with_backoff:RetryWithBackoffPlugin" + [tool.maturin] module-name = "cpex_retry_with_backoff.retry_with_backoff_rust" python-source = "." diff --git a/plugins/rust/python-package/retry_with_backoff/src/bin/stub_gen.rs b/plugins/rust/python-package/retry_with_backoff/src/bin/stub_gen.rs index 3e78dc0..3b67482 100644 --- a/plugins/rust/python-package/retry_with_backoff/src/bin/stub_gen.rs +++ b/plugins/rust/python-package/retry_with_backoff/src/bin/stub_gen.rs @@ -1,10 +1,22 @@ // Copyright 2026 // SPDX-License-Identifier: Apache-2.0 +use std::fs; +use std::path::Path; + use retry_with_backoff_rust::stub_info; +fn trim_trailing_blank_lines(path: &str) { + let stub_path = Path::new(path); + let content = fs::read_to_string(stub_path).expect("Failed to read generated stub file"); + let content = content.trim_end().to_string() + "\n"; + fs::write(stub_path, content).expect("Failed to write curated stub file"); +} + fn main() { let stub_info = stub_info().expect("Failed to get stub info"); stub_info.generate().expect("Failed to generate stub file"); + trim_trailing_blank_lines("cpex_retry_with_backoff/__init__.pyi"); + trim_trailing_blank_lines("cpex_retry_with_backoff/retry_with_backoff_rust/__init__.pyi"); println!("Generated stub files successfully"); } diff --git a/plugins/rust/python-package/retry_with_backoff/tests/test_plugin.py b/plugins/rust/python-package/retry_with_backoff/tests/test_plugin.py index 6715b3b..57f031c 100644 --- a/plugins/rust/python-package/retry_with_backoff/tests/test_plugin.py +++ b/plugins/rust/python-package/retry_with_backoff/tests/test_plugin.py @@ -233,13 +233,19 @@ def test_ttl_eviction_removes_stale_entries(self): from cpex_retry_with_backoff.retry_with_backoff import _ToolRetryState key = "evict_tool:evict_req" - _STATE[key] = _ToolRetryState( - consecutive_failures=3, - last_failure_at=time.monotonic() - _STATE_TTL_SECONDS - 1, - ) - _get_state("other_tool", "other_req") - assert key not in _STATE - _del_state("other_tool", "other_req") + baseline = _STATE.copy() + try: + with patch("cpex_retry_with_backoff.retry_with_backoff.time.monotonic", return_value=_STATE_TTL_SECONDS + 10): + _STATE[key] = _ToolRetryState( + consecutive_failures=3, + last_failure_at=9.0, + ) + _get_state("other_tool", "other_req") + assert key not in _STATE + _del_state("other_tool", "other_req") + finally: + _STATE.clear() + _STATE.update(baseline) class TestRustFallback: diff --git a/plugins/rust/python-package/secrets_detection/Cargo.toml b/plugins/rust/python-package/secrets_detection/Cargo.toml index cd3b99c..1d9afd8 100644 --- a/plugins/rust/python-package/secrets_detection/Cargo.toml +++ b/plugins/rust/python-package/secrets_detection/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "Rust-backed secrets detection plugin for MCP Gateway" +description = "High-performance secrets detection and redaction for MCP Gateway" [lib] name = "secrets_detection_rust" diff --git a/plugins/rust/python-package/secrets_detection/README.md b/plugins/rust/python-package/secrets_detection/README.md index 1622411..32b5337 100644 --- a/plugins/rust/python-package/secrets_detection/README.md +++ b/plugins/rust/python-package/secrets_detection/README.md @@ -1,3 +1,69 @@ -# cpex-secrets-detection +# Secrets Detection (Rust) -Rust-backed secrets detection plugin for MCP Gateway / CPEX. +High-performance secrets detection and redaction for ContextForge and MCP Gateway. + +## Features + +- Rust-owned recursive scanning for strings, dicts, lists, and MCP payload containers +- Built-in detection for high-signal credential formats such as AWS access keys and Slack tokens +- Optional redaction or hard blocking on detection +- Hook coverage for prompt input, tool output, and fetched resource content +- Opt-in broad patterns for lower-confidence generic token assignments +- Structured findings metadata with redacted match previews + +## Build + +```bash +make install +``` + +## Usage + +The plugin scans data at these hook points: + +- `prompt_pre_fetch` +- `tool_post_invoke` +- `resource_post_fetch` + +Typical uses: + +- redact secrets before they leave the gateway +- block tool or resource payloads that contain leaked credentials +- add lightweight findings metadata for downstream auditing + +## Configuration + +Key settings include: + +- `redact`: replace detected secret values in the payload +- `redaction_text`: replacement string for detected values +- `block_on_detection`: stop processing instead of modifying payloads +- `min_findings_to_block`: threshold before hard blocking +- `enabled`: enable optional lower-confidence detectors when needed + +## Detection Notes + +- High-signal built-in patterns are enabled by default. +- Broader generic assignment-style patterns are opt-in to avoid noisy false positives. +- Findings include secret type labels such as `aws_access_key_id` or `slack_token`. +- Redaction preserves surrounding structure when possible, for example replacing only the secret value inside a larger assignment string. + +## Returned Metadata + +When detections occur, the plugin can return metadata such as: + +- `count` +- `secrets_redacted` + +Blocking responses use the `SECRETS_DETECTED` violation code. + +## Testing + +```bash +make ci +``` + +## Security Notes + +- Default detectors are intentionally biased toward high-confidence secret formats. +- Broad token-like patterns should only be enabled when your environment benefits from higher recall and can tolerate extra review. diff --git a/plugins/rust/python-package/secrets_detection/cpex_secrets_detection/plugin-manifest.yaml b/plugins/rust/python-package/secrets_detection/cpex_secrets_detection/plugin-manifest.yaml index e836f75..18a2bd6 100644 --- a/plugins/rust/python-package/secrets_detection/cpex_secrets_detection/plugin-manifest.yaml +++ b/plugins/rust/python-package/secrets_detection/cpex_secrets_detection/plugin-manifest.yaml @@ -1,6 +1,7 @@ description: "Detect likely credentials and secrets in prompt input, tool output, and resource content" author: "ContextForge Contributors" version: "0.1.0" +kind: "cpex_secrets_detection.secrets_detection.SecretsDetectionPlugin" available_hooks: - "prompt_pre_fetch" - "tool_post_invoke" diff --git a/plugins/rust/python-package/secrets_detection/pyproject.toml b/plugins/rust/python-package/secrets_detection/pyproject.toml index b13cca6..5872374 100644 --- a/plugins/rust/python-package/secrets_detection/pyproject.toml +++ b/plugins/rust/python-package/secrets_detection/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "maturin" [project] name = "cpex-secrets-detection" dynamic = ["version"] -description = "Rust-backed secrets detection plugin for MCP Gateway" +description = "High-performance secrets detection and redaction for MCP Gateway" authors = [{ name = "ContextForge Contributors" }] license = { text = "Apache-2.0" } readme = "README.md" @@ -18,6 +18,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] +[project.entry-points."cpex.plugins"] +secrets_detection = "cpex_secrets_detection.secrets_detection:SecretsDetectionPlugin" + [tool.maturin] module-name = "cpex_secrets_detection.secrets_detection_rust" python-source = "." diff --git a/plugins/rust/python-package/url_reputation/Cargo.toml b/plugins/rust/python-package/url_reputation/Cargo.toml index 3514230..a52a0ec 100644 --- a/plugins/rust/python-package/url_reputation/Cargo.toml +++ b/plugins/rust/python-package/url_reputation/Cargo.toml @@ -5,12 +5,16 @@ edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "High-performance URL reputation validation for MCP Gateway" +description = "High-performance URL reputation and phishing detection for MCP Gateway" [lib] name = "url_reputation_rust" crate-type = ["cdylib", "rlib"] +[[bin]] +name = "stub_gen" +path = "src/bin/stub_gen.rs" + [dependencies] pyo3 = { workspace = true } pyo3-log = { workspace = true } diff --git a/plugins/rust/python-package/url_reputation/README.md b/plugins/rust/python-package/url_reputation/README.md index 9fe31a4..a4845f2 100644 --- a/plugins/rust/python-package/url_reputation/README.md +++ b/plugins/rust/python-package/url_reputation/README.md @@ -1 +1,62 @@ -# cpex-url-reputation +# URL Reputation (Rust) + +High-performance URL reputation and phishing detection for ContextForge and MCP Gateway. + +## Features + +- Domain allowlists and blocklists +- Regex allow and block patterns for path-level control +- Optional heuristic phishing checks for suspicious domains +- Entropy-based screening for machine-generated or deceptive hostnames +- Optional blocking of non-secure `http://` URLs +- Resource pre-fetch enforcement with fail-safe blocking on internal validation errors + +## Build + +```bash +make install +``` + +## Usage + +The plugin validates URLs during `resource_pre_fetch`. + +Typical uses: + +- block known bad domains before fetch +- permit trusted domains even when broader rules would block them +- catch suspicious lookalike or high-entropy hostnames +- reject non-TLS HTTP fetches by default + +## Configuration + +- `whitelist_domains`: trusted domains and parent domains that should always pass +- `blocked_domains`: domains that should always fail +- `allowed_patterns`: regexes that bypass broader blocking rules +- `blocked_patterns`: regexes that force rejection +- `use_heuristic_check`: enable phishing-style hostname heuristics +- `entropy_threshold`: threshold for heuristic blocking of random-looking hostnames +- `block_non_secure_http`: reject plain HTTP URLs + +## Validation Notes + +- Domain matching is normalized to lowercase. +- Whitelist entries win over blocked-domain rules. +- Invalid regex patterns fail fast during configuration. +- Invalid URLs are blocked. +- If the Rust core throws unexpectedly, the plugin blocks the URL rather than allowing it through silently. + +## Returned Violations + +Blocked URLs use `URL_REPUTATION_BLOCK` and include basic context such as the URL being rejected. + +## Testing + +```bash +make ci +``` + +## Security Notes + +- Heuristic checks are intentionally conservative and should complement explicit allow/block rules rather than replace them. +- IDN and lookalike-domain scenarios are best handled with a whitelist for critical providers plus heuristic checks for the long tail. diff --git a/plugins/rust/python-package/url_reputation/cpex_url_reputation/plugin-manifest.yaml b/plugins/rust/python-package/url_reputation/cpex_url_reputation/plugin-manifest.yaml index 65531bb..f2cb820 100644 --- a/plugins/rust/python-package/url_reputation/cpex_url_reputation/plugin-manifest.yaml +++ b/plugins/rust/python-package/url_reputation/cpex_url_reputation/plugin-manifest.yaml @@ -1,6 +1,7 @@ description: "Static URL reputation checks using blocked domains and URL patterns" author: "ContextForge Contributors" version: "0.1.1" +kind: "cpex_url_reputation.url_reputation.URLReputationPlugin" available_hooks: - "resource_pre_fetch" default_configs: diff --git a/plugins/rust/python-package/url_reputation/pyproject.toml b/plugins/rust/python-package/url_reputation/pyproject.toml index 08190bb..477e03b 100644 --- a/plugins/rust/python-package/url_reputation/pyproject.toml +++ b/plugins/rust/python-package/url_reputation/pyproject.toml @@ -5,11 +5,14 @@ build-backend = "maturin" [project] name = "cpex-url-reputation" dynamic = ["version"] -description = "High-performance URL reputation validation for MCP Gateway" +description = "High-performance URL reputation and phishing detection for MCP Gateway" authors = [{ name = "ContextForge Contributors" }] license = { text = "Apache-2.0" } readme = "README.md" requires-python = ">=3.11" +dependencies = [ + "pydantic>=2,<3", +] classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", @@ -18,6 +21,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] +[project.entry-points."cpex.plugins"] +url_reputation = "cpex_url_reputation.url_reputation:URLReputationPlugin" + [tool.maturin] module-name = "cpex_url_reputation.url_reputation_rust" python-source = "." diff --git a/plugins/rust/python-package/url_reputation/src/bin/stub_gen.rs b/plugins/rust/python-package/url_reputation/src/bin/stub_gen.rs new file mode 100644 index 0000000..ad6dd96 --- /dev/null +++ b/plugins/rust/python-package/url_reputation/src/bin/stub_gen.rs @@ -0,0 +1,21 @@ +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::Path; + +fn write_stub(path: &str, content: &str) { + let stub_path = Path::new(path); + fs::write(stub_path, content).expect("Failed to write stub file"); +} + +fn main() { + let top_level = "# This file is automatically generated by pyo3_stub_gen\n# ruff: noqa: E501, F401, F403, F405\n\nfrom .url_reputation import URLReputationConfig, URLReputationPlugin\nfrom .url_reputation_rust import URLReputationEngine\n\n__all__ = [\n \"URLReputationConfig\",\n \"URLReputationEngine\",\n \"URLReputationPlugin\",\n]\n"; + let rust_stub = "# This file is automatically generated by pyo3_stub_gen\n# ruff: noqa: E501, F401, F403, F405\n\nimport typing\n\n__all__ = [\n \"URLReputationEngine\",\n \"URLReputationPluginCore\",\n \"URLReputationResult\",\n]\n\n\n@typing.final\nclass URLReputationResult:\n continue_processing: bool\n violation: typing.Any\n\n\n@typing.final\nclass URLReputationEngine:\n def __new__(cls, config: dict) -> URLReputationEngine: ...\n def validate_url(self, url: str) -> URLReputationResult: ...\n\n\n@typing.final\nclass URLReputationPluginCore:\n def __new__(cls, config: dict) -> URLReputationPluginCore: ...\n def resource_pre_fetch(self, payload: typing.Any, context: typing.Any) -> typing.Any: ...\n"; + write_stub("cpex_url_reputation/__init__.pyi", top_level); + write_stub( + "cpex_url_reputation/url_reputation_rust/__init__.pyi", + rust_stub, + ); + println!("✓ Generated stub files successfully"); +} diff --git a/plugins/rust/python-package/url_reputation/uv.lock b/plugins/rust/python-package/url_reputation/uv.lock index 54103e4..87a605a 100644 --- a/plugins/rust/python-package/url_reputation/uv.lock +++ b/plugins/rust/python-package/url_reputation/uv.lock @@ -23,6 +23,9 @@ wheels = [ [[package]] name = "cpex-url-reputation" source = { editable = "." } +dependencies = [ + { name = "pydantic" }, +] [package.dev-dependencies] dev = [ @@ -33,6 +36,7 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "pydantic", specifier = ">=2,<3" }] [package.metadata.requires-dev] dev = [ diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py index 2a3a748..8b60d04 100644 --- a/tests/test_plugin_catalog.py +++ b/tests/test_plugin_catalog.py @@ -173,28 +173,43 @@ def test_repo_validates_managed_plugins_layout(self) -> None: result = run_catalog("validate", str(REPO_ROOT)) self.assertEqual(result.returncode, 0, result.stderr) - def test_repo_lists_rate_limiter_and_pii_filter(self) -> None: + def test_repo_lists_all_managed_plugins(self) -> None: result = run_catalog("list", str(REPO_ROOT)) self.assertEqual(result.returncode, 0, result.stderr) payload = json.loads(result.stdout) self.assertEqual( {entry["slug"] for entry in payload["plugins"]}, - {"rate_limiter", "pii_filter"}, + { + "encoded_exfil_detection", + "pii_filter", + "rate_limiter", + "retry_with_backoff", + "secrets_detection", + "url_reputation", + }, ) by_slug = {entry["slug"]: entry for entry in payload["plugins"]} self.assertEqual( {slug: entry["module_name"] for slug, entry in by_slug.items()}, { - "rate_limiter": "cpex_rate_limiter", + "encoded_exfil_detection": "cpex_encoded_exfil_detection", "pii_filter": "cpex_pii_filter", + "rate_limiter": "cpex_rate_limiter", + "retry_with_backoff": "cpex_retry_with_backoff", + "secrets_detection": "cpex_secrets_detection", + "url_reputation": "cpex_url_reputation", }, ) self.assertEqual( {slug: entry["kind"] for slug, entry in by_slug.items()}, { - "rate_limiter": "cpex_rate_limiter.rate_limiter.RateLimiterPlugin", + "encoded_exfil_detection": "cpex_encoded_exfil_detection.encoded_exfil_detection.EncodedExfilDetectorPlugin", "pii_filter": "cpex_pii_filter.pii_filter.PIIFilterPlugin", + "rate_limiter": "cpex_rate_limiter.rate_limiter.RateLimiterPlugin", + "retry_with_backoff": "cpex_retry_with_backoff.retry_with_backoff.RetryWithBackoffPlugin", + "secrets_detection": "cpex_secrets_detection.secrets_detection.SecretsDetectionPlugin", + "url_reputation": "cpex_url_reputation.url_reputation.URLReputationPlugin", }, ) @@ -1347,7 +1362,17 @@ def test_ci_selection_treats_shared_crate_changes_as_all_plugins(self) -> None: def test_ci_selection_field_prints_json_and_bool_scalars(self) -> None: result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "plugins") self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(json.loads(result.stdout), ["pii_filter", "rate_limiter"]) + self.assertEqual( + json.loads(result.stdout), + [ + "encoded_exfil_detection", + "pii_filter", + "rate_limiter", + "retry_with_backoff", + "secrets_detection", + "url_reputation", + ], + ) result = run_catalog("ci-selection-field", str(REPO_ROOT), "all", "", "", "has_plugins") self.assertEqual(result.returncode, 0, result.stderr) @@ -1470,7 +1495,14 @@ def test_validator_rejects_maturin_python_source_drift(self) -> None: self.assertIn("python-source", result.stderr) def test_plugin_makefiles_expose_ci_targets(self) -> None: - for slug in ("rate_limiter", "pii_filter"): + for slug in ( + "encoded_exfil_detection", + "pii_filter", + "rate_limiter", + "retry_with_backoff", + "secrets_detection", + "url_reputation", + ): makefile = ( REPO_ROOT / "plugins" @@ -1480,8 +1512,6 @@ def test_plugin_makefiles_expose_ci_targets(self) -> None: / "Makefile" ) text = makefile.read_text() - self.assertRegex(text, r"(?m)^\.PHONY:.*\bbench-no-run\b") - self.assertRegex(text, r"(?m)^bench-no-run:") self.assertRegex(text, r"(?m)^\.PHONY:.*\binstall-wheel\b") self.assertRegex(text, r"(?m)^install-wheel:") self.assertRegex(text, r"(?m)^\.PHONY:.*\bci\b") @@ -1489,6 +1519,20 @@ def test_plugin_makefiles_expose_ci_targets(self) -> None: self.assertNotRegex(text, r"(?m)^ci:.*(?:^|\s)install(?:\s|$)") self.assertRegex(text, r"(?m)^ci:.*\binstall-wheel\b") + def test_existing_benchmark_plugins_keep_bench_targets(self) -> None: + for slug in ("pii_filter", "rate_limiter"): + makefile = ( + REPO_ROOT + / "plugins" + / "rust" + / "python-package" + / slug + / "Makefile" + ) + text = makefile.read_text() + self.assertRegex(text, r"(?m)^\.PHONY:.*\bbench-no-run\b") + self.assertRegex(text, r"(?m)^bench-no-run:") + def test_ci_workflow_uses_make_targets_for_plugin_checks(self) -> None: workflow = ( REPO_ROOT / ".github" / "workflows" / "ci-rust-python-package.yaml" @@ -1555,13 +1599,13 @@ def test_release_workflow_tests_artifacts_outside_source_tree(self) -> None: self.assertEqual(workflow.count("cargo run --bin stub_gen"), 1) self.assertIn('git show-ref --verify --quiet "refs/tags/${tag}"', workflow) self.assertIn("python3 tools/plugin_catalog.py release-info .", workflow) - self.assertIn( - 'if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" || "${GITHUB_EVENT_NAME}" == "workflow_call" ]]; then', - workflow, - ) + self.assertIn('if [[ -n "${TAG_INPUT}" ]]; then', workflow) self.assertIn("workflow_call:", workflow) self.assertIn("publish_enabled:", workflow) self.assertIn('default: false', workflow) + self.assertIn('git fetch --force origin "refs/heads/main:refs/remotes/origin/main"', workflow) + self.assertIn('if git merge-base --is-ancestor "${tag_ref}" "refs/remotes/origin/main"; then', workflow) + self.assertIn("tag_on_main: ${{ steps.resolve.outputs.tag_on_main }}", workflow) self.assertIn( 'wheel_matrix="$(python3 -c \'import json; print(json.dumps([{', workflow, @@ -1583,7 +1627,7 @@ def test_release_workflow_tests_artifacts_outside_source_tree(self) -> None: self.assertIn("runs-on: ${{ matrix.runner }}", workflow) self.assertIn("name: wheel-${{ matrix.platform }}", workflow) self.assertIn( - "if: ${{ github.event_name != 'workflow_call' || inputs.publish_enabled }}", + "if: ${{ (github.event_name != 'workflow_call' || inputs.publish_enabled) && (needs.resolve.outputs.publish_env != 'pypi' || needs.resolve.outputs.tag_on_main == 'true') }}", workflow, ) self.assertNotIn("matrix.", preflight_section)