Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .github/workflows/betterleaks.yml
Original file line number Diff line number Diff line change
@@ -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<<BETTERLEAKS_SUMMARY"
cat betterleaks-email-summary.txt
echo "BETTERLEAKS_SUMMARY"
} >> "$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
89 changes: 89 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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''',
]
2 changes: 1 addition & 1 deletion Jenkinsfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'''
}
Expand Down
50 changes: 50 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading