41 pages Β· 8 sections
Ctrl K
GitHub Portfolio

Secrets Management

Proper secrets management eliminates hardcoded credentials and enables secure, auditable access to sensitive resources across your infrastructure. A zero-hardcoded-secrets policy is foundational to cloud security.

Zero-Hardcoded-Secrets Policy

The zero-hardcoded-secrets policy mandates that no credential, token, key, or password may exist in source code, configuration files, container images, or environment variables in plaintext. This policy must be enforced through automated detection in CI/CD pipelines.

# secrets-policy.yaml β€” Organizational secrets policy
# Applies to all repositories, CI/CD pipelines, and infrastructure code

policy_name: Zero Hardcoded Secrets
severity: critical
enforcement: automated
scope:
  - source_code
  - configuration_files
  - container_images
  - infrastructure_as_code
  - documentation

prohibited_patterns:
  - AWS Access Key ID: "AKIA[0-9A-Z]{16}"
  - AWS Secret Key: "[A-Za-z0-9/+=]{40}"
  - Private Keys: "-----BEGIN (RSA|EC|DSA|OPENSSH) PRIVATE KEY-----"
  - API Keys Generic: "api[_-]?key\s*[:=]\s*['\"][a-zA-Z0-9]{20,}['\"]"
  - Password Assignments: "password\s*[:=]\s*['\"][^'\"\s]+['\"]"
  - Connection Strings: "(postgres|mysql|mongodb)://[^:]+:[^@]+@"
  - Slack Tokens: "xox[baprs]-[0-9a-zA-Z-]+"
  - GitHub Tokens: "gh[pousr]_[A-Za-z0-9_]{36,}"

enforcement_points:
  pre_commit:
    tool: git-secrets / GitLeaks
    action: block_commit
  
  pull_request:
    tool: GitLeaks + TruffleHog CI
    action: block_merge
    
  container_build:
    tool: Trivy filesystem scan
    action: fail_build
    
  scheduled:
    tool: GitGuardian
    action: create_incident_ticket

response_procedure:
  detection:
    - Immediately rotate the exposed secret
    - Revoke the old secret from the target system
    - Audit access logs for unauthorized usage
  
  remediation:
    - Remove secret from git history (BFG Repo-Cleaner or git-filter-repo)
    - Move secret to approved secrets manager
    - Update application to use dynamic secret retrieval
    
  verification:
    - Confirm secret no longer exists in any branch or history
    - Verify application functions with new secret source
    - Review scan results for additional findings

Secret Exposure is a P0 Incident

When a secret is committed to a repository, assume it is compromised. Even if deleted in a subsequent commit, it remains in git history. Secrets must be rotated immediately β€” cleanup of git history alone is insufficient.

Secrets Lifecycle

Secrets Lifecycle
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Creation   │───→│   Storage    │───→│   Rotation   │───→│  Revocation  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚                   β”‚                   β”‚                    β”‚
       β–Ό                   β–Ό                   β–Ό                    β–Ό
   Auto-generated     Encrypted at rest    Time-based TTL      Immediate on
   via API              (AES-256-GCM)      or event-triggered   detection/breach
   (min 32 bytes)      Access audited      Dual-active period   Auto-cleanup

Creation

  • Secrets must be auto-generated using cryptographically secure random number generators
  • Minimum length: 32 bytes for symmetric keys, 2048-bit RSA or P-256 ECDSA
  • Never use user-provided secrets for service-to-service authentication

Storage

  • Encrypt at rest using AES-256-GCM or equivalent
  • Encryption keys managed by HSM or cloud KMS service
  • All access audited with immutable audit logs

Rotation

  • Time-based: Automatic rotation at configurable intervals
  • Event-based: Triggered by employee termination, breach suspicion, or policy change
  • Dual-active rotation: New secret created and deployed before old secret expires

Revocation

  • Immediate revocation capability with <60 second propagation
  • Automated revocation on anomaly detection
  • Audit trail of all revocations with reason code

AWS Secrets Manager

Setup and Configuration

# terraform/aws-secrets-manager.tf

resource "aws_secretsmanager_secret" "database_password" {
  name                    = "production/database/password"
  description             = "Primary database password for production API"
  kms_key_id              = aws_kms_key.secrets.arn
  recovery_window_in_days = 30
  
  tags = {
    Environment = "production"
    Application = "api-gateway"
    AutoRotate  = "true"
    ManagedBy   = "terraform"
  }
}

resource "aws_secretsmanager_secret_version" "database_password" {
  secret_id     = aws_secretsmanager_secret.database_password.id
  secret_string = jsonencode({
    username = "api_app"
    password = random_password.database_password.result
    engine   = "postgres"
    host     = aws_db_instance.primary.address
    port     = 5432
    dbname   = "api_production"
  })
}

resource "random_password" "database_password" {
  length           = 32
  special          = true
  override_special = "!#$%&*()-_=+[]{}<>:?."
  min_upper        = 5
  min_lower        = 5
  min_numeric      = 5
  min_special      = 3
}

resource "aws_kms_key" "secrets" {
  description             = "KMS key for Secrets Manager encryption"
  deletion_window_in_days = 30
  enable_key_rotation     = true
  multi_region            = true
  
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "Enable IAM User Permissions"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "Allow Secrets Manager"
        Effect = "Allow"
        Principal = {
          Service = "secretsmanager.amazonaws.com"
        }
        Action = [
          "kms:Encrypt",
          "kms:Decrypt",
          "kms:GenerateDataKey*",
          "kms:DescribeKey"
        ]
        Resource = "*"
      }
    ]
  })
}

Automatic Rotation Lambda

# lambda/secrets_rotation.py β€” RDS PostgreSQL password rotation
import json
import boto3
import psycopg2
import logging
from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    """Rotate PostgreSQL database credentials via Secrets Manager."""
    
    arn = event['SecretId']
    token = event['ClientRequestToken']
    step = event['Step']
    
    secrets_client = boto3.client('secretsmanager')
    
    # Get secret metadata
    metadata = secrets_client.describe_secret(SecretId=arn)
    
    if not metadata['RotationEnabled']:
        raise ValueError(f"Secret {arn} does not have rotation enabled")
    
    versions = metadata['VersionIdsToStages']
    if token not in versions:
        raise ValueError(f"Secret version {token} has no stage for rotation")
    if 'AWSCURRENT' in versions[token]:
        logger.info("Version already current")
        return
    elif 'AWSPENDING' not in versions[token]:
        raise ValueError(f"Version {token} not set as AWSPENDING")
    
    if step == 'createSecret':
        create_secret(secrets_client, arn, token)
    elif step == 'setSecret':
        set_secret(secrets_client, arn, token)
    elif step == 'testSecret':
        test_secret(secrets_client, arn, token)
    elif step == 'finishSecret':
        finish_secret(secrets_client, arn, token)
    else:
        raise ValueError(f"Invalid step: {step}")

def create_secret(secrets_client, arn, token):
    """Generate a new secret and store as AWSPENDING."""
    try:
        secrets_client.get_secret_value(
            SecretId=arn, VersionId=token, VersionStage='AWSPENDING'
        )
        logger.info("Pending version already exists")
    except ClientError:
        # Generate new password
        exclude_chars = '/@"\\'
        new_password = secrets_client.get_random_password(
            PasswordLength=32,
            ExcludeCharacters=exclude_chars,
            IncludeSpace=False
        )['RandomPassword']
        
        # Get current secret to copy structure
        current = secrets_client.get_secret_value(
            SecretId=arn, VersionStage='AWSCURRENT'
        )
        current_dict = json.loads(current['SecretString'])
        current_dict['password'] = new_password
        
        secrets_client.put_secret_value(
            SecretId=arn,
            ClientRequestToken=token,
            SecretString=json.dumps(current_dict),
            VersionStages=['AWSPENDING']
        )
        logger.info("Created pending secret version")

def set_secret(secrets_client, arn, token):
    """Update the actual database password."""
    pending = secrets_client.get_secret_value(
        SecretId=arn, VersionId=token, VersionStage='AWSPENDING'
    )
    secret = json.loads(pending['SecretString'])
    
    conn = psycopg2.connect(
        host=secret['host'],
        port=secret['port'],
        database='postgres',
        user=secret['username'],
        password=secret['password']  # Uses pending (new) password
    )
    conn.autocommit = True
    
    try:
        with conn.cursor() as cursor:
            cursor.execute(
                "ALTER USER %s WITH PASSWORD %s",
                (secret['username'], secret['password'])
            )
        logger.info("Database password updated")
    except psycopg2.Error as e:
        logger.error(f"Failed to update password: {e}")
        raise
    finally:
        conn.close()

def test_secret(secrets_client, arn, token):
    """Verify the new password works."""
    pending = secrets_client.get_secret_value(
        SecretId=arn, VersionId=token, VersionStage='AWSPENDING'
    )
    secret = json.loads(pending['SecretString'])
    
    conn = psycopg2.connect(
        host=secret['host'],
        port=secret['port'],
        database=secret['dbname'],
        user=secret['username'],
        password=secret['password']
    )
    conn.close()
    logger.info("Successfully tested new credentials")

def finish_secret(secrets_client, arn, token):
    """Promote AWSPENDING to AWSCURRENT."""
    metadata = secrets_client.describe_secret(SecretId=arn)
    
    current_version = None
    for version, stages in metadata['VersionIdsToStages'].items():
        if 'AWSCURRENT' in stages:
            current_version = version
            break
    
    secrets_client.update_secret_version_stage(
        SecretId=arn,
        VersionStage='AWSCURRENT',
        MoveToVersionId=token,
        RemoveFromVersionId=current_version
    )
    logger.info("Rotation complete β€” pending is now current")
# terraform/secrets-rotation.tf (continued)

# IAM role for rotation Lambda
resource "aws_iam_role" "secrets_rotation" {
  name = "secrets-rotation-lambda"
  
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy" "secrets_rotation" {
  name = "secrets-rotation-policy"
  role = aws_iam_role.secrets_rotation.id
  
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "secretsmanager:DescribeSecret",
          "secretsmanager:GetSecretValue",
          "secretsmanager:PutSecretValue",
          "secretsmanager:UpdateSecretVersionStage"
        ]
        Resource = aws_secretsmanager_secret.database_password.arn
      },
      {
        Effect = "Allow"
        Action = [
          "secretsmanager:GetRandomPassword"
        ]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "arn:aws:logs:*:*:*"
      }
    ]
  })
}

# Rotation Lambda function
resource "aws_lambda_function" "secrets_rotation" {
  function_name = "db-password-rotation"
  role          = aws_iam_role.secrets_rotation.arn
  handler       = "secrets_rotation.lambda_handler"
  runtime       = "python3.11"
  timeout       = 30
  filename      = "${path.module}/lambda/secrets_rotation.zip"
  
  vpc_config {
    subnet_ids         = var.private_subnet_ids
    security_group_ids = [var.lambda_security_group_id]
  }
}

# Enable rotation
resource "aws_secretsmanager_secret_rotation" "database" {
  secret_id           = aws_secretsmanager_secret.database_password.id
  rotation_lambda_arn = aws_lambda_function.secrets_rotation.arn
  
  rotation_rules {
    automatically_after_days = 30
    schedule_expression      = "rate(30 days)"
  }
}

Application Secret Reference

# app/config.py β€” AWS Secrets Manager integration
import boto3
import json
import os
from functools import lru_cache
from botocore.exceptions import ClientError

class SecretManager:
    """Production-grade secrets manager with caching and failover."""
    
    def __init__(self, region=None):
        self.region = region or os.environ.get('AWS_REGION', 'us-east-1')
        self.client = boto3.client('secretsmanager', region_name=self.region)
        self._local_cache = {}
    
    @lru_cache(maxsize=128)
    def get_secret(self, secret_name, version_stage='AWSCURRENT'):
        """Retrieve secret with caching and error handling."""
        try:
            response = self.client.get_secret_value(
                SecretId=secret_name,
                VersionStage=version_stage
            )
            
            if 'SecretString' in response:
                return json.loads(response['SecretString'])
            else:
                # Binary secret
                return response['SecretBinary']
                
        except ClientError as e:
            error_code = e.response['Error']['Code']
            if error_code == 'ResourceNotFoundException':
                raise ValueError(f"Secret {secret_name} not found")
            elif error_code == 'InvalidRequestException':
                raise RuntimeError(f"Secret {secret_name} has invalid request")
            elif error_code == 'InvalidParameterException':
                raise ValueError(f"Invalid parameter for {secret_name}")
            raise
    
    def get_database_url(self):
        """Get formatted database URL from secret."""
        secret = self.get_secret("production/database/password")
        return (
            f"postgresql://{secret['username']}:{secret['password']}"
            f"@{secret['host']}:{secret['port']}/{secret['dbname']}"
        )

# Usage
secrets = SecretManager()
DATABASE_URL = secrets.get_database_url()

Azure Key Vault

# terraform/azure-keyvault.tf

resource "azurerm_key_vault" "main" {
  name                       = "kv-${var.project}-${var.environment}"
  location                   = azurerm_resource_group.main.location
  resource_group_name        = azurerm_resource_group.main.name
  tenant_id                  = data.azurerm_client_config.current.tenant_id
  sku_name                   = "premium"  # HSM-backed keys
  soft_delete_retention_days = 90
  purge_protection_enabled   = true
  enable_rbac_authorization  = true  # Use RBAC instead of access policies
  
  network_acls {
    default_action             = "Deny"
    bypass                     = "AzureServices"
    ip_rules                   = var.allowed_cidr_blocks
    virtual_network_subnet_ids = [var.app_subnet_id]
  }
}

# RBAC assignment for application
resource "azurerm_role_assignment" "app_secret_user" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_user_assigned_identity.app.principal_id
}

# Store a secret
resource "azurerm_key_vault_secret" "database_password" {
  name         = "database-password"
  value        = random_password.database.result
  key_vault_id = azurerm_key_vault.main.id
  
  content_type = "text/plain"
  tags = {
    environment = var.environment
    rotation    = "enabled"
  }
  
  # Expiration forces rotation
  expiration_date = timeadd(timestamp(), "2160h")  # 90 days
}

Application Integration

# Azure Key Vault reference in Python
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
import os

credential = DefaultAzureCredential()
client = SecretClient(
    vault_url=f"https://{os.environ['KEY_VAULT_NAME']}.vault.azure.net/",
    credential=credential
)

# Retrieve secret
db_password = client.get_secret("database-password").value

Kubernetes Secrets (and Why to Avoid Default)

Never Use Default Kubernetes Secrets for Sensitive Data

By default, Kubernetes secrets are base64-encoded β€” not encrypted. They are stored in etcd in plaintext unless etcd encryption is enabled. Anyone with read access to the namespace can read all secrets.

Enable etcd Encryption at Rest

# k8s/etcd-encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
      - configmaps
    providers:
      - kms:
          name: aws-kms
          endpoint: unix:///var/run/k8s-kms-plugin/socket.sock
          cachesize: 1000
          timeout: 3s
      - aescbc:
          keys:
            - name: key1
              secret: <base64-encoded-32-byte-key>
      - identity: {}  # Fallback β€” remove in production

External Secrets Operator

# k8s/external-secrets/helm-release.yaml
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
  name: external-secrets
  namespace: external-secrets
spec:
  interval: 1h
  chart:
    spec:
      chart: external-secrets
      version: "0.9.x"
      sourceRef:
        kind: HelmRepository
        name: external-secrets
        namespace: flux-system
  values:
    installCRDs: true
    serviceAccount:
      annotations:
        eks.amazonaws.com/role_arn: arn:aws:iam::123456789:role/external-secrets
    webhook:
      port: 10250
    certController:
      create: true
# k8s/external-secrets/secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-secrets-manager
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets
---
# k8s/external-secrets/external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: database-credentials
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-secrets-manager
  target:
    name: database-credentials
    creationPolicy: Owner
    template:
      type: Opaque
      data:
        DATABASE_URL: "postgresql://{{ .username }}:{{ .password }}@{{ .host }}:{{ .port }}/{{ .dbname }}"
  data:
    - secretKey: username
      remoteRef:
        key: production/database/password
        property: username
    - secretKey: password
      remoteRef:
        key: production/database/password
        property: password
    - secretKey: host
      remoteRef:
        key: production/database/password
        property: host
    - secretKey: port
      remoteRef:
        key: production/database/password
        property: port
    - secretKey: dbname
      remoteRef:
        key: production/database/password
        property: dbname

Sealed Secrets (Bitnami) for GitOps

# Install Sealed Secrets controller
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
  --namespace kube-system \
  --set-string fullnameOverride=sealed-secrets-controller

# Install kubeseal CLI
# macOS
brew install kubeseal
# Linux
wget https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/kubeseal-0.24.0-linux-amd64.tar.gz
tar -xzf kubeseal-0.24.0-linux-amd64.tar.gz
sudo mv kubeseal /usr/local/bin/
# k8s/sealed-secrets/original-secret.yaml
# Create a regular secret locally (never commit this file!)
apiVersion: v1
kind: Secret
metadata:
  name: database-credentials
  namespace: production
type: Opaque
stringData:
  DATABASE_URL: "postgresql://app_user:secret123@db.internal:5432/app"
  API_KEY: "sk_live_abc123"
# Seal the secret for GitOps
kubeseal --controller-namespace=kube-system \
  --controller-name=sealed-secrets-controller \
  --format yaml \
  < k8s/secrets/original-secret.yaml \
  > k8s/sealed-secrets/database-credentials.yaml

# The sealed secret is safe to commit to git
cat k8s/sealed-secrets/database-credentials.yaml
# k8s/sealed-secrets/database-credentials.yaml β€” Safe to commit
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: database-credentials
  namespace: production
spec:
  encryptedData:
    DATABASE_URL: AgAr4t7Z...(encrypted blobοΌ‰...
    API_KEY: AgBt8xK2...(encrypted blobοΌ‰...
  template:
    type: Opaque

Secret Rotation Strategies

StrategyDescriptionRTOComplexity
Single ActiveReplace secret, brief outage during switchMinutesLow
Dual ActiveNew secret created, both valid during transitionZeroMedium
Canary RotationRotate on subset of instances firstZeroHigh
Break-GlassEmergency immediate rotationDependsLow

Dual-Active Rotation Implementation

# secret_rotator.py β€” Dual-active rotation with zero downtime
import boto3
import time
from datetime import datetime, timedelta

class DualActiveRotator:
    """Rotates secrets with dual-active period for zero downtime."""
    
    DUAL_ACTIVE_MINUTES = 15
    
    def __init__(self, secret_arn):
        self.client = boto3.client('secretsmanager')
        self.secret_arn = secret_arn
    
    def rotate(self):
        """Execute dual-active rotation."""
        # Step 1: Get current secret
        current = self.client.get_secret_value(
            SecretId=self.secret_arn,
            VersionStage='AWSCURRENT'
        )
        current_secret = json.loads(current['SecretString'])
        
        # Step 2: Generate new secret
        new_password = self._generate_password()
        
        # Step 3: Update target system to accept BOTH passwords
        self._enable_dual_auth(current_secret['password'], new_password)
        
        # Step 4: Create new secret version
        new_secret_data = current_secret.copy()
        new_secret_data['password'] = new_password
        
        response = self.client.put_secret_value(
            SecretId=self.secret_arn,
            SecretString=json.dumps(new_secret_data),
            VersionStages=['AWSPENDING']
        )
        new_version_id = response['VersionId']
        
        # Step 5: Wait for propagation
        time.sleep(60)
        
        # Step 6: Promote new version to CURRENT
        self.client.update_secret_version_stage(
            SecretId=self.secret_arn,
            VersionStage='AWSCURRENT',
            MoveToVersionId=new_version_id,
            RemoveFromVersionId=current['VersionId']
        )
        
        # Step 7: Wait for dual-active period
        print(f"Dual-active period: {self.DUAL_ACTIVE_MINUTES} minutes")
        time.sleep(self.DUAL_ACTIVE_MINUTES * 60)
        
        # Step 8: Disable old password
        self._disable_old_auth(current_secret['password'])
        
        print(f"Rotation complete at {datetime.utcnow().isoformat()}")
    
    def _generate_password(self):
        import secrets
        return secrets.token_urlsafe(32)
    
    def _enable_dual_auth(self, old_pw, new_pw):
        """Configure system to accept both passwords."""
        # Implementation depends on target system
        pass
    
    def _disable_old_auth(self, old_pw):
        """Remove old password from target system."""
        pass

Secrets Scanning in CI/CD

GitLeaks Configuration

# .gitleaks.toml β€” Comprehensive secrets detection
title = "GitLeaks Configuration"

[extend]
# Use default rules plus custom
useDefault = true

[[rules]]
id = "company-api-key"
description = "Company internal API key"
regex = '''(?i)(company_api_key|compkey)[\s]*[:=][\s]*['"][a-zA-Z0-9]{32,}['"]'''
tags = ["apikey", "company"]
secretGroup = 1

[[rules]]
id = "internal-jwt"
description = "Internal JWT signing key"
regex = '''(?i)(jwt_signing_key|jwt_secret)[\s]*[:=][\s]*['"][^'"]{20,}['"]'''
tags = ["jwt", "key"]

[[rules]]
# Allowlist for test fixtures
[rules.allowlist]
paths = [
  '''test/fixtures/''',
  '''_test\.go$''',
  '''\.test\.''',
  '''test/'''
]
regexes = [
  '''test-password-123''',
  '''EXAMPLE_KEY'''
]

[allowlist]
paths = [
  '''\.gitleaks\.toml$''',
  '''(.*?)(jpg|gif|doc|pdf|bin)$'''
]
commits = [
  "a1b2c3d4",  # Initial commit with test fixtures
]

TruffleHog Configuration

# .trufflehog.yml β€” Deep scanning with entropy detection
version: 3

# Verify detected secrets against live services
verification:
  enabled: true
  timeout: 30s
  # Exclude known test keys from verification
  exclusions:
    - "AKIAIOSFODNN7EXAMPLE"
    - "sk_test_"

# Scan scope
scan:
  # Include all files
  paths:
    - .
  
  # Exclude patterns
  exclude:
    paths:
      - "vendor/"
      - "node_modules/"
      - "\.git/"
      - "test/fixtures/"
    extensions:
      - ".lock"
      - ".sum"
    
# Entropy detection for unknown secrets
entropy:
  enabled: true
  min_entropy: 4.5
  # Per-character set entropy thresholds
  per_char_set:
    hex: 3.0
    base64: 4.0
    base58: 4.5

# Detector configuration
detectors:
  # Enable all built-in detectors
  - name: all
    enabled: true
  
  # Custom detector for internal patterns
  - name: custom
    regex:
      company_api_key: "(?i)company[_-]?api[_-]?key\\s*[:=]\\s*['\"]([a-f0-9]{32})['\"]"
    verification:
      endpoint: "https://api.internal.company.com/health"
      headers:
        Authorization: "Bearer {match}"
      valid_status: [200]

Complete CI/CD Security Pipeline

# .github/workflows/secrets-scan.yml
name: Secrets Security Scan

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * *'  # Daily at 6 AM UTC

jobs:
  gitleaks:
    name: GitLeaks Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for proper scanning
      
      - name: Run GitLeaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITLEAKS_CONFIG: .gitleaks.toml
      
      - name: Upload Report
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: gitleaks-report
          path: gitleaks-report.sarif

  trufflehog:
    name: TruffleHog Deep Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      - name: Run TruffleHog
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: main
          head: HEAD
          extra_args: --debug --only-verified

  # Fail the workflow if ANY scanner finds secrets
  security-gate:
    name: Security Gate
    needs: [gitleaks, trufflehog]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Check Results
        run: |
          if [ "${{ needs.gitleaks.result }}" == "failure" ] || \
             [ "${{ needs.trufflehog.result }}" == "failure" ]; then
            echo "::error::Secrets detected! Review scanner output above."
            exit 1
          fi
          echo "No secrets detected β€” security gate passed."

Pre-Commit Hook

Install the pre-commit hook to catch secrets before they enter the repository:

# .git/hooks/pre-commit or use pre-commit framework
#!/bin/bash
# Install: pre-commit install
# Config: .pre-commit-config.yaml

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
  
  - repo: https://github.com/trufflesecurity/trufflehog
    rev: v3.63.0
    hooks:
      - id: trufflehog
        args: ["filesystem", "."]

Related Topics