From 66a92067d5b903406bb27dd2895bbb32dd475b8d Mon Sep 17 00:00:00 2001 From: Dobricean Ioan Dorian Date: Mon, 6 Jul 2026 13:04:52 +0300 Subject: [PATCH] Add betterleaks action --- .github/workflows/betterleaks.yml | 133 ++++++++++++++++++++++++++++++ .gitleaks.toml | 89 ++++++++++++++++++++ Jenkinsfile | 2 +- README.rst | 50 +++++++++++ 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/betterleaks.yml create mode 100644 .gitleaks.toml diff --git a/.github/workflows/betterleaks.yml b/.github/workflows/betterleaks.yml new file mode 100644 index 0000000..e3ee2df --- /dev/null +++ b/.github/workflows/betterleaks.yml @@ -0,0 +1,133 @@ +name: Betterleaks + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + scan: + name: Scan for secrets + runs-on: ubuntu-latest + env: + SMTP_URL: ${{ secrets.SMTP_URL }} + SMTP_PORT: ${{ secrets.SMTP_PORT || '25' }} + SMTP_EMAIL: ${{ secrets.SMTP_EMAIL }} + SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Run Betterleaks + id: betterleaks + continue-on-error: true + uses: dortort/betterleaks-action@v0.1.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + scan-mode: dir + scan-path: . + config: .gitleaks.toml + report-format: json + report-path: betterleaks-report.json + redact: "true" + no-color: "true" + no-banner: "true" + fail-on-leak: "true" + + - name: Upload Betterleaks report + if: always() + uses: actions/upload-artifact@v4 + with: + name: betterleaks-report + path: betterleaks-report.json + if-no-files-found: ignore + + - name: Build Betterleaks email summary + id: leak_summary + if: steps.betterleaks.outcome == 'failure' + shell: bash + run: | + if [[ -s betterleaks-report.json ]]; then + jq -r ' + def one_line: + tostring + | gsub("[\r\n]+"; " ") + | if length > 240 then .[0:240] + "..." else . end; + + .[:20][] + | "- " + (.RuleID // "unknown-rule") + + " at " + (.File // "unknown-file") + + ":" + ((.StartLine // 0) | tostring) + + "\n match: " + ((.Match // .Secret // "REDACTED") | one_line) + ' betterleaks-report.json > betterleaks-email-summary.txt + + count="$(jq 'length' betterleaks-report.json)" + if (( count > 20 )); then + { + echo "" + echo "... and $((count - 20)) more finding(s). Download the artifact for full details." + } >> betterleaks-email-summary.txt + fi + else + echo "No JSON report was generated. Download the workflow logs for details." > betterleaks-email-summary.txt + fi + + { + echo "text<> "$GITHUB_OUTPUT" + + - name: Resolve committer email + id: committer + if: steps.betterleaks.outcome == 'failure' + shell: bash + run: | + committer_email="$(git log -1 --format='%ce')" + author_email="$(git log -1 --format='%ae')" + email="$committer_email" + if [[ -z "$email" || "$email" == *"noreply.github.com"* ]]; then + email="$author_email" + fi + if [[ "$email" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ && "$email" != *"noreply.github.com"* ]]; then + echo "email=$email" >> "$GITHUB_OUTPUT" + else + echo "No deliverable committer email found; skipping Betterleaks email notification." + echo "email=" >> "$GITHUB_OUTPUT" + fi + + - name: Email committer on Betterleaks failure + if: steps.betterleaks.outcome == 'failure' && steps.committer.outputs.email != '' && env.SMTP_URL != '' && env.SMTP_EMAIL != '' + uses: dawidd6/action-send-mail@v18 + with: + server_address: ${{ env.SMTP_URL }} + server_port: ${{ env.SMTP_PORT }} + secure: ${{ env.SMTP_PORT == '465' }} + username: ${{ env.SMTP_EMAIL }} + password: ${{ env.SMTP_PASSWORD }} + from: ${{ env.SMTP_EMAIL }} + to: ${{ steps.committer.outputs.email }} + subject: "[Betterleaks] Secret scan failed in ${{ github.repository }}" + body: | + Betterleaks detected one or more potential secrets. + + Repository: ${{ github.repository }} + Branch: ${{ github.ref_name }} + Commit: ${{ github.sha }} + Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + Findings: + ${{ steps.leak_summary.outputs.text }} + + Download the betterleaks-report artifact from the workflow run for details. + + - name: Fail if Betterleaks found leaks + if: steps.betterleaks.outcome == 'failure' + run: | + echo "Betterleaks detected one or more secrets. Download the betterleaks-report artifact from this workflow run for details." + exit 1 diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..4313fd7 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,89 @@ +title = "Betterleaks config" + +[extend] +useDefault = true + +[[rules]] +id = "secret-literal-assignment" +description = "Secret-like literal assignment in source, YAML, env and config files" +regex = '''(?i)([A-Za-z0-9_.-]*(?:password|passwd|pwd|api[_-]?key|apikey|access[_-]?key|secret[_-]?access[_-]?key|private[_-]?key|client[_-]?secret|consumer[_-]?key|consumer[_-]?secret|(?:auth|access|refresh|session|api|bearer|id|jwt|csrf|xsrf|oauth)[_-]?token|translate[_-]?auth|translation[_-]?auth|auth[_-]?password|auth[_-]?key|auth[_-]?credentials|database[_-]?url|connection[_-]?string)[A-Za-z0-9_.-]*)[ \t]*[:=][ \t]*["'']?([^"''[:space:]#{}$.][^"''\n#{}]{2,})["'']?''' +secretGroup = 2 +keywords = [ + "password", + "passwd", + "pwd", + "api_key", + "apikey", + "access_key", + "private_key", + "client_secret", + "consumer_key", + "consumer_secret", + "auth_token", + "access_token", + "refresh_token", + "session_token", + "api_token", + "bearer_token", + "id_token", + "jwt_token", + "csrf_token", + "xsrf_token", + "oauth_token", + "translate_auth", + "translation_auth", + "auth_password", + "auth_key", + "auth_credentials", + "database_url", + "connection_string" +] +tags = ["literal-secret"] + +[[rules]] +id = "env-short-secret-assignment" +description = "Uppercase env-style PASS/TOKEN/SECRET assignment" +regex = '''\b((?:PASS|TOKEN|SECRET|[A-Z0-9_]*(?:_PASS|_TOKEN|_SECRET|PASS_|TOKEN_|SECRET_)[A-Z0-9_]*))[ \t]*[:=][ \t]*["'']?([^"''[:space:]#{}$.][^"''\n#{}]{2,})["'']?''' +secretGroup = 2 +keywords = [ + "PASS", + "TOKEN", + "SECRET" +] +tags = ["env", "literal-secret"] + +[[rules]] +id = "standalone-sk-token" +description = "Standalone sk-* token not attached to a secret-like variable name" +regex = '''(?i)\b(sk-[A-Za-z0-9][A-Za-z0-9_-]{20,})\b''' +secretGroup = 1 +entropy = 2.5 +keywords = ["sk-"] +tags = ["standalone-token", "generic"] + +[[rules]] +id = "dotenv-only-jest-setup" +description = ".env may only contain the committed Jest setup lines" +path = '''(?i)(^|/)\.env$''' +regex = '''(?m)^(.+)$''' +secretGroup = 1 +tags = ["file", "dotenv"] +[[rules.allowlists]] +regexTarget = "match" +regexes = [ + '''^JEST_USE_SETUP=(ON|OFF) # Jest configuration variables: ON, OFF\r?$''', +] + +[[rules]] +id = "forbidden-secret-file" +description = "Forbidden secret-bearing file committed to repository" +path = '''(?i)(^|/)(\.env\..*|\.npmrc|\.pypirc|id_rsa|id_ed25519|.*\.(pem|key|p12|pfx|jks|kubeconfig))$''' +regex = '''(?s).{1,}''' +tags = ["file", "secret-file"] + +[[allowlists]] +description = "Allow Jenkins SonarQube token environment variable reference" +regexTarget = "match" +regexes = [ + '''SONAR_AUTH_TOKEN''', +] diff --git a/Jenkinsfile b/Jenkinsfile index 7295bd4..ccd4059 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -186,7 +186,7 @@ pipeline { } steps { node(label: 'docker') { - withCredentials([[$class: 'UsernamePasswordMultiBinding', credentialsId: 'eea-jenkins', usernameVariable: 'EGGREPO_USERNAME', passwordVariable: 'EGGREPO_PASSWORD'],string(credentialsId: 'eea-jenkins-token', variable: 'GITHUB_TOKEN'),[$class: 'UsernamePasswordMultiBinding', credentialsId: 'pypi-jenkins', usernameVariable: 'PYPI_USERNAME', passwordVariable: 'PYPI_PASSWORD']]) { + withCredentials([[$class: 'UsernamePasswordMultiBinding', credentialsId: 'eea-jenkins', usernameVariable: 'EGGREPO_USERNAME', passwordVariable: 'EGGREPO_PASSWORD'],string(credentialsId: 'eea-jenkins-token', variable: 'GITHUB_TOKEN'),[$class: 'UsernamePasswordMultiBinding', credentialsId: 'pypi-jenkins', usernameVariable: 'PYPI_USERNAME', passwordVariable: 'PYPI_PASSWORD']]) { //betterleaks:allow sh '''docker pull eeacms/gitflow''' sh '''docker run -i --rm --name="$BUILD_TAG-gitflow-master" -e GIT_BRANCH="$BRANCH_NAME" -e EGGREPO_USERNAME="$EGGREPO_USERNAME" -e EGGREPO_PASSWORD="$EGGREPO_PASSWORD" -e GIT_NAME="$GIT_NAME" -e PYPI_USERNAME="$PYPI_USERNAME" -e PYPI_PASSWORD="$PYPI_PASSWORD" -e GIT_ORG="$GIT_ORG" -e GIT_TOKEN="$GITHUB_TOKEN" eeacms/gitflow''' } diff --git a/README.rst b/README.rst index 86151dc..c76f8ee 100644 --- a/README.rst +++ b/README.rst @@ -108,3 +108,53 @@ EEA_ - European Environment Agency (EU) .. _EEA: https://www.eea.europa.eu/ .. _`EEA Web Systems Training`: http://www.youtube.com/user/eeacms/videos?view=1 + +Secret Scanning +=============== + +This repository uses the Betterleaks GitHub Action to scan the current +repository content on every push and pull request. The scan uses the rules in +``.gitleaks.toml`` and uploads a ``betterleaks-report`` artifact when a finding +is detected. + +If the optional SMTP secrets are configured, failed scans also send an email to +the last commit committer. The workflow expects these repository or +organization secrets: + +- ``SMTP_URL`` +- ``SMTP_PORT`` (optional, defaults to ``25``) +- ``SMTP_EMAIL`` +- ``SMTP_PASSWORD`` (optional if the SMTP server does not require authentication) + +Port ``465`` is sent with direct TLS; other ports use the default SMTP +handshake. The email includes a short finding summary from the redacted +Betterleaks report, including the redacted matched line from each finding. + +There are three common outcomes: + +1. Everything is OK. The ``Betterleaks / Scan for secrets`` check is green and + no action is needed. Regular references to runtime values are OK, for example:: + + token_from_cookie = request.cookies.get("auth_token") + +2. A real secret was found. The check is red and the workflow log asks you to + download the ``betterleaks-report`` artifact. Open the artifact from the + GitHub Actions run and check the reported file, line and rule. Remove the + committed value, move it to the proper secret store, and rotate it if it was + exposed. A report entry looks like this:: + + { + "RuleID": "secret-literal-assignment", + "File": "src/config.py", + "StartLine": 12, + "Secret": "[REDACTED]" + } + +3. The finding is a false positive. Keep the value only if it is clearly not + sensitive, such as a test fixture, placeholder, or public example. Add + ``betterleaks:allow`` on the same line and include a short explanation in the + pull request:: + + test_password = "admin" #betterleaks:allow + +Do not add ``betterleaks:allow`` to real credentials.