Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [Unreleased]

### Added
- **`env-secrets-guard` skill (`arch-quality`):** Detects hardcoded credentials, `.env` files tracked by git, and `.env` files missing from `.gitignore` through the stdlib-only [`scan_secrets.py`](./catalog/arch-quality/env-secrets-guard/scripts/scan_secrets.py) (`--json`, exit codes `0`/`1`/`2`, secrets redacted in output).

---

## [1.3.10] - 2026-11-06

### Changed
Expand Down
60 changes: 60 additions & 0 deletions catalog/arch-quality/env-secrets-guard/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
name: "env-secrets-guard"
description: "Use when adding or reviewing code that reads configuration, API keys, tokens or passwords, creating .env files, editing .gitignore, or before committing and pushing to detect hardcoded credentials, tracked or unignored .env files, and missing .env.example templates. Do not trigger for rotating secrets in cloud consoles, configuring secret managers or vaults, or purely visual UI work."
version: "1.0.0"
track: "arch-quality"
tags:
- "security"
- "secrets"
- "dotenv"
- "gitignore"
- "pre-commit"
routing_signals:
languages: ["python", "typescript", "javascript"]
files_any: [".env", ".env.example", ".gitignore"]
dependencies_any: ["python-dotenv", "dotenv"]
negative_triggers:
- "Do not trigger for secret rotation in cloud consoles or vault and secret manager configuration."
entrypoints:
scripts:
- path: "scripts/scan_secrets.py"
description: "Scans a repository for hardcoded credentials and .env files that are tracked by git or not ignored."
---

# Env Secrets Guard (`env-secrets-guard`)

## When to Use This Skill
Invoke this skill whenever code reads credentials from configuration, a `.env` file is created or changed, `.gitignore` is edited, or before a commit or push. The bundled script does the detection, so do not grep for secrets by hand.

## Mandatory Execution Workflow

1. **Run the deterministic scanner first** from the repository root:
```bash
python catalog/arch-quality/env-secrets-guard/scripts/scan_secrets.py --path . --json
```
Exit code `0` means clean, `1` means findings, `2` means the path is invalid. Findings never include full secret values, only a 4-character prefix.
2. **Fix `env-file-tracked` (error):** run `git rm --cached <file>`, add the file to `.gitignore`, and tell the user the secret is already in git history and must be rotated. Do not rewrite history without explicit approval.
3. **Fix `env-file-not-ignored` (error):** add `.env` and `.env.*` to `.gitignore`, then keep a safe template with an allow rule such as `!.env.example`.
4. **Fix hardcoded credentials (`*-key`, `*-token`, `private-key-block`, `hardcoded-secret-assignment`):**
- Replace the literal with a read from the environment (`os.environ["NAME"]` in Python, `process.env.NAME` in Node).
- Fail fast when the variable is missing instead of falling back to a default secret.
- Add the variable name, with a placeholder value, to `.env.example`.
- Tell the user that any value already committed or shared must be rotated, even if removed from the code.
5. **Never print, log or paste a full secret** in the answer, in commit messages, or in new files. Refer to findings by `file:line` and rule name.
6. **Re-run the scanner** and confirm `"status": "ok"` (exit code `0`) before reporting the work as done.

## Interpreting Findings

| Rule | Severity | Meaning |
| :--- | :---: | :--- |
| `env-file-tracked` | error | A real `.env*` file is in the git index. |
| `env-file-not-ignored` | error | A real `.env*` file exists and `.gitignore` does not cover it. |
| `aws-access-key-id`, `github-token`, `anthropic-api-key`, `openai-api-key`, `google-api-key`, `huggingface-token`, `slack-token` | error | Provider key with a known prefix. |
| `private-key-block` | error | A PEM private key header is present in a file. |
| `hardcoded-secret-assignment` | warning | A secret-like name is assigned a literal string. Confirm it is not a placeholder. |

## Scanner Behavior and Limits
- Skips `.git`, `node_modules`, virtualenvs, build output, files over 1 MB and non-UTF-8 files.
- Never reads the content of real `.env*` files. It only checks whether they are tracked or ignored. Files ending in `.example`, `.sample`, `.template` or `.dist` are scanned like source files.
- Values containing words like `example`, `changeme`, `your_` or `xxx`, and template syntax such as `${VAR}`, are treated as placeholders.
- It is a heuristic scanner. A clean result does not prove the repository is free of secrets, and it does not inspect git history.
78 changes: 78 additions & 0 deletions catalog/arch-quality/env-secrets-guard/evals/evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"skill_name": "env-secrets-guard",
"schema_version": "1.0",
"evals": [
{
"id": "eval-01-hardcoded-api-key",
"name": "Replace a hardcoded provider API key with an environment variable",
"prompt": "Our `client.py` creates the API client with a literal key string. Review it and fix the way the credential is loaded.",
"fixtures": [],
"assertions": [
{
"id": "assert-ran-scan-secrets-script",
"type": "tool_call_contains",
"target": "scan_secrets.py",
"description": "Agent executed the deterministic secrets scanner."
},
{
"id": "assert-reads-from-environment",
"type": "regex_match",
"target": "(os\\.environ|os\\.getenv|process\\.env)",
"description": "Credential is read from the environment instead of a literal."
},
{
"id": "assert-advises-rotation",
"type": "regex_match",
"target": "(?i)rotat",
"description": "Agent tells the user that an exposed key must be rotated."
}
]
},
{
"id": "eval-02-env-file-not-ignored",
"name": "Ignore .env files and provide a safe .env.example template",
"prompt": "I just created a `.env` with my database password for local development. Make sure it is safe to commit this project.",
"fixtures": [],
"assertions": [
{
"id": "assert-ran-scan-secrets-script",
"type": "tool_call_contains",
"target": "scan_secrets.py",
"description": "Agent executed the deterministic secrets scanner."
},
{
"id": "assert-gitignore-covers-env",
"type": "regex_match",
"target": "\\.env",
"description": "Agent adds .env patterns to .gitignore."
},
{
"id": "assert-creates-env-example",
"type": "regex_match",
"target": "\\.env\\.example",
"description": "Agent provides a .env.example template with placeholder values."
}
]
},
{
"id": "eval-03-tracked-env-file",
"name": "Untrack a committed .env file without printing its secrets",
"prompt": "Check why our repository leaks credentials. There is a `.env` that was committed by mistake.",
"fixtures": [],
"assertions": [
{
"id": "assert-removes-from-index",
"type": "regex_match",
"target": "git rm --cached",
"description": "Agent removes the tracked env file from the git index."
},
{
"id": "assert-advises-rotation",
"type": "regex_match",
"target": "(?i)rotat",
"description": "Agent warns that secrets already in git history must be rotated."
}
]
}
]
}
204 changes: 204 additions & 0 deletions catalog/arch-quality/env-secrets-guard/scripts/scan_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"""Deterministic secrets and .env hygiene scanner for any repository."""

from __future__ import annotations

import argparse
import fnmatch
import json
import re
import subprocess
from pathlib import Path
from typing import Any

SKILL = "env-secrets-guard"
SCRIPT = "scan_secrets.py"

SKIP_DIRS = {
".git", "node_modules", ".venv", "venv", "dist", "build",
"__pycache__", ".next", "coverage", ".tox", ".mypy_cache",
}
MAX_FILE_BYTES = 1_000_000
EXAMPLE_SUFFIXES = (".example", ".sample", ".template", ".dist")

# (rule id, compiled regex). Provider keys use fixed prefixes to keep false positives low.
TOKEN_RULES: list[tuple[str, re.Pattern[str]]] = [
("aws-access-key-id", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
("anthropic-api-key", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
("openai-api-key", re.compile(r"\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_\-]{32,}")),
("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
("huggingface-token", re.compile(r"\bhf_[A-Za-z0-9]{30,}\b")),
("slack-token", re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{10,}")),
("private-key-block", re.compile(r"-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----")),
]
ASSIGNMENT_RULE = re.compile(
r"""(?ix)
\b[\w.-]*(?:secret|passw(?:or)?d|api[_-]?key|access[_-]?token|auth[_-]?token)[\w.-]*
\s*[:=]\s*
["']([^"'\s]{8,})["']
"""
)
PLACEHOLDER = re.compile(
r"(?i)example|changeme|change_me|your[_-]|xxx|dummy|placeholder|sample|<[^>]+>|\$\{|%\(|\*{3,}"
)


def _redact(value: str) -> str:
return f"{value[:4]}***" if len(value) > 4 else "***"


def _is_env_file(name: str) -> bool:
return name == ".env" or name.startswith(".env.")


def _is_example_env(name: str) -> bool:
return name.endswith(EXAMPLE_SUFFIXES)


def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str] | None:
try:
return subprocess.run(
["git", "-C", root.as_posix(), *args],
capture_output=True, text=True, check=False, timeout=15,
)
except (OSError, subprocess.SubprocessError):
return None


def _in_git_repo(root: Path) -> bool:
res = _git(root, "rev-parse", "--is-inside-work-tree")
return bool(res and res.returncode == 0 and res.stdout.strip() == "true")


def _is_ignored(root: Path, rel: str, use_git: bool) -> bool:
if use_git:
res = _git(root, "check-ignore", "-q", "--", rel)
return bool(res and res.returncode == 0)
# ponytail: no-git fallback only fnmatches simple .gitignore lines (no negation or nesting)
gitignore = root / ".gitignore"
if not gitignore.is_file():
return False
name = Path(rel).name
for line in gitignore.read_text(encoding="utf-8", errors="replace").splitlines():
pattern = line.strip().rstrip("/")
if pattern and not pattern.startswith(("#", "!")):
if fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(rel, pattern):
return True
return False


def _is_tracked(root: Path, rel: str, use_git: bool) -> bool:
if not use_git:
return False
res = _git(root, "ls-files", "--error-unmatch", "--", rel)
return bool(res and res.returncode == 0)


def _walk(root: Path) -> list[Path]:
files: list[Path] = []
for path in sorted(root.rglob("*")):
rel_parts = path.relative_to(root).parts
if any(part in SKIP_DIRS for part in rel_parts[:-1]):
continue
if path.is_file() and not path.is_symlink():
files.append(path)
return files


def _finding(rel: str, line: int, rule: str, severity: str, impact: str) -> dict[str, Any]:
return {"file": rel, "line": line, "symbol": rule, "impact": impact, "severity": severity}


def scan_repo(root: Path) -> tuple[list[dict[str, Any]], int]:
"""Return (findings, files_scanned) for the directory tree under root."""
findings: list[dict[str, Any]] = []
scanned = 0
use_git = _in_git_repo(root)

for path in _walk(root):
rel = path.relative_to(root).as_posix()
name = path.name

if _is_env_file(name) and not _is_example_env(name):
if _is_tracked(root, rel, use_git):
findings.append(_finding(
rel, 0, "env-file-tracked", "error",
"Environment file is tracked by git; remove it from the index and rotate its secrets.",
))
elif not _is_ignored(root, rel, use_git):
findings.append(_finding(
rel, 0, "env-file-not-ignored", "error",
"Environment file is not covered by .gitignore; add '.env' and '.env.*'.",
))
continue # real .env files hold secrets by design; never scan or print their content

try:
if path.stat().st_size > MAX_FILE_BYTES:
continue
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
scanned += 1

for lineno, line in enumerate(text.splitlines(), start=1):
for rule, pattern in TOKEN_RULES:
match = pattern.search(line)
if match and not PLACEHOLDER.search(match.group(0)):
findings.append(_finding(
rel, lineno, rule, "error",
f"Hardcoded credential detected ({_redact(match.group(0))}); "
"move it to an environment variable and rotate it.",
))
match = ASSIGNMENT_RULE.search(line)
if match and not PLACEHOLDER.search(match.group(1)):
findings.append(_finding(
rel, lineno, "hardcoded-secret-assignment", "warning",
f"Literal value assigned to a secret-like name ({_redact(match.group(1))}); "
"read it from the environment instead.",
))

return findings, scanned


def main(argv: list[str] | None = None) -> int:
"""CLI entrypoint: exit 0 clean, 1 findings, 2 invalid path."""
parser = argparse.ArgumentParser(description="Scan a repository for secrets and .env hygiene issues.")
parser.add_argument("--path", required=True, help="Repository root directory to scan.")
parser.add_argument("--json", action="store_true", dest="as_json", help="Emit JSON output.")
args = parser.parse_args(argv)

root = Path(args.path)
if not root.is_dir():
if args.as_json:
print(json.dumps({
"skill": SKILL, "script": SCRIPT, "status": "error",
"target": root.as_posix(),
"summary": {"files_scanned": 0, "findings_count": 0}, "findings": [],
}))
else:
print(f"error: not a directory: {root}")
return 2

findings, scanned = scan_repo(root)
status = "ok" if not findings else "findings_detected"
if args.as_json:
print(json.dumps({
"skill": SKILL, "script": SCRIPT, "status": status,
"target": root.as_posix(),
"summary": {
"files_scanned": scanned,
"findings_count": len(findings),
"errors": sum(f["severity"] == "error" for f in findings),
"warnings": sum(f["severity"] == "warning" for f in findings),
},
"findings": findings,
}, indent=2))
else:
for f in findings:
print(f"{f['file']}:{f['line']}: {f['severity']}: {f['symbol']}: {f['impact']}")
print(f"status={status} files_scanned={scanned} findings={len(findings)}")
return 0 if not findings else 1


if __name__ == "__main__":
raise SystemExit(main())
4 changes: 2 additions & 2 deletions tests/npx_core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ describe("SkillOps Core Models & Subagent Contracts", () => {

it("discovers and parses real repository catalog skills and subagents", () => {
const skillDirs = discoverSkillDirectories(CATALOG_ROOT);
expect(skillDirs.length).toBe(1);
expect(skillDirs.length).toBe(2);

const skillNames: string[] = [];
for (const dir of skillDirs) {
Expand All @@ -157,7 +157,7 @@ describe("SkillOps Core Models & Subagent Contracts", () => {
expect(parsed.declaredScripts.length).toBeGreaterThanOrEqual(1);
}

expect(skillNames).toEqual(["mcp-architect"]);
expect(skillNames.sort()).toEqual(["env-secrets-guard", "mcp-architect"]);

const subagentFiles = discoverSubagentFiles(CATALOG_ROOT);
expect(subagentFiles.length).toBe(1);
Expand Down
Loading
Loading