/secrets-scan Workflow
Output Rules
- Always print full absolute paths for all artifact references (plan files, review files, audit logs). This makes paths clickable in terminals like Warp. Use the resolved
$PLANS_DIRvalue, never relative paths.
Role
This skill is a pipeline coordinator. It orchestrates a sequential secrets detection workflow using pattern-based scanning. It delegates grep/regex scanning to Bash and synthesis to analysis tasks. It does NOT require external tools like trufflehog or gitleaks — all scanning uses built-in grep patterns, making it self-contained and deployable anywhere Claude Code runs.
Zero tolerance policy: Any confirmed secret detected results in a BLOCKED verdict. There is no passing threshold — secrets in code are a critical finding.
Report redaction rule: This skill NEVER includes actual secret values in reports. Reports show secret type, file path, and line number only. Pattern matches are redacted to show type and location: e.g., "AWS Access Key at src/config.js:42".
Inputs
- Scan scope: $ARGUMENTS
staged(default) — scan git staged files only (pre-commit gate)all— scan entire working directoryhistory— scan git commit history (use for post-incident review)
Step 0 — Pre-flight checks
Resolve devkit paths (MUST be first action in Step 0):
Tool: Bash
# --- Devkit Path Resolution ---
DEVKIT_SCRIPTS="${CLAUDE_DEVKIT:-$HOME/.claude-devkit}/scripts"
# Source path resolution helper
if [ -f "$DEVKIT_SCRIPTS/resolve-project-dir.sh" ]; then
. "$DEVKIT_SCRIPTS/resolve-project-dir.sh"
DEVKIT_PROJECT_DIR_RESOLVED=$(resolve_devkit_project_dir) || {
echo "Failed to resolve project directory" >&2; exit 1
}
elif [ -n "${DEVKIT_PROJECT_DIR:-}" ]; then
DEVKIT_PROJECT_DIR_RESOLVED="$DEVKIT_PROJECT_DIR"
else
echo "WARNING: devkit is not installed. Using deprecated .devkit/ fallback." >&2
DEVKIT_PROJECT_DIR_RESOLVED=".devkit"
fi
PLANS_DIR="$DEVKIT_PROJECT_DIR_RESOLVED/plans"
mkdir -p "$PLANS_DIR"
echo "Plans directory: $PLANS_DIR"
Tool: Bash (direct — coordinator does this)
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
echo "Secrets scan run: $TIMESTAMP"
# Verify we are in a git repository
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "ERROR: Not inside a git repository. /secrets-scan requires git."
exit 1
fi
# Determine scope from arguments
SCOPE="${1:-staged}"
if [ "$SCOPE" != "staged" ] && [ "$SCOPE" != "all" ] && [ "$SCOPE" != "history" ]; then
echo "ERROR: Invalid scope '$SCOPE'. Valid options: staged, all, history"
echo "Usage: /secrets-scan [staged|all|history]"
exit 1
fi
# For staged scope: check that there are staged files
if [ "$SCOPE" = "staged" ]; then
STAGED_FILES=$(git diff --cached --name-only)
if [ -z "$STAGED_FILES" ]; then
echo "No staged files found. Stage files with 'git add' before running /secrets-scan staged."
echo "VERDICT: PASS (no staged files to scan)"
exit 0
fi
echo "Staged files: $(echo "$STAGED_FILES" | wc -l | tr -d ' ') file(s)"
fi
echo "SCOPE=$SCOPE"
echo "TIMESTAMP=$TIMESTAMP"
Pre-flight failures:
- Not a git repo: Stop with error message.
- Invalid scope argument: Stop with usage message.
- No staged files (staged scope): Output PASS with explanation and stop.
Step 1 — Determine scan scope and collect target content
Tool: Bash (direct — coordinator does this)
Based on the scope from Step 0, collect the content to scan:
SCAN_TARGET_FILE=$(mktemp /tmp/secrets-scan-XXXXXXXX.tmp)
case "$SCOPE" in
staged)
# Scan staged content (what would be committed)
# git diff --cached shows the actual content being staged
git diff --cached -U0 2>/dev/null > "$SCAN_TARGET_FILE"
echo "Collected staged diff for scanning ($(wc -l < "$SCAN_TARGET_FILE") lines)"
;;
all)
# Scan all tracked and untracked files in working directory
# Exclude common binary and generated directories
FILELIST_TMP=$(mktemp /tmp/secrets-scan-filelist-XXXXXXXX.tmp)
FILELIST_FILTERED_TMP=$(mktemp /tmp/secrets-scan-filelist-XXXXXXXX.tmp)
git ls-files 2>/dev/null > "$FILELIST_TMP"
git ls-files --others --exclude-standard 2>/dev/null >> "$FILELIST_TMP"
# Filter out binary-likely extensions and large generated files
grep -v -E '\.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|pdf|zip|tar|gz|bin|exe|dll|so|dylib|pyc|class)$' \
"$FILELIST_TMP" | \
grep -v -E '(node_modules/|\.git/|vendor/|dist/|build/|__pycache__/)' > "$FILELIST_FILTERED_TMP"
# Read all filtered files into scan target
while IFS= read -r f; do
[ -f "$f" ] && echo "=== FILE: $f ===" >> "$SCAN_TARGET_FILE" && cat "$f" >> "$SCAN_TARGET_FILE"
done < "$FILELIST_FILTERED_TMP"
rm -f "$FILELIST_TMP" "$FILELIST_FILTERED_TMP" 2>/dev/null
echo "Collected working directory content for scanning"
;;
history)
# Scan git log content (recent commits — last 50 commits or since last tag)
git log --oneline -50 --format="%H" 2>/dev/null | while read -r commit; do
git show "$commit" 2>/dev/null >> "$SCAN_TARGET_FILE"
done
echo "Collected git history (last 50 commits) for scanning"
;;
esac
Step 2 — Pattern-based secret detection
Tool: Bash (direct — coordinator does this)
Run pattern-based detection across the collected scan target. Each pattern targets a distinct secret type.
Note: Pattern-based detection only. Entropy analysis not yet included; planned for a future release after false-positive calibration against real codebases.
FINDINGS_FILE="$PLANS_DIR/secrets-scan-${TIMESTAMP}.raw-findings.txt"
touch "$FINDINGS_FILE"
echo "=== PATTERN SCAN RESULTS ===" >> "$FINDINGS_FILE"
echo "Timestamp: $TIMESTAMP" >> "$FINDINGS_FILE"
echo "Scope: $SCOPE" >> "$FINDINGS_FILE"
echo "" >> "$FINDINGS_FILE"
# Pattern 1: AWS Access Keys (AKIA... format — 20 char alphanumeric after AKIA)
echo "--- AWS Access Keys (AKIA...) ---" >> "$FINDINGS_FILE"
grep -n -E 'AKIA[0-9A-Z]{16}' "$SCAN_TARGET_FILE" | \
sed 's/\(AKIA[0-9A-Z]\{4\}\)[0-9A-Z]*/\1****REDACTED/' >> "$FINDINGS_FILE" || true
# Pattern 2: AWS Secret Access Keys (40-char base64-like strings after known label)
echo "--- AWS Secret Access Keys ---" >> "$FINDINGS_FILE"
grep -n -E -i '(aws_secret_access_key|aws_secret_key)\s*[=:]\s*[A-Za-z0-9/+=]{40}' "$SCAN_TARGET_FILE" | \
sed 's/\([A-Za-z0-9\/+=]\{4\}\)[A-Za-z0-9\/+=]\{32\}\([A-Za-z0-9\/+=]\{4\}\)/\1****REDACTED****\2/' >> "$FINDINGS_FILE" || true
# Pattern 3: GitHub Personal Access Tokens (ghp_, gho_, ghu_, ghs_, ghr_ prefixes)
echo "--- GitHub Tokens (ghp_/gho_/ghu_/ghs_/ghr_) ---" >> "$FINDINGS_FILE"
grep -n -E '(ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9_]{36}' "$SCAN_TARGET_FILE" | \
sed 's/\(gh[a-z]_[A-Za-z0-9_]\{4\}\)[A-Za-z0-9_]*/\1****REDACTED/' >> "$FINDINGS_FILE" || true
# Pattern 4: Private key headers (RSA, EC, OpenSSH, DSA)
echo "--- Private Key Material ---" >> "$FINDINGS_FILE"
grep -n -E '-----BEGIN (RSA |EC |DSA |OPENSSH |PRIVATE )PRIVATE KEY-----' "$SCAN_TARGET_FILE" >> "$FINDINGS_FILE" || true
# Pattern 5: Generic high-confidence password patterns (labeled assignments)
echo "--- Generic Passwords (labeled) ---" >> "$FINDINGS_FILE"
grep -n -E -i '(password|passwd|pwd|secret|api_key|apikey|api_secret|client_secret|auth_token|access_token)\s*[=:]\s*['''"][^'''"]{8,}['''"]' "$SCAN_TARGET_FILE" | \
sed "s/\(=\s*['\"][^'\"]\{4\}\)[^'\"]*\([^'\"]\{4\}['\"]\)/\1****REDACTED****\2/" >> "$FINDINGS_FILE" || true
# Pattern 6: Database connection strings with embedded credentials
echo "--- Database Connection Strings ---" >> "$FINDINGS_FILE"
grep -n -E '(mysql|postgresql|postgres|mongodb|redis|amqp|jdbc)://[^@\s]+:[^@\s]+@' "$SCAN_TARGET_FILE" | \
sed 's|://\([^:]*\):[^@]*@|://\1:****REDACTED****@|' >> "$FINDINGS_FILE" || true
# Pattern 7: JWT tokens (3-part base64 separated by dots, starting with eyJ)
echo "--- JWT Tokens ---" >> "$FINDINGS_FILE"
grep -n -E 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' "$SCAN_TARGET_FILE" | \
sed 's/\(eyJ[A-Za-z0-9_-]\{6\}\)[A-Za-z0-9_-]*\(\.[A-Za-z0-9_-]\{4\}\).*/\1****REDACTED***\2.../' >> "$FINDINGS_FILE" || true
# Pattern 8: Slack tokens (xox[bpaso]-...)
echo "--- Slack Tokens ---" >> "$FINDINGS_FILE"
grep -n -E 'xox[bpaso]-[A-Za-z0-9-]{10,}' "$SCAN_TARGET_FILE" | \
sed 's/\(xox[bpaso]-[A-Za-z0-9-]\{6\}\)[A-Za-z0-9-]*/\1****REDACTED/' >> "$FINDINGS_FILE" || true
# Pattern 9: Google API keys (AIza...)
echo "--- Google API Keys ---" >> "$FINDINGS_FILE"
grep -n -E 'AIza[0-9A-Za-z_-]{35}' "$SCAN_TARGET_FILE" | \
sed 's/\(AIza[0-9A-Za-z_-]\{4\}\)[0-9A-Za-z_-]*/\1****REDACTED/' >> "$FINDINGS_FILE" || true
# Pattern 10: Stripe API keys (sk_live_, pk_live_, sk_test_, rk_live_)
echo "--- Stripe API Keys ---" >> "$FINDINGS_FILE"
grep -n -E '(sk_live_|pk_live_|rk_live_|sk_test_)[0-9a-zA-Z]{24,}' "$SCAN_TARGET_FILE" | \
sed 's/\(\(sk\|pk\|rk\)_\(live\|test\)_[0-9a-zA-Z]\{6\}\)[0-9a-zA-Z]*/\1****REDACTED/' >> "$FINDINGS_FILE" || true
echo "" >> "$FINDINGS_FILE"
echo "=== RAW SCAN COMPLETE ===" >> "$FINDINGS_FILE"
# Count non-empty finding lines (excluding headers/separators)
FINDING_LINES=$(grep -v -E '^(---|===|Timestamp|Scope|$)' "$FINDINGS_FILE" | grep -c '[0-9]' || true)
echo "Raw matches found: $FINDING_LINES"
# Delete unredacted scan target immediately after pattern scan
rm -f "$SCAN_TARGET_FILE"
Insecure Default Patterns
In addition to secret patterns above, scan for fail-open insecure defaults that allow the application to run with weak security in production.
Patterns to detect:
Fallback secrets in env var handling: Matches:
os.environ.get("SECRET_KEY", "my-default-secret")Excludes: Empty strings,None, short placeholder valuesHardcoded default credentials: Matches common default credentials (
admin:admin,password: changeme, etc.)Fail-open security toggles: Named security controls explicitly set to disabled/off.
Weak cryptographic defaults: Only flag when used in security contexts (hashing passwords, encrypting data, signing tokens). Do NOT flag when used in checksums, cache keys, or non-security file hashing.
Permissive access defaults: CORS wildcards, world-readable permissions, and allow-all access controls.
Debug/development mode flags: Only flag in production configuration files and application defaults. Do NOT flag in test fixtures, development-only configs, or CI configurations.
echo "" >> "$FINDINGS_FILE"
echo "=== INSECURE DEFAULT PATTERNS ===" >> "$FINDINGS_FILE"
# Pattern 11: Fallback secrets in env var handling
echo "--- Fallback Secrets in Env Var Handling ---" >> "$FINDINGS_FILE"
grep -rnE "(environ\.get|getenv|ENV\[|process\.env)\s*\(?\s*['\"][^'\"]+['\"]\s*,\s*['\"][^'\"]{4,}['\"]" --include="*.py" --include="*.rb" --include="*.js" --include="*.ts" . >> "$FINDINGS_FILE" || true
# Pattern 12: Hardcoded default credentials
echo "--- Hardcoded Default Credentials ---" >> "$FINDINGS_FILE"
grep -rnEi "(admin[:/]admin|password\s*[:=]\s*['\"]?(password|changeme|default|admin|root|test123)['\"]?)" --include="*.py" --include="*.js" --include="*.ts" --include="*.yaml" --include="*.yml" --include="*.json" --include="*.rb" --include="*.go" --include="*.java" . >> "$FINDINGS_FILE" || true
# Pattern 13: Fail-open security toggles
echo "--- Fail-Open Security Toggles ---" >> "$FINDINGS_FILE"
grep -rnEi "(AUTH_REQUIRED|VERIFY_SSL|CSRF_ENABLED|SECURE_COOKIES|REQUIRE_AUTH|ENABLE_AUTH)\s*[:=]\s*\b(False|false|0|no|off)\b" --include="*.py" --include="*.js" --include="*.ts" --include="*.yaml" --include="*.yml" --include="*.env" --include="*.rb" --include="*.go" . >> "$FINDINGS_FILE" || true
# Pattern 14: Weak cryptographic defaults
echo "--- Weak Cryptographic Defaults ---" >> "$FINDINGS_FILE"
grep -rnEi "\b(md5|sha1(?!_)|des[^cir]|rc4|ecb|blowfish)\b" --include="*.py" --include="*.js" --include="*.ts" --include="*.java" --include="*.go" --include="*.rb" --include="*.cs" . >> "$FINDINGS_FILE" || true
# Pattern 15: Permissive access defaults
echo "--- Permissive Access Defaults ---" >> "$FINDINGS_FILE"
grep -rnE "(Access-Control-Allow-Origin.*\*|cors.*origin.*\*|0\.0\.0\.0|chmod\s+0?777|ALLOW_ALL|allow_any)" --include="*.py" --include="*.js" --include="*.ts" --include="*.yaml" --include="*.yml" --include="*.go" --include="*.java" --include="*.rb" . >> "$FINDINGS_FILE" || true
# Pattern 16: Debug/development mode flags
echo "--- Debug/Development Mode Flags ---" >> "$FINDINGS_FILE"
grep -rnEi "(DEBUG\s*[:=]\s*(True|true|1|yes|on)|FLASK_DEBUG|DJANGO_DEBUG|NODE_ENV.*development)" --include="*.py" --include="*.js" --include="*.ts" --include="*.yaml" --include="*.yml" --include="*.env" . >> "$FINDINGS_FILE" || true
Step 3 — False positive filtering
Tool: Task, subagent_type=general-purpose, model=claude-sonnet-4-6
Prompt: "You are a security analyst reviewing raw pattern-match output for false positive elimination.
Read the raw findings file at $PLANS_DIR/secrets-scan-[TIMESTAMP].raw-findings.txt.
Your task:
Review each finding and classify as CONFIRMED or FALSE_POSITIVE.
False positive indicators — classify as FALSE_POSITIVE if:
- The match is in a test fixture file (e.g., path contains:
test/,tests/,spec/,fixtures/,__tests__/,testdata/) - The match is in a documentation file (
.md,.rst,.txt,.adoc) showing an example format - The match is in a comment explaining what a secret LOOKS LIKE (e.g.,
# example: AKIA...) - The match is an obvious placeholder (e.g.,
AKIAIOSFODNN7EXAMPLE,your-secret-here,<YOUR_API_KEY>,xxx...xxx) - The match is in a
.env.example,.env.sample, or.env.templatefile - The match is in a README or CONTRIBUTING file describing configuration
- The value is clearly a test/dummy value (e.g.,
password: 'test',password: 'secret'in test files) - The match is in a mock or stub (file path contains
mock,stub,fake)
Confirmed secret indicators — classify as CONFIRMED if:
- The match appears in a source code file that would be executed
- The match appears in a configuration file that is NOT an example/template
- The match appears in a script file
- The match appears in a Dockerfile or docker-compose file
- The value appears to be a real credential format (not a placeholder)
- The match appears in a git diff as an added line (lines starting with '+' in staged scope)
Insecure default classification rules:
- CONFIRMED if the fallback/default value would be used in production (no environment variable override required for the app to start)
- FALSE_POSITIVE if the pattern is in a test file, example/sample config, documentation, or has a comment indicating it is intentionally a placeholder
- FALSE_POSITIVE if the "weak crypto" match is for non-security use (cache keys, file checksums, ETag generation)
CRITICAL REDACTION RULE: Your output must NEVER include actual secret values. The raw findings file already has patterns redacted. Do NOT attempt to reconstruct or show the original value. Report type, file path, and line number only.
Write your analysis to $PLANS_DIR/secrets-scan-[TIMESTAMP].filtered-findings.md:
## Secrets Scan Filtered Findings
**Scope:** [staged/all/history]
**Timestamp:** [TIMESTAMP]
### Confirmed Secrets
| # | Type | File | Line | Severity | Notes |
|---|------|------|------|----------|-------|
[rows for each confirmed finding]
[Or: 'No confirmed secrets detected.']
### False Positives Excluded
| Type | File | Reason |
|------|------|--------|
[rows for each excluded finding]
[Or: 'No false positives to exclude.']
### Summary
- Total pattern matches: [N]
- Confirmed secrets: [N]
- False positives excluded: [N]
```"
## Step 4 — Verdict gate
Tool: `Read` (direct — coordinator does this)
Read `$PLANS_DIR/secrets-scan-[TIMESTAMP].filtered-findings.md` and count confirmed secrets.
**Verdict rules — zero tolerance:**
- **BLOCKED:** Any confirmed secret detected (count > 0). No exceptions. No thresholds.
- **PASS:** Zero confirmed secrets detected.
There is no PASS_WITH_NOTES for secrets — a secret is either present or it is not.
**If BLOCKED:**
Output immediately:
BLOCKED — Secrets detected in [scope] scan.
Confirmed secrets found: [count]
[List each: type + file:line — NO actual secret values]
Remove all confirmed secrets before committing or shipping. Options:
- Remove the secret and rotate the credential (strongly recommended)
- Add the file to .gitignore if it must not be committed
- Use environment variables or a secrets manager instead
- If this is a test fixture or example, rename the file to include 'example' or 'fixture' and use obviously fake values
Run /secrets-scan again after remediation to verify.
Stop workflow. Do not proceed to Step 5 if BLOCKED.
**If PASS:**
Proceed to Step 5.
## Step 5 — Report generation and archive
Tool: `Task` (report) + `Bash` (archive)
**Generate final report:**
Tool: `Task`, `subagent_type=general-purpose`, `model=claude-sonnet-4-6`
Prompt:
"Generate a final secrets scan report.
Read `$PLANS_DIR/secrets-scan-[TIMESTAMP].filtered-findings.md`.
Write the final report to `$PLANS_DIR/secrets-scan-[TIMESTAMP].report.md`:
Secrets Scan Report
Date: [TIMESTAMP] Scope: [staged/all/history] Verdict: PASS
Summary
No secrets detected in [scope] scan.
Pattern categories checked:
- AWS Access Keys (AKIA... format)
- AWS Secret Access Keys (labeled assignments)
- GitHub Personal Access Tokens (ghp_, gho_, ghu_, ghs_, ghr_)
- Private Key Material (RSA, EC, DSA, OpenSSH)
- Generic Passwords (labeled high-confidence patterns)
- Database Connection Strings (with embedded credentials)
- JWT Tokens
- Slack Tokens (xox[bpaso]-)
- Google API Keys (AIza...)
- Stripe API Keys (sk_live_, pk_live_, rk_live_)
False Positives Excluded
[List from filtered findings, or 'None']
Recommendations
For production use, consider also deploying:
trufflehog(https://github.com/trufflesecurity/trufflehog) — entropy-based scanninggitleaks(https://github.com/zricethezax/gitleaks) — pre-commit hook integration- A secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) to eliminate secrets from code entirely
Note: Pattern-based detection only. Entropy-based detection (for unstructured secrets) is not yet implemented.
**Archive artifacts:**
Tool: `Bash`
```bash
mkdir -p $PLANS_DIR/archive/secrets-scan/${TIMESTAMP}
mv $PLANS_DIR/secrets-scan-${TIMESTAMP}.raw-findings.txt \
$PLANS_DIR/secrets-scan-${TIMESTAMP}.filtered-findings.md \
$PLANS_DIR/archive/secrets-scan/${TIMESTAMP}/ 2>/dev/null || true
echo "Archived intermediate artifacts to $PLANS_DIR/archive/secrets-scan/${TIMESTAMP}/"
echo "Final report: $PLANS_DIR/secrets-scan-${TIMESTAMP}.report.md"
Final output:
"Secrets scan PASS. No secrets detected in [scope] scan.
Report: $PLANS_DIR/secrets-scan-[TIMESTAMP].report.md
Archived artifacts: $PLANS_DIR/archive/secrets-scan/[TIMESTAMP]/"