diff --git a/.github/dependabot.yml b/.github/dependabot.yml index aeae2c5..b3c2494 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,63 +1,74 @@ -# 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 NuGet dependencies up to date - package-ecosystem: "nuget" directory: "/ZeroBounceSDK/" schedule: - interval: "weekly" - day: "monday" - target-branch: "master" + interval: weekly + day: tuesday + time: "06:00" + timezone: Europe/Bucharest + open-pull-requests-limit: 0 labels: + - "dependabot-security" - "nuget dependencies" groups: - nuget: - applies-to: version-updates + nuget-sdk-security: + applies-to: security-updates patterns: - "*" - package-ecosystem: "nuget" directory: "/ZeroBounceTests/" schedule: - interval: "weekly" - day: "monday" - target-branch: "master" + interval: weekly + day: tuesday + time: "06:00" + timezone: Europe/Bucharest + open-pull-requests-limit: 0 labels: + - "dependabot-security" - "nuget dependencies" groups: - nuget: - applies-to: version-updates + nuget-tests-security: + applies-to: security-updates patterns: - "*" - package-ecosystem: "nuget" directory: "/ZeroBounceSample/" schedule: - interval: "weekly" - day: "monday" - target-branch: "master" + interval: weekly + day: tuesday + time: "06:00" + timezone: Europe/Bucharest + open-pull-requests-limit: 0 labels: + - "dependabot-security" - "nuget dependencies" groups: - nuget: - applies-to: version-updates + nuget-sample-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: "master" + 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 1e8dbe7..99b6493 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 b971564..fea3865 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,14 +13,27 @@ 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: refs/tags/${{ github.event.inputs.tag }} + persist-credentials: false - name: Verify tag exists run: | TAG="${{ github.event.inputs.tag }}" @@ -28,11 +41,18 @@ 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 - echo "Publishing tag: $TAG" + + - 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" publish: name: Test, pack and publish to NuGet @@ -45,12 +65,12 @@ 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 .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: "8.0.x" @@ -69,7 +89,7 @@ jobs: dotnet pack ZeroBounceSDK/ZeroBounceSDK.csproj -c Release --no-build - name: NuGet login (trusted publishing) - uses: NuGet/login@v1 + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 id: login with: user: ${{ secrets.NUGET_USER || 'ZeroBounceIntegrations' }} @@ -97,28 +117,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 57f6f56..44dd41b 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 .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: "8.0.x" diff --git a/Dockerfile b/Dockerfile index 2adf5cc..4e584e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,4 +2,7 @@ # Mount the repo at /app (see sdk-docs/docker-compose.yml). Do not rely on COPY for local workflows. FROM mcr.microsoft.com/dotnet/sdk:8.0 WORKDIR /app + +USER 65532:65532 + CMD ["dotnet", "test", "ZeroBounceTests/ZeroBounceTests.csproj", "-v", "normal"] diff --git a/ZeroBounceSDK/ZeroBounce.cs b/ZeroBounceSDK/ZeroBounce.cs index bc0cea0..7fc1496 100644 --- a/ZeroBounceSDK/ZeroBounce.cs +++ b/ZeroBounceSDK/ZeroBounce.cs @@ -42,6 +42,23 @@ private static void RequireHttpsUrl(string url) } } + private static string Enc(string value) => Uri.EscapeDataString(value ?? ""); + + private static string RedactApiKey(string message, string apiKey) + { + if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(message)) + return message; + return message.Replace(apiKey, "***"); + } + + private static string SafeMultipartFileName(string fileName) + { + var baseName = Path.GetFileName(fileName ?? "upload.csv"); + if (string.IsNullOrWhiteSpace(baseName)) + baseName = "upload.csv"; + return baseName.Replace("\"", "").Replace("\r", "").Replace("\n", ""); + } + /// /// Initialize the SDK with your API key. Uses default API base URL. /// @@ -89,7 +106,7 @@ public void Validate(string email, string ipAddress, Action if (InvalidApiKey(failureCallback)) return; _sendRequest( - _apiBaseUrl + "/validate?api_key=" + _apiKey + "&email=" + email + "&ip_address=" + (ipAddress ?? ""), + _apiBaseUrl + "/validate?api_key=" + Enc(_apiKey) + "&email=" + Enc(email) + "&ip_address=" + Enc(ipAddress ?? ""), successCallback, failureCallback).Wait(); } @@ -132,7 +149,7 @@ public void GetCredits(Action successCallback, Action { if (InvalidApiKey(failureCallback)) return; - _sendRequest(_apiBaseUrl + "/getcredits?api_key=" + _apiKey, + _sendRequest(_apiBaseUrl + "/getcredits?api_key=" + Enc(_apiKey), successCallback, failureCallback).Wait(); } @@ -146,9 +163,9 @@ public void GetApiUsage( if (InvalidApiKey(failureCallback)) return; _sendRequest( - _apiBaseUrl + "/getapiusage?api_key=" + _apiKey - + "&start_date=" + startDate.ToString("yyyy-MM-dd") - + "&end_date=" + endDate.ToString("yyyy-MM-dd"), + _apiBaseUrl + "/getapiusage?api_key=" + Enc(_apiKey) + + "&start_date=" + Enc(startDate.ToString("yyyy-MM-dd")) + + "&end_date=" + Enc(endDate.ToString("yyyy-MM-dd")), successCallback, failureCallback).Wait(); } @@ -160,8 +177,8 @@ public void GetActivity(string email, Action successCallback if (InvalidApiKey(failureCallback)) return; _sendRequest( - _apiBaseUrl + "/activity?api_key=" + _apiKey - + "&email=" + email, + _apiBaseUrl + "/activity?api_key=" + Enc(_apiKey) + + "&email=" + Enc(email), successCallback, failureCallback).Wait(); } @@ -249,21 +266,21 @@ private void _FindEmail( return; } - string url = _apiBaseUrl + "/guessformat?api_key=" + _apiKey; + string url = _apiBaseUrl + "/guessformat?api_key=" + Enc(_apiKey); if (domain != null) { - url += "&domain=" + (domain ?? ""); + url += "&domain=" + Enc(domain ?? ""); } if (companyName != null) { - url += "&company_name=" + (companyName ?? ""); + url += "&company_name=" + Enc(companyName ?? ""); } if (firstName != null) { - url += "&first_name=" + (firstName ?? ""); + url += "&first_name=" + Enc(firstName ?? ""); } if (middleName != null) { - url += "&middle_name=" + (middleName ?? ""); + url += "&middle_name=" + Enc(middleName ?? ""); } if (lastName != null) { - url += "&last_name=" + (lastName ?? ""); + url += "&last_name=" + Enc(lastName ?? ""); } _sendRequest( @@ -308,12 +325,12 @@ private void _FindDomain( return; } - string url = _apiBaseUrl + "/guessformat?api_key=" + _apiKey; + string url = _apiBaseUrl + "/guessformat?api_key=" + Enc(_apiKey); if (domain != null) { - url += "&domain=" + (domain ?? ""); + url += "&domain=" + Enc(domain ?? ""); } if (companyName != null) { - url += "&company_name=" + (companyName ?? ""); + url += "&company_name=" + Enc(companyName ?? ""); } _sendRequest( @@ -336,11 +353,11 @@ public void EmailFinder( if (InvalidApiKey(failureCallback)) return; _sendRequest( - _apiBaseUrl + "/guessformat?api_key=" + _apiKey - + "&domain=" + (domain ?? "") - + "&first_name=" + (firstName ?? "") - + "&middle_name=" + (middleName ?? "") - + "&last_name=" + (lastName ?? ""), + _apiBaseUrl + "/guessformat?api_key=" + Enc(_apiKey) + + "&domain=" + Enc(domain ?? "") + + "&first_name=" + Enc(firstName ?? "") + + "&middle_name=" + Enc(middleName ?? "") + + "&last_name=" + Enc(lastName ?? ""), successCallback, failureCallback).Wait(); } @@ -374,7 +391,7 @@ private void _FileStatus(bool scoring, string fileId, } _sendRequest( - BulkApiBaseUrl + (scoring ? "/scoring" : "") + "/filestatus?api_key=" + _apiKey + "&file_id=" + fileId, + BulkApiBaseUrl + (scoring ? "/scoring" : "") + "/filestatus?api_key=" + Enc(_apiKey) + "&file_id=" + Enc(fileId), successCallback, failureCallback).Wait(); } @@ -423,7 +440,7 @@ public void ScoringSendFile(string filePath, SendFileOptions options, } catch (Exception e) { - failureCallback(e.Message); + failureCallback(RedactApiKey(e.Message, _apiKey)); } } @@ -456,7 +473,7 @@ public void SendFile(string filePath, SendFileOptions options, } catch (Exception e) { - failureCallback(e.Message); + failureCallback(RedactApiKey(e.Message, _apiKey)); } } @@ -479,7 +496,7 @@ private async Task _SendFile(bool scoring, Stream file, string fileName, SendFil try { var content = new MultipartFormDataContent(); - content.Add(new StreamContent(file), "file", fileName); + content.Add(new StreamContent(file), "file", SafeMultipartFileName(fileName)); content.Add(new StringContent(_apiKey), "api_key"); @@ -512,7 +529,7 @@ private async Task _SendFile(bool scoring, Stream file, string fileName, SendFil } catch (Exception e) { - failureCallback(e.Message); + failureCallback(RedactApiKey(e.Message, _apiKey)); } } @@ -569,7 +586,7 @@ private static string DownloadTypeToQueryValue(ZBDownloadType t) private static string BuildGetFileUrl(string bulkBase, bool scoring, string apiKey, string fileId, GetFileOptions options) { - var url = bulkBase + (scoring ? "/scoring" : "") + "/getfile?api_key=" + apiKey + "&file_id=" + fileId; + var url = bulkBase + (scoring ? "/scoring" : "") + "/getfile?api_key=" + Enc(apiKey) + "&file_id=" + Enc(fileId); if (options == null) return url; if (options.DownloadType.HasValue) url += "&download_type=" + DownloadTypeToQueryValue(options.DownloadType.Value); @@ -640,7 +657,7 @@ private async Task _GetFile(bool scoring, string fileId, string localDownloadPat } catch (Exception e) { - failureCallback(e.Message); + failureCallback(RedactApiKey(e.Message, _apiKey)); } } @@ -669,7 +686,7 @@ private void _DeleteFile(bool scoring, string fileId, Action