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
| Strategy | Description | RTO | Complexity |
|---|---|---|---|
| Single Active | Replace secret, brief outage during switch | Minutes | Low |
| Dual Active | New secret created, both valid during transition | Zero | Medium |
| Canary Rotation | Rotate on subset of instances first | Zero | High |
| Break-Glass | Emergency immediate rotation | Depends | Low |
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
- HashiCorp Vault β Centralized secrets management platform
- Code Security β Supply chain security and pre-commit hooks
- SecOps Overview β Security frameworks and metrics