From f537f1ca7f24bdc4f5265d2a9a24ab5897c96c4f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 24 Feb 2026 14:45:24 -0600 Subject: [PATCH] feat: add gitleaks secret scanning to quality rails Replace non-blocking git-secrets with mandatory gitleaks scanning: - Pre-commit: blocks commit if gitleaks not installed or secrets found - CI: pinned gitleaks Docker image scans each commit in Woodpecker - Shared .gitleaks.toml with 12 custom rules for database URLs, alembic.ini, bearer tokens, PEM keys, docker-compose secrets, etc. - Stopwords suppress localhost/changeme/placeholder false positives - Install/verify scripts updated for gitleaks (no longer optional) Co-Authored-By: Claude Opus 4.6 --- tools/quality/scripts/install.ps1 | 8 +- tools/quality/scripts/install.sh | 8 +- tools/quality/scripts/verify.ps1 | 34 ++++ tools/quality/scripts/verify.sh | 42 +++-- tools/quality/templates/.gitleaks.toml | 162 ++++++++++++++++++ .../templates/monorepo/.husky/pre-commit | 15 +- .../templates/monorepo/.woodpecker.yml | 9 + .../typescript-nextjs/.husky/pre-commit | 15 +- .../typescript-nextjs/.woodpecker.yml | 9 + .../typescript-node/.husky/pre-commit | 15 +- .../templates/typescript-node/.woodpecker.yml | 9 + 11 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 tools/quality/templates/.gitleaks.toml diff --git a/tools/quality/scripts/install.ps1 b/tools/quality/scripts/install.ps1 index e3f88a7..30504bb 100644 --- a/tools/quality/scripts/install.ps1 +++ b/tools/quality/scripts/install.ps1 @@ -33,6 +33,10 @@ Copy-Item -Path "$TemplateDir\.eslintrc.strict.js" -Destination "$TargetDir\.esl Copy-Item -Path "$TemplateDir\tsconfig.strict.json" -Destination "$TargetDir\tsconfig.json" -Force -ErrorAction SilentlyContinue Copy-Item -Path "$TemplateDir\.woodpecker.yml" -Destination $TargetDir -Force -ErrorAction SilentlyContinue +# Copy shared gitleaks config from templates root +$SharedTemplates = Split-Path -Parent $TemplateDir +Copy-Item -Path "$SharedTemplates\.gitleaks.toml" -Destination $TargetDir -Force -ErrorAction SilentlyContinue + Write-Host "✓ Files copied" if (Test-Path "$TargetDir\package.json") { @@ -50,4 +54,6 @@ Write-Host "" Write-Host "Next steps:" Write-Host "1. Install dependencies: npm install" Write-Host "2. Initialize husky: npx husky install" -Write-Host "3. Run verification: ..\quality-rails\scripts\verify.ps1" +Write-Host "3. Install gitleaks: winget install gitleaks" +Write-Host "4. Run verification: ..\quality-rails\scripts\verify.ps1" +Write-Host "5. (Optional) Scan full history: gitleaks git --redact --verbose" diff --git a/tools/quality/scripts/install.sh b/tools/quality/scripts/install.sh index 87a50fa..06d9621 100755 --- a/tools/quality/scripts/install.sh +++ b/tools/quality/scripts/install.sh @@ -53,6 +53,10 @@ cp "$TEMPLATE_DIR/.eslintrc.strict.js" "$TARGET_DIR/.eslintrc.js" 2>/dev/null || cp "$TEMPLATE_DIR/tsconfig.strict.json" "$TARGET_DIR/tsconfig.json" 2>/dev/null || true cp "$TEMPLATE_DIR/.woodpecker.yml" "$TARGET_DIR/" 2>/dev/null || true +# Copy shared gitleaks config from templates root +SHARED_TEMPLATES="$(dirname "$TEMPLATE_DIR")" +cp "$SHARED_TEMPLATES/.gitleaks.toml" "$TARGET_DIR/" 2>/dev/null || true + echo "✓ Files copied" # Check if package.json exists @@ -71,5 +75,7 @@ echo "" echo "Next steps:" echo "1. Install dependencies: npm install" echo "2. Initialize husky: npx husky install" -echo "3. Run verification: ~/.config/mosaic/bin/mosaic-quality-verify --target $TARGET_DIR" +echo "3. Install gitleaks: https://github.com/gitleaks/gitleaks#installing" +echo "4. Run verification: ~/.config/mosaic/bin/mosaic-quality-verify --target $TARGET_DIR" +echo "5. (Optional) Scan full history: gitleaks git --redact --verbose" echo "" diff --git a/tools/quality/scripts/verify.ps1 b/tools/quality/scripts/verify.ps1 index 70ad8d6..95efada 100644 --- a/tools/quality/scripts/verify.ps1 +++ b/tools/quality/scripts/verify.ps1 @@ -39,6 +39,40 @@ if ($output -match "no-explicit-any") { git reset HEAD test-file.ts 2>$null Remove-Item test-file.ts -ErrorAction SilentlyContinue +# Test 3a: gitleaks binary must be present +Write-Host "" +Write-Host "Test 3a: gitleaks must be installed..." +$gitleaksPath = Get-Command gitleaks -ErrorAction SilentlyContinue +if ($gitleaksPath) { + $gitleaksVer = & gitleaks version 2>&1 | Out-String + Write-Host "✅ PASS: gitleaks found ($($gitleaksVer.Trim()))" -ForegroundColor Green + $Passed++ +} else { + Write-Host "❌ FAIL: gitleaks is NOT installed — secret scanning will not work" -ForegroundColor Red + Write-Host " Install: winget install gitleaks" + $Failed++ +} + +# Test 3b: gitleaks detects a planted AWS key +Write-Host "" +Write-Host "Test 3b: gitleaks should detect planted AWS key..." +if ($gitleaksPath) { + "aws_access_key_id = AKIAIOSFODNN7REALKEY" | Out-File -FilePath gitleaks-test-secret.txt -Encoding utf8 + git add gitleaks-test-secret.txt 2>$null + $output = & gitleaks git --pre-commit --staged --redact 2>&1 | Out-String + if ($output -match "leak|finding") { + Write-Host "✅ PASS: gitleaks detected planted secret" -ForegroundColor Green + $Passed++ + } else { + Write-Host "❌ FAIL: gitleaks did NOT detect planted secret" -ForegroundColor Red + $Failed++ + } + git reset HEAD gitleaks-test-secret.txt 2>$null + Remove-Item gitleaks-test-secret.txt -ErrorAction SilentlyContinue +} else { + Write-Host "⚠ SKIP: gitleaks not installed (Test 3a already failed)" +} + # Summary Write-Host "" Write-Host "═══════════════════════════════════════════" diff --git a/tools/quality/scripts/verify.sh b/tools/quality/scripts/verify.sh index 7f9cedf..0b9be4b 100755 --- a/tools/quality/scripts/verify.sh +++ b/tools/quality/scripts/verify.sh @@ -40,23 +40,35 @@ fi git reset HEAD test-file.ts 2>/dev/null rm test-file.ts 2>/dev/null -# Test 3: Hardcoded secret blocked (if git-secrets installed) +# Test 3a: gitleaks binary must be present echo "" -echo "Test 3: Hardcoded secrets should be blocked..." -if command -v git-secrets &> /dev/null; then - echo "const password = 'SuperSecret123!';" > test-file.ts - git add test-file.ts 2>/dev/null - if git commit -m "Test commit" 2>&1 | grep -q -i "secret\|password"; then - echo "✅ PASS: Secrets blocked" - ((PASSED++)) - else - echo "⚠ WARN: Secrets NOT blocked (git-secrets may need configuration)" - ((FAILED++)) - fi - git reset HEAD test-file.ts 2>/dev/null - rm test-file.ts 2>/dev/null +echo "Test 3a: gitleaks must be installed..." +if command -v gitleaks &> /dev/null; then + echo "✅ PASS: gitleaks found ($(gitleaks version 2>/dev/null || echo 'unknown version'))" + PASSED=$((PASSED + 1)) else - echo "⚠ SKIP: git-secrets not installed" + echo "❌ FAIL: gitleaks is NOT installed — secret scanning will not work" + echo " Install: https://github.com/gitleaks/gitleaks#installing" + FAILED=$((FAILED + 1)) +fi + +# Test 3b: gitleaks detects a planted AWS key +echo "" +echo "Test 3b: gitleaks should detect planted AWS key..." +if command -v gitleaks &> /dev/null; then + echo 'aws_access_key_id = AKIAIOSFODNN7REALKEY' > gitleaks-test-secret.txt + git add gitleaks-test-secret.txt 2>/dev/null + if gitleaks git --pre-commit --staged --redact 2>&1 | grep -q -i "leak\|finding"; then + echo "✅ PASS: gitleaks detected planted secret" + PASSED=$((PASSED + 1)) + else + echo "❌ FAIL: gitleaks did NOT detect planted secret" + FAILED=$((FAILED + 1)) + fi + git reset HEAD gitleaks-test-secret.txt 2>/dev/null + rm gitleaks-test-secret.txt 2>/dev/null +else + echo "⚠ SKIP: gitleaks not installed (Test 3a already failed)" fi # Test 4: Lint error blocked diff --git a/tools/quality/templates/.gitleaks.toml b/tools/quality/templates/.gitleaks.toml new file mode 100644 index 0000000..c58de7b --- /dev/null +++ b/tools/quality/templates/.gitleaks.toml @@ -0,0 +1,162 @@ +# Mosaic Quality Rails — gitleaks configuration +# Shared across all project templates. Copied to project root by install.sh. +# Built-in rules: https://github.com/gitleaks/gitleaks/tree/master/config +# This file adds custom rules for patterns the 150+ built-in rules miss. + +title = "Mosaic gitleaks config" + +[allowlist] + description = "Global allowlist — skip files that never contain real secrets" + paths = [ + '''node_modules/''', + '''dist/''', + '''build/''', + '''\.next/''', + '''\.nuxt/''', + '''\.output/''', + '''coverage/''', + '''__pycache__/''', + '''\.venv/''', + '''vendor/''', + '''pnpm-lock\.yaml$''', + '''package-lock\.json$''', + '''yarn\.lock$''', + '''\.lock$''', + '''\.snap$''', + '''\.min\.js$''', + '''\.min\.css$''', + '''\.gitleaks\.toml$''', + ] + stopwords = [ + "localhost", + "127.0.0.1", + "changeme", + "placeholder", + "example", + "example.com", + "test", + "dummy", + "fake", + "sample", + "your-", + "xxx", + "CHANGEME", + "PLACEHOLDER", + "TODO", + "REPLACE_ME", + ] + +# ────────────────────────────────────────────── +# Custom rules — patterns the built-in rules miss +# ────────────────────────────────────────────── + +[[rules]] + id = "database-url-with-credentials" + description = "Database connection URL with embedded password" + regex = '''(?i)(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|amqp)://[^:\s]+:[^@\s]+@[^/\s]+''' + tags = ["database", "connection-string"] + [rules.allowlist] + stopwords = ["localhost", "127.0.0.1", "changeme", "password", "example", "test_", "placeholder"] + +[[rules]] + id = "alembic-ini-sqlalchemy-url" + description = "SQLAlchemy URL in alembic.ini with credentials" + regex = '''sqlalchemy\.url\s*=\s*\S+://[^:\s]+:[^@\s]+@\S+''' + paths = ['''alembic\.ini$''', '''\.ini$'''] + tags = ["python", "alembic", "database"] + [rules.allowlist] + stopwords = ["localhost", "127.0.0.1", "changeme", "driver://user:pass"] + +[[rules]] + id = "dotenv-secret-value" + description = "High-entropy secret value in .env file" + regex = '''(?i)(?:SECRET|TOKEN|PASSWORD|KEY|CREDENTIALS|AUTH)[\w]*\s*=\s*['"]?[A-Za-z0-9/+=]{20,}['"]?\s*$''' + paths = ['''\.env$''', '''\.env\.\w+$'''] + tags = ["dotenv", "secret"] + [rules.allowlist] + stopwords = ["changeme", "placeholder", "example", "your_", "REPLACE", "TODO"] + +[[rules]] + id = "jdbc-url-with-password" + description = "JDBC connection string with embedded password" + regex = '''jdbc:[a-z]+://[^;\s]+password=[^;\s&]+''' + tags = ["java", "jdbc", "database"] + [rules.allowlist] + stopwords = ["changeme", "placeholder", "example"] + +[[rules]] + id = "dsn-inline-password" + description = "DSN-style connection string with inline password" + regex = '''(?i)(?:dsn|connection_string|conn_str)\s*[:=]\s*\S+://[^:\s]+:[^@\s]+@\S+''' + tags = ["database", "connection-string"] + [rules.allowlist] + stopwords = ["localhost", "127.0.0.1", "changeme", "example"] + +[[rules]] + id = "hardcoded-password-variable" + description = "Hardcoded password assignment in source code" + regex = '''(?i)(?:password|passwd|pwd)\s*[:=]\s*['"][^'"]{8,}['"]''' + tags = ["password", "hardcoded"] + [rules.allowlist] + stopwords = ["changeme", "placeholder", "example", "test", "dummy", "password123", "your_password"] + paths = [ + '''test[s]?/''', + '''spec[s]?/''', + '''__test__/''', + '''fixture[s]?/''', + '''mock[s]?/''', + ] + +[[rules]] + id = "bearer-token-in-code" + description = "Hardcoded bearer token in source code" + regex = '''(?i)['"]Bearer\s+[A-Za-z0-9\-._~+/]+=*['"]''' + tags = ["auth", "bearer", "token"] + [rules.allowlist] + stopwords = ["example", "test", "dummy", "placeholder", "fake"] + +[[rules]] + id = "spring-application-properties-password" + description = "Password in Spring Boot application properties" + regex = '''(?i)spring\.\w+\.password\s*=\s*\S+''' + paths = ['''application\.properties$''', '''application\.yml$''', '''application-\w+\.properties$''', '''application-\w+\.yml$'''] + tags = ["java", "spring", "password"] + [rules.allowlist] + stopwords = ["changeme", "placeholder", "${"] + +[[rules]] + id = "docker-compose-env-secret" + description = "Hardcoded secret in docker-compose environment" + regex = '''(?i)(?:POSTGRES_PASSWORD|MYSQL_ROOT_PASSWORD|MYSQL_PASSWORD|REDIS_PASSWORD|RABBITMQ_DEFAULT_PASS|MONGO_INITDB_ROOT_PASSWORD)\s*[:=]\s*['"]?[^\s'"$]{8,}['"]?''' + paths = ['''compose\.ya?ml$''', '''docker-compose\.ya?ml$'''] + tags = ["docker", "compose", "secret"] + [rules.allowlist] + stopwords = ["changeme", "placeholder", "example", "${"] + +[[rules]] + id = "terraform-variable-secret" + description = "Sensitive default value in Terraform variable" + regex = '''(?i)default\s*=\s*"[^"]{8,}"''' + paths = ['''variables\.tf$''', '''\.tf$'''] + tags = ["terraform", "secret"] + [rules.allowlist] + stopwords = ["changeme", "placeholder", "example", "TODO"] + +[[rules]] + id = "private-key-pem-inline" + description = "PEM-encoded private key in source" + regex = '''-----BEGIN\s+(?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----''' + tags = ["key", "pem", "private-key"] + +[[rules]] + id = "base64-encoded-secret" + description = "Base64 value assigned to secret-named variable" + regex = '''(?i)(?:secret|token|key|password|credentials)[\w]*\s*[:=]\s*['"]?[A-Za-z0-9+/]{40,}={0,2}['"]?''' + tags = ["base64", "encoded", "secret"] + [rules.allowlist] + stopwords = ["changeme", "placeholder", "example", "test"] + paths = [ + '''test[s]?/''', + '''spec[s]?/''', + '''fixture[s]?/''', + ] diff --git a/tools/quality/templates/monorepo/.husky/pre-commit b/tools/quality/templates/monorepo/.husky/pre-commit index 01e587e..b778287 100644 --- a/tools/quality/templates/monorepo/.husky/pre-commit +++ b/tools/quality/templates/monorepo/.husky/pre-commit @@ -1,2 +1,15 @@ npx lint-staged -npx git-secrets --scan || echo "Warning: git-secrets not installed" + +# Secret scanning — gitleaks is REQUIRED (not optional like git-secrets was) +if ! command -v gitleaks &>/dev/null; then + echo "" + echo "ERROR: gitleaks is not installed. Secret scanning is required." + echo "" + echo "Install:" + echo " Linux: curl -sSfL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_8.24.0_linux_x64.tar.gz | sudo tar -xz -C /usr/local/bin gitleaks" + echo " macOS: brew install gitleaks" + echo " Windows: winget install gitleaks" + echo "" + exit 1 +fi +gitleaks git --pre-commit --redact --staged --verbose diff --git a/tools/quality/templates/monorepo/.woodpecker.yml b/tools/quality/templates/monorepo/.woodpecker.yml index cee6671..82da1c0 100644 --- a/tools/quality/templates/monorepo/.woodpecker.yml +++ b/tools/quality/templates/monorepo/.woodpecker.yml @@ -4,11 +4,19 @@ when: variables: - &node_image "node:20-alpine" + - &gitleaks_image "ghcr.io/gitleaks/gitleaks:v8.24.0" - &install_deps | corepack enable npm ci --ignore-scripts steps: + # Secret scanning (runs in parallel with install, no deps) + secret-scan: + image: *gitleaks_image + commands: + - gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD" + depends_on: [] + install: image: *node_image commands: @@ -65,3 +73,4 @@ steps: - typecheck - test - security-audit + - secret-scan diff --git a/tools/quality/templates/typescript-nextjs/.husky/pre-commit b/tools/quality/templates/typescript-nextjs/.husky/pre-commit index 01e587e..b778287 100644 --- a/tools/quality/templates/typescript-nextjs/.husky/pre-commit +++ b/tools/quality/templates/typescript-nextjs/.husky/pre-commit @@ -1,2 +1,15 @@ npx lint-staged -npx git-secrets --scan || echo "Warning: git-secrets not installed" + +# Secret scanning — gitleaks is REQUIRED (not optional like git-secrets was) +if ! command -v gitleaks &>/dev/null; then + echo "" + echo "ERROR: gitleaks is not installed. Secret scanning is required." + echo "" + echo "Install:" + echo " Linux: curl -sSfL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_8.24.0_linux_x64.tar.gz | sudo tar -xz -C /usr/local/bin gitleaks" + echo " macOS: brew install gitleaks" + echo " Windows: winget install gitleaks" + echo "" + exit 1 +fi +gitleaks git --pre-commit --redact --staged --verbose diff --git a/tools/quality/templates/typescript-nextjs/.woodpecker.yml b/tools/quality/templates/typescript-nextjs/.woodpecker.yml index 1b0880d..69fcbda 100644 --- a/tools/quality/templates/typescript-nextjs/.woodpecker.yml +++ b/tools/quality/templates/typescript-nextjs/.woodpecker.yml @@ -4,11 +4,19 @@ when: variables: - &node_image "node:20-alpine" + - &gitleaks_image "ghcr.io/gitleaks/gitleaks:v8.24.0" - &install_deps | corepack enable npm ci --ignore-scripts steps: + # Secret scanning (runs in parallel with install, no deps) + secret-scan: + image: *gitleaks_image + commands: + - gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD" + depends_on: [] + install: image: *node_image commands: @@ -65,3 +73,4 @@ steps: - typecheck - test - security-audit + - secret-scan diff --git a/tools/quality/templates/typescript-node/.husky/pre-commit b/tools/quality/templates/typescript-node/.husky/pre-commit index 01e587e..b778287 100644 --- a/tools/quality/templates/typescript-node/.husky/pre-commit +++ b/tools/quality/templates/typescript-node/.husky/pre-commit @@ -1,2 +1,15 @@ npx lint-staged -npx git-secrets --scan || echo "Warning: git-secrets not installed" + +# Secret scanning — gitleaks is REQUIRED (not optional like git-secrets was) +if ! command -v gitleaks &>/dev/null; then + echo "" + echo "ERROR: gitleaks is not installed. Secret scanning is required." + echo "" + echo "Install:" + echo " Linux: curl -sSfL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_8.24.0_linux_x64.tar.gz | sudo tar -xz -C /usr/local/bin gitleaks" + echo " macOS: brew install gitleaks" + echo " Windows: winget install gitleaks" + echo "" + exit 1 +fi +gitleaks git --pre-commit --redact --staged --verbose diff --git a/tools/quality/templates/typescript-node/.woodpecker.yml b/tools/quality/templates/typescript-node/.woodpecker.yml index 5aab8cf..43a6db0 100644 --- a/tools/quality/templates/typescript-node/.woodpecker.yml +++ b/tools/quality/templates/typescript-node/.woodpecker.yml @@ -6,11 +6,19 @@ when: variables: - &node_image "node:20-alpine" + - &gitleaks_image "ghcr.io/gitleaks/gitleaks:v8.24.0" - &install_deps | corepack enable npm ci --ignore-scripts steps: + # Secret scanning (runs in parallel with install, no deps) + secret-scan: + image: *gitleaks_image + commands: + - gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD" + depends_on: [] + # Stage 1: Install install: image: *node_image @@ -64,3 +72,4 @@ steps: - typecheck - test - security-audit + - secret-scan -- 2.49.1