Skip to content
Closed
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
59 changes: 59 additions & 0 deletions .github/actions/secret-scan/action.yml
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions .github/actions/secret-scan/compose-config.py
Original file line number Diff line number Diff line change
@@ -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 = <org baseline>` 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())
83 changes: 83 additions & 0 deletions .github/actions/secret-scan/gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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$''',
]
73 changes: 73 additions & 0 deletions .github/workflows/secret-scan.yml
Original file line number Diff line number Diff line change
@@ -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 }}
38 changes: 38 additions & 0 deletions .github/workflows/test-gitleaks-config.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,21 @@
# .github
# .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).
23 changes: 23 additions & 0 deletions examples/secret-scan/calling-repo-security.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading