diff --git a/.github/actions/secret-scan/action.yml b/.github/actions/secret-scan/action.yml new file mode 100644 index 0000000..e21a33f --- /dev/null +++ b/.github/actions/secret-scan/action.yml @@ -0,0 +1,59 @@ +############################################################################### +# Composite Secret Scan (Gitleaks CLI) +# +# Called from .github/workflows/secret-scan.yml via `$/.github/actions/secret-scan` +# so this directory (including gitleaks.toml) is loaded at the same commit the +# caller pinned. Reusable workflows cannot otherwise read sibling files. +# +# Uses the Gitleaks CLI binary directly (MIT licensed, free for private repos). +############################################################################### + +name: Secret Scan (Gitleaks) +description: Scan the checked-out repo with the M0 org Gitleaks baseline. + +inputs: + gitleaks_version: + description: Gitleaks CLI version to install (without 'v' prefix). + required: false + default: "8.24.2" + config_path: + description: Path to a repo-level .gitleaks.toml allow-list (relative to repo root). + required: false + default: ".gitleaks.toml" + fail_on_findings: + description: Exit non-zero (fail the job) if secrets are detected. + required: false + default: "true" + +runs: + using: composite + steps: + - name: Install Gitleaks ${{ inputs.gitleaks_version }} + shell: bash + env: + VERSION: ${{ inputs.gitleaks_version }} + run: | + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \ + | tar -xz gitleaks + sudo mv gitleaks /usr/local/bin/gitleaks + gitleaks version + + - name: Compose Gitleaks config + shell: bash + env: + ORG_CONFIG: ${{ github.action_path }}/gitleaks.toml + REPO_CONFIG: ${{ github.workspace }}/${{ inputs.config_path }} + EFFECTIVE_CONFIG: /tmp/effective-gitleaks.toml + run: python3 "${{ github.action_path }}/compose-config.py" + + - name: Run Gitleaks + shell: bash + env: + EXIT_CODE: ${{ inputs.fail_on_findings == 'true' && '1' || '0' }} + run: | + gitleaks git \ + --config=/tmp/effective-gitleaks.toml \ + --exit-code="${EXIT_CODE}" \ + --verbose \ + --redact diff --git a/.github/actions/secret-scan/compose-config.py b/.github/actions/secret-scan/compose-config.py new file mode 100755 index 0000000..9b50668 --- /dev/null +++ b/.github/actions/secret-scan/compose-config.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Compose the M0 org Gitleaks baseline with an optional repo-level config. + +A --config file replaces Gitleaks' default rules unless it extends something. +Repo configs in this org use `[extend] useDefault = true` plus allow-lists. +This script rewrites that to `[extend] path = ` so EVM rules +always apply and repo allow-lists still win on duplicate rule IDs. +""" + +from __future__ import annotations + +import os +import pathlib +import re +import sys + + +def main() -> int: + org_path = pathlib.Path(os.environ["ORG_CONFIG"]).resolve() + repo_path = pathlib.Path(os.environ["REPO_CONFIG"]) + out_path = pathlib.Path(os.environ["EFFECTIVE_CONFIG"]) + + if not org_path.is_file(): + print(f"org Gitleaks config missing: {org_path}", file=sys.stderr) + return 1 + + if not repo_path.is_file(): + out_path.write_text(org_path.read_text(), encoding="utf-8") + print(f"using org baseline only ({org_path})") + return 0 + + text = repo_path.read_text(encoding="utf-8") + org_abs = str(org_path) + + if re.search(r"^path\s*=", text, re.MULTILINE): + print( + f"ERROR: {repo_path} already sets [extend].path. " + "Repo configs must use `useDefault = true` so the org baseline " + "can be injected. Move extra allow-lists into this file and drop path.", + file=sys.stderr, + ) + return 1 + + replacement = f'path = "{org_abs}"' + if re.search(r"^useDefault\s*=", text, re.MULTILINE): + text, n = re.subn( + r"^useDefault\s*=\s*true\s*$", + replacement, + text, + count=1, + flags=re.MULTILINE, + ) + if n != 1: + print( + f"ERROR: {repo_path} has useDefault but not `useDefault = true`.", + file=sys.stderr, + ) + return 1 + elif re.search(r"^\[extend\]", text, re.MULTILINE): + text = re.sub( + r"^\[extend\]", + f"[extend]\n{replacement}", + text, + count=1, + flags=re.MULTILINE, + ) + else: + text = f"[extend]\n{replacement}\n\n{text}" + + out_path.write_text(text, encoding="utf-8") + print(f"composed {repo_path} extending org baseline") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/actions/secret-scan/gitleaks.toml b/.github/actions/secret-scan/gitleaks.toml new file mode 100644 index 0000000..c16338a --- /dev/null +++ b/.github/actions/secret-scan/gitleaks.toml @@ -0,0 +1,83 @@ +############################################################################### +# M0 org Gitleaks baseline +# +# Applied by .github/actions/secret-scan to every calling repo. A --config file +# REPLACES the upstream rule set, so useDefault is mandatory. +# +# Why the extra rules: upstream `generic-api-key` is SEMI-generic. It fires only +# when the identifier contains access/api/auth/credential/key/password/secret/ +# token. `PRIVATE_KEY=0x<64 hex>` is caught; `deployer_pk=0x<64 hex>` is not. +# These rules match the 32-byte hex shape itself. +# +# A private key and a keccak256 / sha256 digest are identical in shape. That is +# deliberate: new 32-byte literals are reviewed once, then removed or +# allow-listed in the repo's .gitleaks.toml / .gitleaksignore. +# +# Repo-level .gitleaks.toml is still supported: the action makes that file +# extend THIS baseline (allow-lists only — do not copy these rules). +############################################################################### + +title = "m0 org gitleaks" + +[extend] +useDefault = true + +# Public EVM addresses (20 bytes) are on-chain data, not credentials. +# regexTarget = "secret" so a line that also contains a 32-byte key is not +# cleared just because an address appears somewhere on it. +[allowlist] +description = "Public EVM addresses and universally public 32-byte values" +regexTarget = "secret" +regexes = [ + '''^0x[0-9a-fA-F]{40}$''', + # Anvil/Hardhat account #0 key (Foundry docs). + '''^0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80$''', + # bytes32(0) / DEFAULT_ADMIN_ROLE and 0x..01 dummy keys. + '''^0x0{63}[01]$''', +] + +[[rules]] +id = "evm-32-byte-hex" +description = "32-byte hex literal (0x + 64 hex) — possible EVM private key" +regex = '''\b0x[0-9a-fA-F]{64}\b''' +keywords = ["0x"] + +[rules.allowlist] +description = "Universally public 32-byte values" +regexTarget = "secret" +regexes = [ + # Anvil/Hardhat account #0 key (address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266). + # Published in the Foundry docs; used in tests so privateKeyToAccount succeeds. + '''^0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80$''', + # bytes32(0) — DEFAULT_ADMIN_ROLE — and the 0x..01 dummy PRIVATE_KEY in tests. + '''^0x0{63}[01]$''', +] + +# Keys are often written WITHOUT the 0x prefix in .env files +# (deployer_pk=4c0883...), which the rule above cannot see. +[[rules]] +id = "bare-32-byte-hex" +description = "32-byte hex literal without 0x prefix — possible EVM private key" +regex = '''\b[0-9a-fA-F]{64}\b''' + +[rules.allowlist] +description = "Lockfiles, checksum manifests, and bytes32(0)/0x..01 without 0x prefix" +regexTarget = "secret" +regexes = [ + '''^0{63}[01]$''', +] +paths = [ + '''(^|/)package-lock\.json$''', + '''(^|/)npm-shrinkwrap\.json$''', + '''(^|/)yarn\.lock$''', + '''(^|/)pnpm-lock\.yaml$''', + '''(^|/)bun\.lockb?$''', + '''(^|/)Cargo\.lock$''', + '''(^|/)go\.sum$''', + '''(^|/)composer\.lock$''', + '''(^|/)poetry\.lock$''', + '''(^|/)uv\.lock$''', + '''(^|/)Gemfile\.lock$''', + '''(^|/)flake\.lock$''', + '''(^|/)\.terraform\.lock\.hcl$''', +] diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..24ac514 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,73 @@ +############################################################################### +# Reusable Secret Scan (Gitleaks CLI) +# +# Scans commits in a PR or push for accidentally committed secrets — API keys, +# tokens, private keys, connection strings, etc. +# +# Uses the Gitleaks CLI binary directly (MIT licensed, free for private repos). +# The gitleaks/gitleaks-action wrapper requires a commercial license for private +# repos — this workflow avoids that by calling the binary via install script. +# +# Org baseline (always on): +# .github/actions/secret-scan/gitleaks.toml +# Extends Gitleaks defaults with name-independent EVM 32-byte hex rules so +# `deployer_pk=0x<64 hex>` is caught, not only `PRIVATE_KEY=...`. +# +# Repo-level allow-listing: +# Add .gitleaks.toml to the calling repo for false positives. Keep +# `useDefault = true` — the action rewrites that to extend the org baseline. +# One-off findings: .gitleaksignore fingerprints. +# https://github.com/gitleaks/gitleaks#configuration +# +# Usage in any repo: +# uses: m0-platform/.github/.github/workflows/secret-scan.yml@main +# +# Public repos must call THIS repo (m0-pipelines is private; GitHub will not +# let a public caller resolve a private reusable workflow). +# Private repos may keep calling m0-pipelines; that wrapper should track this +# repo so the org baseline lives in one place. +############################################################################### + +name: Secret Scan (Reusable) + +on: + workflow_call: + inputs: + gitleaks_version: + description: "Gitleaks CLI version to install (without 'v' prefix)." + type: string + default: "8.24.2" + config_path: + description: "Path to a repo-level .gitleaks.toml allow-list (relative to repo root)." + type: string + default: ".gitleaks.toml" + fail_on_findings: + description: "Exit non-zero (fail the job) if secrets are detected." + type: boolean + default: true + +permissions: + contents: read + +jobs: + gitleaks: + name: Secret Scan (Gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + # actions/checkout v4.3.1 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + persist-credentials: false + + # $/ resolves to this repo at the same commit the caller pinned — required + # so the org gitleaks.toml next to the composite is the one they asked for. + - name: Run Gitleaks + uses: $/.github/actions/secret-scan + with: + gitleaks_version: ${{ inputs.gitleaks_version }} + config_path: ${{ inputs.config_path }} + fail_on_findings: ${{ inputs.fail_on_findings }} diff --git a/.github/workflows/test-gitleaks-config.yml b/.github/workflows/test-gitleaks-config.yml new file mode 100644 index 0000000..d10d63f --- /dev/null +++ b/.github/workflows/test-gitleaks-config.yml @@ -0,0 +1,38 @@ +############################################################################### +# Verify the org Gitleaks baseline against fixture files (not a full git scan). +############################################################################### + +name: Test Gitleaks config + +on: + pull_request: + paths: + - ".github/actions/secret-scan/**" + - ".github/workflows/test-gitleaks-config.yml" + - "scripts/test-gitleaks-config.sh" + - "testdata/gitleaks/**" + push: + branches: [main] + paths: + - ".github/actions/secret-scan/**" + - ".github/workflows/test-gitleaks-config.yml" + - "scripts/test-gitleaks-config.sh" + - "testdata/gitleaks/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + fixtures: + name: Org baseline fixtures + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Run fixture tests + run: ./scripts/test-gitleaks-config.sh diff --git a/README.md b/README.md index a46ae92..1325437 100644 --- a/README.md +++ b/README.md @@ -1 +1,21 @@ -# .github \ No newline at end of file +# .github + +Org community files, plus the **public** Gitleaks reusable workflow. + +Public repositories cannot call reusable workflows in private +[`m0-pipelines`](https://github.com/m0-platform/m0-pipelines). Call this repo instead: + +```yaml +jobs: + secret-scan: + permissions: + contents: read + uses: m0-platform/.github/.github/workflows/secret-scan.yml@main +``` + +See [`examples/secret-scan/calling-repo-security.yml`](examples/secret-scan/calling-repo-security.yml). + +Org baseline: [`.github/actions/secret-scan/gitleaks.toml`](.github/actions/secret-scan/gitleaks.toml) + +Pin `@main` so rule changes land without a SHA bump in every caller. Repo-level +`.gitleaks.toml` should keep `useDefault = true` (allow-lists only). diff --git a/examples/secret-scan/calling-repo-security.yml b/examples/secret-scan/calling-repo-security.yml new file mode 100644 index 0000000..0537c4c --- /dev/null +++ b/examples/secret-scan/calling-repo-security.yml @@ -0,0 +1,23 @@ +############################################################################### +# Copy to YOUR_REPO/.github/workflows/security.yml +# +# Public repos must call m0-platform/.github (this public repo). They cannot +# call private m0-pipelines — GitHub rejects public → private reusable workflows. +############################################################################### + +name: Security + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + schedule: + - cron: "0 3 1 * *" + workflow_dispatch: + +jobs: + secret-scan: + permissions: + contents: read + uses: m0-platform/.github/.github/workflows/secret-scan.yml@main diff --git a/scripts/test-gitleaks-config.sh b/scripts/test-gitleaks-config.sh new file mode 100755 index 0000000..bdcb333 --- /dev/null +++ b/scripts/test-gitleaks-config.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# test-gitleaks-config.sh +# +# Verifies the org Gitleaks baseline against testdata/gitleaks fixtures. +# Requires the gitleaks binary on PATH (installs it if missing on Linux/macOS). +# +# Usage: ./scripts/test-gitleaks-config.sh + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ORG_CONFIG="${ROOT}/.github/actions/secret-scan/gitleaks.toml" +VERSION="${GITLEAKS_VERSION:-8.24.2}" + +if ! command -v gitleaks >/dev/null 2>&1; then + echo "installing gitleaks ${VERSION}" + tmp="$(mktemp -d)" + os="$(uname -s)" + arch="$(uname -m)" + case "$os-$arch" in + Linux-x86_64) asset="gitleaks_${VERSION}_linux_x64.tar.gz" ;; + Linux-aarch64) asset="gitleaks_${VERSION}_linux_arm64.tar.gz" ;; + Darwin-arm64) asset="gitleaks_${VERSION}_darwin_arm64.tar.gz" ;; + Darwin-x86_64) asset="gitleaks_${VERSION}_darwin_x64.tar.gz" ;; + *) echo "unsupported platform: $os $arch" >&2; exit 1 ;; + esac + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/${asset}" \ + | tar -xz -C "$tmp" gitleaks + export PATH="${tmp}:${PATH}" +fi + +fail_dir="${ROOT}/testdata/gitleaks/should-fail" +pass_dir="${ROOT}/testdata/gitleaks/should-pass" +report="$(mktemp)" + +echo "==> should-fail (expect evm-32-byte-hex and bare-32-byte-hex)" +gitleaks dir "$fail_dir" --config="$ORG_CONFIG" --exit-code=0 --report-path="$report" --report-format=json --redact +python3 - "$report" <<'PY' +import json, sys +path = sys.argv[1] +findings = json.load(open(path)) or [] +rules = {f.get("RuleID") for f in findings} +needed = {"evm-32-byte-hex", "bare-32-byte-hex"} +missing = needed - rules +if missing: + raise SystemExit(f"missing rule ids {sorted(missing)}; got {sorted(rules)}") +print(f"found {len(findings)} finding(s) covering {sorted(needed)}") +PY + +echo "==> should-pass (expect clean)" +if ! gitleaks dir "$pass_dir" --config="$ORG_CONFIG" --exit-code=1 --verbose --redact; then + echo "ERROR: unexpected findings in ${pass_dir}" >&2 + exit 1 +fi + +echo "==> compose-config.py rewrites useDefault to org path" +tmp="$(mktemp -d)" +repo_toml="${tmp}/.gitleaks.toml" +effective="${tmp}/effective.toml" +cat > "$repo_toml" <<'TOML' +[extend] +useDefault = true + +[allowlist] +description = "repo extra" +regexTarget = "secret" +regexes = ['''^0xdead$'''] +TOML +ORG_CONFIG="$ORG_CONFIG" REPO_CONFIG="$repo_toml" EFFECTIVE_CONFIG="$effective" \ + python3 "${ROOT}/.github/actions/secret-scan/compose-config.py" +grep -q "path = \".*gitleaks.toml\"" "$effective" +grep -q "useDefault" "$effective" && { echo "ERROR: useDefault should have been replaced"; exit 1; } +grep -q "0xdead" "$effective" + +echo "gitleaks org baseline fixtures passed" diff --git a/testdata/gitleaks/README.md b/testdata/gitleaks/README.md new file mode 100644 index 0000000..3c5d3e8 --- /dev/null +++ b/testdata/gitleaks/README.md @@ -0,0 +1,2 @@ +# Synthetic samples for scripts/test-gitleaks-config.sh. +# Not production secrets. Do not add #gitleaks:allow — the fail fixtures must match. diff --git a/testdata/gitleaks/should-fail/bare.env b/testdata/gitleaks/should-fail/bare.env new file mode 100644 index 0000000..0654575 --- /dev/null +++ b/testdata/gitleaks/should-fail/bare.env @@ -0,0 +1,2 @@ +# Bare 32-byte hex in an env file (no 0x prefix). +SOME_PK=cafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeef diff --git a/testdata/gitleaks/should-fail/deployer.env b/testdata/gitleaks/should-fail/deployer.env new file mode 100644 index 0000000..8b6aa9c --- /dev/null +++ b/testdata/gitleaks/should-fail/deployer.env @@ -0,0 +1,2 @@ +# Identifier does not contain key/secret/token — default generic-api-key misses this. +deployer_pk=0xcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeef diff --git a/testdata/gitleaks/should-pass/address.env b/testdata/gitleaks/should-pass/address.env new file mode 100644 index 0000000..808af43 --- /dev/null +++ b/testdata/gitleaks/should-pass/address.env @@ -0,0 +1,3 @@ +# Public 20-byte address, including next to a key-like identifier. +M_TOKEN=0x1234567890abcdef1234567890abcdef12345678 +PRIVATE_KEY=0x1234567890abcdef1234567890abcdef12345678 diff --git a/testdata/gitleaks/should-pass/package-lock.json b/testdata/gitleaks/should-pass/package-lock.json new file mode 100644 index 0000000..4255e0e --- /dev/null +++ b/testdata/gitleaks/should-pass/package-lock.json @@ -0,0 +1,9 @@ +{ + "name": "fixture", + "lockfileVersion": 3, + "packages": { + "": { + "integrity": "cafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeef" + } + } +} diff --git a/testdata/gitleaks/should-pass/well-known.env b/testdata/gitleaks/should-pass/well-known.env new file mode 100644 index 0000000..44da4cb --- /dev/null +++ b/testdata/gitleaks/should-pass/well-known.env @@ -0,0 +1,4 @@ +# Published Anvil/Hardhat account #0 key — allow-listed in the org baseline. +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +DEFAULT_ADMIN_ROLE=0x0000000000000000000000000000000000000000000000000000000000000000 +DUMMY=0x0000000000000000000000000000000000000000000000000000000000000001