diff --git a/.github/workflows/betterleaks.yml b/.github/workflows/betterleaks.yml new file mode 100644 index 00000000..e3ee2dff --- /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 00000000..36f8aa49 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,148 @@ +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''', +] + +[[allowlists]] +description = "Allow Helm chart placeholder and local development default values" +regexTarget = "match" +regexes = [ + '''(?i)[:=][ \t]*["'']?(?:REPLACEME|replaceme|replaceme-with-a-secure-password|changeme|admin|password|secret|zope|zope_456|postgres|plone|airflow|guest|redmine|databasepassword|redis_password|s3_aws_access_key_id|s3_aws_secret_access_key|oauth_client_secret|opensearch_admin_password|db_readonly_password|rotest|rwtest|kibanatest|minioadmin|smtp_pass|from_file|newRootPassword123|RandomPassword1|secure-password|your-secure-master-key|sk-your-langfuse-secret-key)\b''', + '''(?i)[:=][ \t]*["'']?(?:\{\{[^"''\n]*\}\}|env\([^"''\n]*\)|os\.environ\.get\([^"''\n]*\))''', + '''(?i)[:=][ \t]*["'']?(?:None|""|YWRtaW4=|secretpassword|xxxxx|YOUR_[A-Z0-9_]+)\b''', + '''(?i)[:=][ \t]*["'']?(?:CHANGE_THIS|your-[A-Za-z0-9-]+|my[A-Za-z0-9-]+|kibana-user-password|kibanaPassword123|mySecurePassword123|myPasswordKey|test|mailtrap|oauth_cookie_secret|user_auth_secret|Admin123!@#|newMetricsPassword|sentry-password|postgresql-password|xxj31ZMTZzkVA||\[PASSWORD\]|Q-[0-9a-f-]{36}-0|zM2\?vQ0&mI5>xP2\*1)\b''', + '''(?i)(?:password|passwd|secret|token|key)[A-Za-z0-9_.-]*[ \t]*:?=[ \t]*(?:include|index|tpl|os\.getenv|os\.environ\.get|env\(|append|default|randAlphaNum|randAscii|regexReplaceAllLiteral|printf|list|dict|splitList|join|\.Values|\$[A-Za-z_])''', + '''(?i)\{\{[^}\n]*(?:password|passwd|secret|token|key)[^}\n]*\}\}''', + '''(?i)-u[ \t]+["'']?[^"''\n]*\{\{[^}\n]*\}\}''', + '''(?i)--set[^\n]*(?:password|secret|key|token)[^=]*=[A-Za-z0-9_\[\]-]+''', + '''(?i)postgresql://\{\{''', + '''sk-your-langfuse-secret-key''', + '''(?i)passwords?: this is configured''', + '''YWRtaW4=''', + '''passwordReset:[ \t]*"https://www\.eionet\.europa\.eu/password-reset"''', + '''(?i)(adminPassword|rootPassword):[ \t]*"sha"''', + '''xxj31ZMTZzkVA''', + '''passwordList[ \t]*=''', + '''encodedPassword[ \t]*:=[ \t]*\.password''', + '''DATABASE_URL:[ \t]*"postgresql://''', + '''previous root password''', + '''root password:[ \t]*This value''', + '''password:[ \t]*This value''', + '''on_9B3hXYZ1234567890vll''', + '''SSH_AUTH_KEY=""''', + '''opensearch_admin_password:[ \t]*"Admin123!@#"''', + '''[:=][ \t]*"?Admin123''', + '''privateKey:[ \t]*"-----BEGIN RSA PRIVATE KEY-----''', + '''# password:[ \t]*redis''', + '''password:[ \t]*redis''', + '''(?i)sentry_key=[A-Fa-f0-9]{20,}''', + '''tableau_token=Sohcuk5wohjuoPheedeifeMooghahqu3''', + '''on_9B3hX\*{4}0vll''', +] + +[[allowlists]] +description = "Allow public CA certificate bundles committed with charts" +condition = "AND" +regexTarget = "match" +paths = [ + '''^sources/(eionetldap|rn-bdr-ldap)/cacerts\.pem$''', +] +regexes = [ + '''-----BEGIN CERTIFICATE-----''', +] + +[[allowlists]] +description = "Allow documented example private key in chart README" +condition = "AND" +regexTarget = "match" +paths = [ + '''^sources/logcentral-graylog6/README\.md$''', +] +regexes = [ + '''-----BEGIN PRIVATE KEY-----''', +] diff --git a/README.md b/README.md index 71c55185..2e2de02b 100644 --- a/README.md +++ b/README.md @@ -117,3 +117,62 @@ On the command line you can do the same with the helm tool. ## Source repository The sources for these pages are located at the [GitHub project](https://github.com/eea/helm-charts). + +## 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: + + ```js + const tokenFromCookie = req.universalCookies.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: + + ```json + { + "RuleID": "secret-literal-assignment", + "File": "src/config.js", + "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. + + ```js + const testPassword = 'admin'; //betterleaks:allow + ``` + + ```yaml + password: "admin" #betterleaks:allow + ``` + +Do not add `betterleaks:allow` to real credentials.