diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ae0f708..dbcc8bd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,35 +1,40 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#versioning-strategy +# Dependabot: security updates only (no version-update PRs). +# Grouped security PRs open weekly on Tuesday morning (Europe/Bucharest). +# Auto-merge: .github/workflows/dependabot-auto-merge.yml (on PR + Tuesday cron). +# https://docs.github.com/en/code-security/dependabot/dependabot-security-updates version: 2 updates: - # Keep the Cargo dependencies up to date - package-ecosystem: "cargo" directory: "/" schedule: - interval: "weekly" - day: "monday" - target-branch: "main" + interval: weekly + day: tuesday + time: "06:00" + timezone: Europe/Bucharest + open-pull-requests-limit: 0 labels: + - "dependabot-security" - "rust dependencies" groups: - cargo: - applies-to: version-updates + cargo-security: + applies-to: security-updates patterns: - "*" - # Keep the Github Actions dependencies up to date - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" - day: "monday" - target-branch: "main" + interval: weekly + day: tuesday + time: "06:00" + timezone: Europe/Bucharest + open-pull-requests-limit: 0 labels: + - "dependabot-security" - "github dependencies" groups: - github: - applies-to: version-updates + github-actions-security: + applies-to: security-updates patterns: - "*" diff --git a/.github/workflows/auto_assign_ci.yaml b/.github/workflows/auto_assign_ci.yaml index 0e6c515..59aa47c 100644 --- a/.github/workflows/auto_assign_ci.yaml +++ b/.github/workflows/auto_assign_ci.yaml @@ -1,4 +1,5 @@ name: Auto Assign +# Audit 2026-10-14: pull_request_target — do not checkout the PR head here; configuration-path only. on: pull_request_target: @@ -16,6 +17,6 @@ jobs: name: Auto assign (me only) runs-on: ubuntu-latest steps: - - uses: kentaro-m/auto-assign-action@v2.0.2 + - uses: kentaro-m/auto-assign-action@0a2c53d3721e4c5cfcce6afd4014aecc337979f6 # v2.0.2 with: configuration-path: ".github/auto_assign.yml" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 06307a8..9acb64c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -61,7 +61,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` @@ -71,7 +71,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.8 + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -100,6 +100,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.8 + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..138a59d --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,50 @@ +name: Dependabot security auto-merge + +on: + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: "0 6 * * 2" + timezone: Europe/Bucharest + +permissions: + contents: write + pull-requests: write + +jobs: + approve-and-queue-merge: + runs-on: ubuntu-latest + if: >- + github.event_name == 'pull_request' + && github.event.pull_request.user.login == 'dependabot[bot]' + steps: + - name: Approve Dependabot pull request + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge when checks pass + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + weekly-merge-ready: + runs-on: ubuntu-latest + if: github.event_name == 'schedule' + steps: + - name: Approve and queue merge for open Dependabot PRs + run: | + set -euo pipefail + mapfile -t urls < <(gh pr list --author app/dependabot --state open --json url --jq '.[].url') + if [ "${#urls[@]}" -eq 0 ]; then + echo "No open Dependabot pull requests." + exit 0 + fi + for url in "${urls[@]}"; do + gh pr review --approve "$url" || true + gh pr merge --auto --merge "$url" || true + done + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c72a2ad..799635c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,15 +13,28 @@ permissions: jobs: validate-tag: + outputs: + tag_sha: ${{ steps.tag_sha.outputs.sha }} name: Validate tag runs-on: ubuntu-latest steps: + - name: Validate tag input + env: + TAG: ${{ github.event.inputs.tag }} + run: | + re='^v?[0-9]+\.[0-9]+\.[0-9]+([.+-][0-9A-Za-z.-]+)?$' + if [[ ! "$TAG" =~ $re ]]; then + echo "Error: the tag input must be a release tag such as v1.2.3." + exit 1 + fi + - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - ref: ${{ github.event.inputs.tag }} + ref: refs/tags/${{ github.event.inputs.tag }} + persist-credentials: false - name: Verify tag and crate version run: | TAG="${{ github.event.inputs.tag }}" @@ -29,7 +42,7 @@ jobs: echo "Error: Tag is required." exit 1 fi - if ! git rev-parse "$TAG" >/dev/null 2>&1; then + if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then echo "Error: Tag '$TAG' does not exist." exit 1 fi @@ -43,7 +56,14 @@ jobs: echo "Error: Cargo.toml version ($PKG_VERSION) does not match tag ($TAG)." exit 1 fi - echo "Publishing tag: $TAG (zero-bounce@$PKG_VERSION)" + + - name: Record tag commit SHA + id: tag_sha + env: + TAG: ${{ github.event.inputs.tag }} + run: | + SHA=$(git rev-parse "refs/tags/$TAG^{commit}") + echo "sha=$SHA" >> "$GITHUB_OUTPUT" test: name: Test @@ -52,19 +72,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout tag - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.inputs.tag }} - + ref: ${{ needs.validate-tag.outputs.tag_sha }} + persist-credentials: false - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - name: Run tests run: cargo test --no-fail-fast publish: name: Publish to crates.io - needs: [test] + needs: [validate-tag, test] if: ${{ !cancelled() && !failure() }} runs-on: ubuntu-latest environment: release @@ -73,15 +93,15 @@ jobs: contents: read steps: - name: Checkout tag - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.inputs.tag }} - + ref: ${{ needs.validate-tag.outputs.tag_sha }} + persist-credentials: false - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - name: Authenticate with crates.io - uses: rust-lang/crates-io-auth-action@v1 + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1 id: auth - name: Publish to crates.io @@ -91,28 +111,29 @@ jobs: release: name: Create GitHub Release - needs: [publish] + needs: [validate-tag, publish] if: ${{ !cancelled() && !failure() }} runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.inputs.tag }} + ref: ${{ needs.validate-tag.outputs.tag_sha }} fetch-depth: 0 + persist-credentials: false - name: Create release if missing env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${{ github.event.inputs.tag }}" - if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "Release $TAG already exists, skipping." else gh release create "$TAG" \ - --repo "${{ github.repository }}" \ + --repo "$GITHUB_REPOSITORY" \ --title "$TAG" \ --generate-notes fi diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml index 4cf671e..f8212e6 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -15,10 +15,12 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: components: rustfmt, clippy diff --git a/Dockerfile b/Dockerfile index 9bd67f6..ba3c06a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,4 +7,7 @@ WORKDIR /app COPY . . # Full test suite (unit + integration; network allowed in container) + +USER 65532:65532 + CMD ["cargo", "test", "--no-fail-fast"] diff --git a/src/api/bulk.rs b/src/api/bulk.rs index 675c220..a711423 100644 --- a/src/api/bulk.rs +++ b/src/api/bulk.rs @@ -31,12 +31,6 @@ impl ZeroBounce { let response_ok = response.status().is_success(); let response_content = response.text()?; - // Debug: Print raw response to examine structure in debug mode - #[cfg(debug_assertions)] - { - eprintln!("Raw API response: {}", response_content); - } - if !response_ok { return Err(ZBError::ExplicitError(response_content)); } @@ -93,12 +87,10 @@ impl ZeroBounce { .to_string(); let body_bytes = response.bytes()?; - let body_str = String::from_utf8_lossy(&body_bytes).into_owned(); - - #[cfg(debug_assertions)] - if !status.is_success() || should_treat_get_file_body_as_error(&body_str, &content_type) { - eprintln!("Raw API response: {}", body_str); + if body_bytes.len() > crate::utility::MAX_GETFILE_BYTES { + return Err(ZBError::ExplicitError("getfile response exceeds size limit".into())); } + let body_str = String::from_utf8_lossy(&body_bytes).into_owned(); if !status.is_success() { let msg = if body_str.trim_start().starts_with('{') { diff --git a/src/api/validation.rs b/src/api/validation.rs index e3ea75b..d85a5a0 100644 --- a/src/api/validation.rs +++ b/src/api/validation.rs @@ -90,11 +90,6 @@ impl ZeroBounce { let response_content = response.text()?; // Debug: Print raw response to examine structure in debug mode - #[cfg(debug_assertions)] - { - eprintln!("Raw API response: {}", response_content); - } - if !response_ok { return Err(ZBError::ExplicitError(response_content)); } diff --git a/src/lib.rs b/src/lib.rs index 2daa096..86a493b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,6 +112,9 @@ impl ZeroBounce { let response_ok = response.status().is_success(); let response_content = response.text()?; + if response_content.len() > utility::MAX_JSON_RESPONSE_BYTES { + return Err(ZBError::ExplicitError("response body exceeds size limit".into())); + } if !response_ok { return Err(ZBError::ExplicitError(response_content)); @@ -126,11 +129,16 @@ fn require_https_url(url: String) -> String { if lower.starts_with("https://") { return url; } - // mockito and local tests use loopback HTTP - if lower.starts_with("http://127.0.0.1") - || lower.starts_with("http://localhost") - || lower.starts_with("http://[::1]") - { + let is_loopback_http = reqwest::Url::parse(&url) + .map(|parsed| { + parsed.scheme() == "http" + && matches!( + parsed.host_str(), + Some("127.0.0.1") | Some("localhost") | Some("[::1]") + ) + }) + .unwrap_or(false); + if is_loopback_http { return url; } panic!("base_url must be an https:// URL"); @@ -161,4 +169,16 @@ mod security_tests { fn rejects_remote_http() { require_https_url("http://evil.example/v2/".to_string()); } + + #[test] + #[should_panic(expected = "https://")] + fn rejects_loopback_lookalike_host() { + require_https_url("http://localhost.evil.example/v2/".to_string()); + } + + #[test] + #[should_panic(expected = "https://")] + fn rejects_loopback_userinfo_trick() { + require_https_url("http://127.0.0.1@evil.example/v2/".to_string()); + } } diff --git a/src/utility/mod.rs b/src/utility/mod.rs index be59591..9f6f1a3 100644 --- a/src/utility/mod.rs +++ b/src/utility/mod.rs @@ -8,6 +8,10 @@ pub use bulk_get_file::{format_get_file_error_message, get_file_json_indicates_e pub const CONTENT_TYPE_JSON: &str = "application/json"; pub const CONTENT_TYPE_STREAM: &str = "application/octet-stream"; +/// Cap JSON API responses so error pages cannot exhaust memory (RS-05). +pub const MAX_JSON_RESPONSE_BYTES: usize = 16 * 1024 * 1024; +/// Cap bulk getfile downloads (still large enough for typical CSV exports). +pub const MAX_GETFILE_BYTES: usize = 256 * 1024 * 1024; /// Enum representing the available API base URLs #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -116,8 +120,29 @@ impl ZBError { // // This automatic conversion is expected when using the client // and issuing http requests. +fn redact_api_key(url: &mut reqwest::Url) { + if !url.query_pairs().any(|(key, _)| key == "api_key") { + return; + } + let pairs: Vec<(String, String)> = url + .query_pairs() + .map(|(key, value)| { + let value = if key == "api_key" { + "REDACTED".to_string() + } else { + value.into_owned() + }; + (key.into_owned(), value) + }) + .collect(); + url.query_pairs_mut().clear().extend_pairs(pairs); +} + impl From for ZBError { - fn from(value: reqwest::Error) -> ZBError { + fn from(mut value: reqwest::Error) -> ZBError { + if let Some(url) = value.url_mut() { + redact_api_key(url); + } ZBError::RequestError(value) } }