From cfba47d4e17a910b660d4b3170db5f99301447f4 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:01:08 +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 | 35 ++++++++------- .github/workflows/auto_assign_ci.yaml | 3 +- .github/workflows/codeql.yml | 6 +-- .github/workflows/dependabot-auto-merge.yml | 50 +++++++++++++++++++++ .github/workflows/publish.yml | 47 +++++++++++++------ .github/workflows/sdk_ci.yml | 6 ++- Dockerfile | 7 ++- src/ZeroBounce.php | 42 +++++++++++------ 8 files changed, 146 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/dependabot-auto-merge.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b298580..5f8b954 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 Composer dependencies up to date - package-ecosystem: "composer" 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" - "php dependencies" groups: - composer: - applies-to: version-updates + composer-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 0ff65d5..a34de62 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 ac44299..49c633f 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 composer 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 @@ -39,7 +52,14 @@ jobs: echo "Error: composer.json version ($PKG_VERSION) does not match tag ($TAG)." exit 1 fi - echo "Publishing tag: $TAG (zero-bounce/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 @@ -48,12 +68,12 @@ 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 PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2 with: php-version: "8.2" @@ -65,28 +85,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 12c83ce..d3af245 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 PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2 with: php-version: "8.2" diff --git a/Dockerfile b/Dockerfile index dd8b6e3..c34b0fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ # ZeroBounce PHP SDK – test image (PHP 8.2 + Composer) FROM php:8.2-cli-alpine -RUN apk add --no-cache git unzip && \ - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer +RUN apk add --no-cache git unzip + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app @@ -10,4 +11,6 @@ COPY . . RUN composer install --no-interaction --no-progress +USER 65532:65532 + CMD ["vendor/bin/phpunit", "--colors=always"] diff --git a/src/ZeroBounce.php b/src/ZeroBounce.php index d3f6d99..b36c9df 100644 --- a/src/ZeroBounce.php +++ b/src/ZeroBounce.php @@ -60,7 +60,8 @@ public function validate($email, $ipAddress = NULL) $this->checkValidApiKey(); if (!$email) throw new ZBMissingParameterException("email is required"); $response = new ZBValidateResponse(); - $this->request(self::$ApiBaseUrl . "/validate?api_key=" . $this->apiKey . "&email=" . urlencode($email) . "&ip_address=" . ($ipAddress ? $ipAddress : ""), $response); + $ipPart = $ipAddress ? urlencode($ipAddress) : ""; + $this->request(self::$ApiBaseUrl . "/validate?api_key=" . urlencode($this->apiKey) . "&email=" . urlencode($email) . "&ip_address=" . $ipPart, $response); return $response; } @@ -113,7 +114,7 @@ public function getCredits() { $this->checkValidApiKey(); $response = new ZBGetCreditsResponse(); - $this->request(self::$ApiBaseUrl . "/getcredits?api_key=" . $this->apiKey, $response); + $this->request(self::$ApiBaseUrl . "/getcredits?api_key=" . urlencode($this->apiKey), $response); return $response; } @@ -133,7 +134,7 @@ public function getApiUsage($startDate, $endDate) $response = new ZBApiUsageResponse(); $format = "Y-m-d"; - $this->request(self::$ApiBaseUrl . "/getapiusage?api_key=" . $this->apiKey + $this->request(self::$ApiBaseUrl . "/getapiusage?api_key=" . urlencode($this->apiKey) . "&start_date=" . $startDate->format($format) . "&end_date=" . $endDate->format($format), $response); @@ -154,7 +155,7 @@ public function getActivity($email) $response = new ZBActivityResponse(); $format = "Y-m-d"; - $this->request(self::$ApiBaseUrl . "/activity?api_key=" . $this->apiKey + $this->request(self::$ApiBaseUrl . "/activity?api_key=" . urlencode($this->apiKey) . "&email=" . urlencode($email), $response); return $response; @@ -299,7 +300,7 @@ private function _sendFile( $rsp->Deserialize($response); return $rsp; } catch (Exception $e) { - throw new ZBException($e->getMessage()); + throw new ZBException($this->redactApiKey($e->getMessage())); } } @@ -349,7 +350,7 @@ private function _sendFileWithContent( $rsp->Deserialize($response); return $rsp; } catch (Exception $e) { - throw new ZBException($e->getMessage()); + throw new ZBException($this->redactApiKey($e->getMessage())); } } @@ -400,7 +401,7 @@ protected function curl($url, $fields, $files) { $curl = curl_init(); - $boundary = uniqid(); + $boundary = bin2hex(random_bytes(16)); $delimiter = '-------------' . $boundary; $post_data = $this->build_data_files($boundary, $fields, $files); @@ -471,8 +472,8 @@ private function _fileStatus($scoring, $fileId) $response = new ZBFileStatusResponse(); $this->request(self::BulkApiBaseUrl . ($scoring ? "/scoring" : "") - . "/filestatus?api_key=" . $this->apiKey - . "&file_id=" . $fileId, + . "/filestatus?api_key=" . urlencode($this->apiKey) + . "&file_id=" . urlencode($fileId), $response); return $response; @@ -566,10 +567,23 @@ private function _getFile($scoring, $fileId, $downloadPath, $options = null) $response->localFilePath = $downloadPath; return $response; } catch (Exception $e) { - throw new ZBException($e->getMessage()); + throw new ZBException($this->redactApiKey($e->getMessage())); } } + + /** + * Masks the API key in text that can end up in exception messages or logs. + */ + private function redactApiKey($message) + { + $message = (string) $message; + if (!$this->apiKey) { + return $message; + } + return str_replace(array($this->apiKey, urlencode($this->apiKey), rawurlencode($this->apiKey)), '***', $message); + } + /** * @param string $url * @return array{body: string, headers: string[]} @@ -749,8 +763,8 @@ public function _deleteFile($scoring, $fileId) $response = new ZBDeleteFileResponse(); $this->request(self::BulkApiBaseUrl . ($scoring ? "/scoring" : "") - . "/deletefile?api_key=" . $this->apiKey - . "&file_id=" . $fileId, + . "/deletefile?api_key=" . urlencode($this->apiKey) + . "&file_id=" . urlencode($fileId), $response); return $response; @@ -792,7 +806,7 @@ protected function request($url, $response) $response->Deserialize($json); return $code; } catch (Exception $e) { - throw new ZBException($e->getMessage()); + throw new ZBException($this->redactApiKey($e->getMessage())); } } @@ -829,7 +843,7 @@ private function build_data_files($boundary, $fields, $files) foreach ($files as $path => $content) { - $name = basename($path); + $name = str_replace(array('"', "\r", "\n"), array('%22', '%0D', '%0A'), basename($path)); $data .= "--" . $delimiter . $eol . 'Content-Disposition: form-data; name="file"; filename="' . $name . '"' . $eol //. 'Content-Type: image/png'.$eol