diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a648703..b1dd573 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,54 +1,60 @@ -# 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 Android dependencies up to date - - package-ecosystem: "gradle" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "gradle" + 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" - "android dependencies" ignore: - # CodeQL java-kotlin analysis supports Kotlin below 2.3.30 - dependency-name: "org.jetbrains.kotlin:kotlin-gradle-plugin" versions: [">= 2.3.30"] groups: - android: - applies-to: version-updates + gradle-root-security: + applies-to: security-updates patterns: - - "*" # A wildcard that matches all dependencies in the package - - # Keep the Android dependencies up to date - - package-ecosystem: "gradle" # See documentation for possible values - directory: "/zero_bounce_sdk/" # Location of package manifests + - "*" + + - package-ecosystem: "gradle" + directory: "/zero_bounce_sdk/" 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" - "android dependencies" groups: - android: - applies-to: version-updates + gradle-sdk-security: + applies-to: security-updates patterns: - - "*" # A wildcard that matches all dependencies in the package + - "*" - # Keep the Github Actions dependencies up to date - - package-ecosystem: "github-actions" # See documentation for possible values - directory: "/" # Location of package manifests + - 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: - - "*" # A wildcard that matches all dependencies in the package + - "*" 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 06957b4..d1e0c38 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -64,18 +64,18 @@ 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 - name: Set up JDK 17 if: matrix.build-mode == 'manual' - uses: actions/setup-java@v6 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 with: distribution: temurin java-version: "17" - name: Set up Android SDK if: matrix.build-mode == 'manual' - uses: android-actions/setup-android@v4 + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 with: packages: "tools platform-tools platforms;android-36 build-tools;36.0.0" @@ -87,7 +87,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.9 + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -110,6 +110,6 @@ jobs: run: ./gradlew --no-daemon :zero_bounce_sdk:assembleDebug - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.9 + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.9 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 e365e99..d7d9972 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, release build and publish @@ -41,12 +61,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 JDK 21 - uses: actions/setup-java@v6 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 with: java-version: 21 distribution: 'temurin' @@ -98,20 +118,21 @@ 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 - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: tag_name: ${{ github.event.inputs.tag }} name: ${{ github.event.inputs.tag }} diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml index f300a56..028c06c 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -15,7 +15,7 @@ jobs: name: Test & Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Execute unit tests. - name: Run unit tests diff --git a/Dockerfile b/Dockerfile index cb8dcfc..96ecdde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,7 @@ WORKDIR /app COPY . . # Run unit tests for the SDK library only (no emulator) + +USER 65532:65532 + CMD ["./gradlew", ":zero_bounce_sdk:test", "--no-daemon", "-q"] diff --git a/zero_bounce_sdk/src/main/java/com/zerobounce/android/ZeroBounceSDK.kt b/zero_bounce_sdk/src/main/java/com/zerobounce/android/ZeroBounceSDK.kt index fec2e9b..8d46a96 100644 --- a/zero_bounce_sdk/src/main/java/com/zerobounce/android/ZeroBounceSDK.kt +++ b/zero_bounce_sdk/src/main/java/com/zerobounce/android/ZeroBounceSDK.kt @@ -10,6 +10,7 @@ import com.google.gson.GsonBuilder import com.zerobounce.android.ZeroBounceSDK.apiKey import okhttp3.Call import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.OkHttpClient @@ -22,6 +23,7 @@ import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.io.InputStream +import java.net.URLEncoder import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -75,12 +77,16 @@ object ZeroBounceSDK { internal fun isAllowedApiBaseUrl(url: String): Boolean { val lower = url.lowercase() if (lower.startsWith("https://")) return true - // MockWebServer and local tests use loopback HTTP. - return lower.startsWith("http://127.0.0.1") || - lower.startsWith("http://localhost") || - lower.startsWith("http://[::1]") + if (!lower.startsWith("http://")) return false + val host = url.toHttpUrlOrNull()?.host ?: return false + return host == "localhost" || host == "127.0.0.1" || host == "::1" } + private fun enc(value: String): String = URLEncoder.encode(value, "UTF-8") + + internal fun safeFileName(raw: String?): String? = + raw?.substringAfterLast('/')?.takeIf { it.isNotBlank() && it != "." && it != ".." } + /** * Validates the given [email]. * @@ -99,7 +105,7 @@ object ZeroBounceSDK { val ipAddressPart = ipAddress ?: "" request( - url = "$apiBaseUrl/validate?api_key=$apiKey&email=$email&ip_address=$ipAddressPart", + url = "$apiBaseUrl/validate?api_key=$apiKey&email=${enc(email)}&ip_address=${enc(ipAddressPart)}", body = null, responseCallback = responseCallback, errorCallback = errorCallback @@ -161,10 +167,10 @@ object ZeroBounceSDK { ) { if (invalidApiKey(errorCallback)) return - var url = "$apiBaseUrl/guessformat?api_key=$apiKey&domain=$domain" - firstName?.let { url += "&first_name=$firstName" } - middleName?.let { url += "&middle_name=$middleName" } - lastName?.let { url += "&last_name=$lastName" } + var url = "$apiBaseUrl/guessformat?api_key=$apiKey&domain=${enc(domain)}" + firstName?.let { url += "&first_name=${enc(firstName)}" } + middleName?.let { url += "&middle_name=${enc(middleName)}" } + lastName?.let { url += "&last_name=${enc(lastName)}" } request( url = url, @@ -207,11 +213,11 @@ object ZeroBounceSDK { return } - var url = "$apiBaseUrl/guessformat?api_key=$apiKey&first_name=$firstName" - domain?.let { url += "&domain=$domain" } - companyName?.let { url += "&company_name=$companyName" } - middleName?.let { url += "&middle_name=$middleName" } - lastName?.let { url += "&last_name=$lastName" } + var url = "$apiBaseUrl/guessformat?api_key=$apiKey&first_name=${enc(firstName)}" + domain?.let { url += "&domain=${enc(domain)}" } + companyName?.let { url += "&company_name=${enc(companyName)}" } + middleName?.let { url += "&middle_name=${enc(middleName)}" } + lastName?.let { url += "&last_name=${enc(lastName)}" } request( url = url, @@ -248,8 +254,8 @@ object ZeroBounceSDK { } var url = "$apiBaseUrl/guessformat?api_key=$apiKey" - domain?.let { url += "&domain=$domain" } - companyName?.let { url += "&company_name=$companyName" } + domain?.let { url += "&domain=${enc(domain)}" } + companyName?.let { url += "&company_name=${enc(companyName)}" } request( url = url, @@ -279,7 +285,7 @@ object ZeroBounceSDK { val eDate = dateFormat.format(endDate) request( - url = "$apiBaseUrl/getapiusage?api_key=$apiKey&start_date=$sDate&end_date=$eDate", + url = "$apiBaseUrl/getapiusage?api_key=$apiKey&start_date=${enc(sDate)}&end_date=${enc(eDate)}", body = null, responseCallback = responseCallback, errorCallback = errorCallback @@ -728,7 +734,7 @@ object ZeroBounceSDK { val baseUrl = if (scoring) bulkApiScoringBaseUrl else bulkApiBaseUrl request( - url = "$baseUrl/filestatus?api_key=$apiKey&file_id=$fileId", + url = "$baseUrl/filestatus?api_key=$apiKey&file_id=${enc(fileId)}", body = null, responseCallback = responseCallback, errorCallback = errorCallback @@ -815,55 +821,49 @@ object ZeroBounceSDK { ) { if (invalidApiKey(errorCallback)) return - if (!hasPermissions(context, STORAGE_PERMISSIONS)) { - val errorResponse = ErrorResponse.parseError( - "Cannot send file, permissions $READ_EXTERNAL_STORAGE and $WRITE_EXTERNAL_STORAGE are not granted" - ) - errorCallback(errorResponse) - return - } - val baseUrl = if (scoring) bulkApiScoringBaseUrl else bulkApiBaseUrl val downloadTypeQuery = downloadType?.let { "&download_type=${it.apiValue}" } ?: "" val activityDataQuery = if (!scoring && activityData != null) "&activity_data=$activityData" else "" val requestBuilder = Request.Builder() - .url("$baseUrl/getfile?api_key=$apiKey&file_id=$fileId$downloadTypeQuery$activityDataQuery") + .url("$baseUrl/getfile?api_key=$apiKey&file_id=${enc(fileId)}$downloadTypeQuery$activityDataQuery") val request = requestBuilder.build() val call = client.newCall(request) call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { - val body = response.body - - val fileName = - response.headers["content-disposition"]?.substringAfter("filename=") - ?.substringBefore(";") - val name = fileName ?: "$fileId.csv" - val file = File(context.getExternalFilesDir(null), "/$name") - val contentType = body.contentType()?.toString().orEmpty() + response.use { + val body = it.body ?: run { + errorCallback(ErrorResponse.parseError("Empty response body")) + return + } - if (response.isSuccessful) { - try { - // getfile may return JSON errors with HTTP 200 for unsupported download_type. - if (contentType.contains("application/json", ignoreCase = true)) { - errorCallback(ErrorResponse.parseError(body.string())) - return + val fileName = + it.headers["content-disposition"]?.substringAfter("filename=") + ?.substringBefore(";") + val name = safeFileName(fileName) ?: "$fileId.csv".substringAfterLast('/') + val file = File(context.getExternalFilesDir(null), name) + val contentType = body.contentType()?.toString().orEmpty() + + if (it.isSuccessful) { + try { + if (contentType.contains("application/json", ignoreCase = true)) { + errorCallback(ErrorResponse.parseError(body.string())) + return@use + } + FileOutputStream(file.path).use { fos -> + body.byteStream().copyTo(fos) + } + responseCallback(ZBGetFileResponse(file.path)) + } catch (e: FileNotFoundException) { + errorCallback(ErrorResponse.parseError(e.message)) + } catch (e: IOException) { + errorCallback(ErrorResponse.parseError(e.message)) } - FileOutputStream(file.path).use { fos -> - body.byteStream().copyTo(fos) - fos.flush() - fos.close() - } - responseCallback(ZBGetFileResponse(file.path)) - } catch (e: FileNotFoundException) { - errorCallback(ErrorResponse.parseError(e.message)) - } catch (e: IOException) { - errorCallback(ErrorResponse.parseError(e.message)) + } else { + errorCallback(ErrorResponse.parseError(body.string())) } - } else { - errorCallback(ErrorResponse.parseError(body.string())) } } @@ -931,7 +931,7 @@ object ZeroBounceSDK { val baseUrl = if (scoring) bulkApiScoringBaseUrl else bulkApiBaseUrl request( - url = "$baseUrl/deletefile?api_key=$apiKey&file_id=$fileId", + url = "$baseUrl/deletefile?api_key=$apiKey&file_id=${enc(fileId)}", body = null, responseCallback = successCallback, errorCallback = errorCallback @@ -954,7 +954,7 @@ object ZeroBounceSDK { if (invalidApiKey(errorCallback)) return request( - url = "$apiBaseUrl/activity?api_key=$apiKey&email=$email", + url = "$apiBaseUrl/activity?api_key=$apiKey&email=${enc(email)}", body = null, responseCallback = responseCallback, errorCallback = errorCallback