From fe482ee2ddf81e196e98f267e4635dab32f79a41 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:01:33 +0400 Subject: [PATCH] Security audit 2026-10-14: CI hardening and Dependabot security-only. Pin GitHub Actions, harden publish/checkout workflows, and configure weekly vulnerability-only Dependabot PRs with auto-merge (Europe/Bucharest). Co-authored-by: Cursor --- .github/dependabot.yml | 21 ++++--- .github/workflows/auto_assign_ci.yaml | 3 +- .github/workflows/codeql.yml | 10 +-- .github/workflows/dependabot-auto-merge.yml | 50 +++++++++++++++ .github/workflows/publish.yml | 45 ++++++++++---- .github/workflows/sdk_ci.yml | 4 +- CMakeLists.txt | 6 +- Dockerfile | 11 +++- include/ZeroBounce/ZeroBounce.h | 10 +-- src/ZBDomainSearchResponse.cpp | 3 +- src/ZeroBounce.cpp | 67 +++++++++++++-------- 11 files changed, 166 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/dependabot-auto-merge.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ba0b4b0..b3f5fb4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,20 +1,23 @@ -# 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 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 8a00ecc..92678a8 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,7 +100,7 @@ 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}}" output: sarif-results @@ -108,7 +108,7 @@ jobs: # Exclude alerts in fetched dependencies (e.g. curl parsedate.c) from code scanning results - name: Filter dependency alerts from SARIF - uses: advanced-security/filter-sarif@v1 + uses: advanced-security/filter-sarif@f3b8118a9349d88f7b1c0c488476411145b6270d # v1.0.1 with: patterns: | -**/curl-src/** @@ -116,7 +116,7 @@ jobs: output: sarif-results/cpp.sarif - name: Upload SARIF - uses: github/codeql-action/upload-sarif@v4.37.8 + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: sarif_file: sarif-results 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 fbf3b22..92c426f 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 project 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: CMakeLists.txt version ($PKG_VERSION) does not match tag ($TAG)." exit 1 fi - echo "Publishing tag: $TAG (ZeroBounce C++ SDK $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,10 +72,10 @@ 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: Install build dependencies run: | sudo apt-get update @@ -69,28 +89,29 @@ jobs: release: name: Create GitHub Release - needs: [test] + needs: [validate-tag, test] 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 a99be16..2511340 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -15,7 +15,9 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install build dependencies run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index 700a4cb..5bd558f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,8 @@ cmake_minimum_required(VERSION 3.20) +if(POLICY CMP0026) + cmake_policy(SET CMP0026 NEW) +endif() +set(CMAKE_POLICY_VERSION_MINIMUM 3.5) project(ZeroBounce VERSION 2.1.4 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) @@ -14,7 +18,7 @@ include(FetchContent) FetchContent_Declare( cpr GIT_REPOSITORY https://github.com/libcpr/cpr.git - GIT_TAG 1.10.2 + GIT_TAG 1.11.2 ) # Download and build nlohmann_json FetchContent_Declare( diff --git a/Dockerfile b/Dockerfile index 9772c2a..a7d68d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,13 @@ WORKDIR /app COPY . . -RUN cmake -S . -B build -G "Unix Makefiles" \ - && cmake --build build +RUN cmake -S . -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release \ + && cmake --build build \ + && ctest --test-dir build --output-on-failure + +RUN useradd --uid 65532 --create-home --shell /usr/sbin/nologin zbtest \ + && chown -R zbtest:zbtest /app + +USER zbtest -# Run tests via CTest CMD ["ctest", "--test-dir", "build", "--output-on-failure"] diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index caca8b0..430c9d9 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -95,19 +95,19 @@ class BaseRequestHandler { class RequestHandler : public BaseRequestHandler { protected: cpr::Response doGet(const cpr::Url& url) { - return cpr::Get(url, cpr::Timeout{120000}); + return cpr::Get(url, cpr::Timeout{120000}, cpr::Redirect{false}); } cpr::Response doGet(const cpr::Url& url, const cpr::Header& header) { - return cpr::Get(url, header, cpr::Timeout{120000}); + return cpr::Get(url, header, cpr::Timeout{120000}, cpr::Redirect{false}); } cpr::Response doGetWithParameters(const cpr::Url& url, const cpr::Parameters& parameters) { - return cpr::Get(url, parameters, cpr::Timeout{120000}); + return cpr::Get(url, parameters, cpr::Timeout{120000}, cpr::Redirect{false}); } cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) { - return cpr::Post(url, header, body, cpr::Timeout{120000}); + return cpr::Post(url, header, body, cpr::Timeout{120000}, cpr::Redirect{false}); } cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Multipart& multipart) { - return cpr::Post(url, header, multipart, cpr::Timeout{120000}); + return cpr::Post(url, header, multipart, cpr::Timeout{120000}, cpr::Redirect{false}); } }; diff --git a/src/ZBDomainSearchResponse.cpp b/src/ZBDomainSearchResponse.cpp index 4148239..29c3ffe 100644 --- a/src/ZBDomainSearchResponse.cpp +++ b/src/ZBDomainSearchResponse.cpp @@ -53,7 +53,8 @@ ZBDomainSearchResponse ZBDomainSearchResponse::from_json(const json& json_obj) { response.failureReason = getOrDefault(json_obj, "failure_reason", ""); response.otherDomainFormats = std::vector(); - const json domainFormats = json_obj["other_domain_formats"]; + const json domainFormats = json_obj.contains("other_domain_formats") + ? json_obj["other_domain_formats"] : json::array(); if (domainFormats.is_array()) { for (int index = 0; index < domainFormats.size(); index ++) { response.otherDomainFormats.push_back( diff --git a/src/ZeroBounce.cpp b/src/ZeroBounce.cpp index 12436c0..f4eeab4 100644 --- a/src/ZeroBounce.cpp +++ b/src/ZeroBounce.cpp @@ -6,12 +6,23 @@ #include #include +#include #include "ZeroBounce/ZeroBounce.h" namespace fs = std::filesystem; using json = nlohmann::json; +static std::string urlEncode(const std::string& value) { + char* escaped = curl_easy_escape(nullptr, value.c_str(), static_cast(value.size())); + if (!escaped) { + throw std::bad_alloc(); + } + std::string result(escaped); + curl_free(escaped); + return result; +} + ZeroBounce::ZeroBounce() { defaultRequestHandler = std::make_unique(); requestHandler = defaultRequestHandler.get(); @@ -138,7 +149,7 @@ void ZeroBounce::getCredits( if (invalidApiKey(errorCallback)) return; sendRequest( - apiBaseUrl + "/getcredits?api_key=" + apiKey, + apiBaseUrl + "/getcredits?api_key=" + urlEncode(apiKey), successCallback, errorCallback ); @@ -156,9 +167,13 @@ void ZeroBounce::getApiUsage( std::string dateFormat = "%Y-%m-%d"; std::ostringstream url; - url << apiBaseUrl << "/getapiusage?api_key=" << apiKey - << "&start_date=" << std::put_time(&startDate, dateFormat.c_str()) - << "&end_date=" << std::put_time(&endDate, dateFormat.c_str()); + std::ostringstream startStr; + std::ostringstream endStr; + startStr << std::put_time(&startDate, dateFormat.c_str()); + endStr << std::put_time(&endDate, dateFormat.c_str()); + url << apiBaseUrl << "/getapiusage?api_key=" << urlEncode(apiKey) + << "&start_date=" << urlEncode(startStr.str()) + << "&end_date=" << urlEncode(endStr.str()); sendRequest( url.str(), @@ -176,9 +191,9 @@ void ZeroBounce::validate( if (invalidApiKey(errorCallback)) return; sendRequest( - apiBaseUrl + "/validate?api_key=" + apiKey + - "&email=" + email + - "&ip_address=" + (ipAddress.empty() ? "" : ipAddress), + apiBaseUrl + "/validate?api_key=" + urlEncode(apiKey) + + "&email=" + urlEncode(email) + + "&ip_address=" + (ipAddress.empty() ? "" : urlEncode(ipAddress)), successCallback, errorCallback ); @@ -334,7 +349,7 @@ void ZeroBounce::getActivityData( if (invalidApiKey(errorCallback)) return; sendRequest( - apiBaseUrl + "/activity?api_key=" + apiKey + "&email=" + email, + apiBaseUrl + "/activity?api_key=" + urlEncode(apiKey) + "&email=" + urlEncode(email), successCallback, errorCallback ); @@ -450,8 +465,8 @@ void ZeroBounce::fileStatusInternal( if (invalidApiKey(errorCallback)) return; sendRequest( - (scoring ? bulkApiScoringBaseUrl : bulkApiBaseUrl) + "/filestatus?api_key=" + apiKey - + "&file_id=" + fileId, + (scoring ? bulkApiScoringBaseUrl : bulkApiBaseUrl) + "/filestatus?api_key=" + urlEncode(apiKey) + + "&file_id=" + urlEncode(fileId), successCallback, errorCallback ); @@ -539,8 +554,8 @@ void ZeroBounce::deleteFileInternal( if (invalidApiKey(errorCallback)) return; sendRequest( - (scoring ? bulkApiScoringBaseUrl : bulkApiBaseUrl) + "/deletefile?api_key=" + apiKey - + "&file_id=" + fileId, + (scoring ? bulkApiScoringBaseUrl : bulkApiBaseUrl) + "/deletefile?api_key=" + urlEncode(apiKey) + + "&file_id=" + urlEncode(fileId), successCallback, errorCallback ); @@ -556,21 +571,21 @@ void ZeroBounce::findEmailInternal( OnErrorCallback errorCallback ) { std::stringstream urlStream; - urlStream << apiBaseUrl << "/guessformat?api_key=" << apiKey; + urlStream << apiBaseUrl << "/guessformat?api_key=" << urlEncode(apiKey); if (domain.size() > 0) { - urlStream << "&domain=" << domain; + urlStream << "&domain=" << urlEncode(domain); } if (company_name.size() > 0) { - urlStream << "&company_name=" << company_name; + urlStream << "&company_name=" << urlEncode(company_name); } if (first_name.size() > 0) { - urlStream << "&first_name=" << first_name; + urlStream << "&first_name=" << urlEncode(first_name); } if (middle_name.size() > 0) { - urlStream << "&middle_name=" << middle_name; + urlStream << "&middle_name=" << urlEncode(middle_name); } if (last_name.size() > 0) { - urlStream << "&last_name=" << last_name; + urlStream << "&last_name=" << urlEncode(last_name); } sendRequest(urlStream.str(), successCallback, errorCallback); } @@ -642,12 +657,12 @@ void ZeroBounce::searchDomainInternal( OnErrorCallback errorCallback ) { std::stringstream urlStream; - urlStream << apiBaseUrl << "/guessformat?api_key=" << apiKey; + urlStream << apiBaseUrl << "/guessformat?api_key=" << urlEncode(apiKey); if (domain.size() > 0) { - urlStream << "&domain=" << domain; + urlStream << "&domain=" << urlEncode(domain); } if (company_name.size() > 0) { - urlStream << "&company_name=" << company_name; + urlStream << "&company_name=" << urlEncode(company_name); } sendRequest(urlStream.str(), successCallback, errorCallback); } @@ -677,18 +692,18 @@ void ZeroBounce::findEmail( OnErrorCallback errorCallback ) { std::stringstream urlStream; - urlStream << apiBaseUrl << "/guessformat?api_key=" << apiKey; + urlStream << apiBaseUrl << "/guessformat?api_key=" << urlEncode(apiKey); if (domain.size() > 0) { - urlStream << "&domain=" << domain; + urlStream << "&domain=" << urlEncode(domain); } if (first_name.size() > 0) { - urlStream << "&first_name=" << first_name; + urlStream << "&first_name=" << urlEncode(first_name); } if (middle_name.size() > 0) { - urlStream << "&middle_name=" << middle_name; + urlStream << "&middle_name=" << urlEncode(middle_name); } if (last_name.size() > 0) { - urlStream << "&last_name=" << last_name; + urlStream << "&last_name=" << urlEncode(last_name); } sendRequest(urlStream.str(), successCallback, errorCallback); }