From 4fab5f56a4680e1dced8fde79da94316bb6c1b77 Mon Sep 17 00:00:00 2001 From: Joao Pedro Plinta Date: Sun, 8 Nov 2026 17:52:51 -0300 Subject: [PATCH] feat(catalog): add env-secrets-guard skill Add an arch-quality skill that detects hardcoded credentials, .env files tracked by git and .env files missing from .gitignore through a stdlib-only scanner (scripts/scan_secrets.py, --json, exit codes 0/1/2, redacted output). Update catalog-count assertions in the existing tests and add a changelog entry. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 + .../arch-quality/env-secrets-guard/SKILL.md | 60 ++++++ .../env-secrets-guard/evals/evals.json | 78 +++++++ .../env-secrets-guard/scripts/scan_secrets.py | 204 ++++++++++++++++++ tests/npx_core.test.ts | 4 +- tests/router_sync.test.ts | 12 +- 6 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 catalog/arch-quality/env-secrets-guard/SKILL.md create mode 100644 catalog/arch-quality/env-secrets-guard/evals/evals.json create mode 100644 catalog/arch-quality/env-secrets-guard/scripts/scan_secrets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 49cc2eb..dbc9d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/catalog/arch-quality/env-secrets-guard/SKILL.md b/catalog/arch-quality/env-secrets-guard/SKILL.md new file mode 100644 index 0000000..e7dc277 --- /dev/null +++ b/catalog/arch-quality/env-secrets-guard/SKILL.md @@ -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 `, 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. diff --git a/catalog/arch-quality/env-secrets-guard/evals/evals.json b/catalog/arch-quality/env-secrets-guard/evals/evals.json new file mode 100644 index 0000000..b682213 --- /dev/null +++ b/catalog/arch-quality/env-secrets-guard/evals/evals.json @@ -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." + } + ] + } + ] +} diff --git a/catalog/arch-quality/env-secrets-guard/scripts/scan_secrets.py b/catalog/arch-quality/env-secrets-guard/scripts/scan_secrets.py new file mode 100644 index 0000000..1e72fff --- /dev/null +++ b/catalog/arch-quality/env-secrets-guard/scripts/scan_secrets.py @@ -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()) diff --git a/tests/npx_core.test.ts b/tests/npx_core.test.ts index 641fa70..30291e0 100644 --- a/tests/npx_core.test.ts +++ b/tests/npx_core.test.ts @@ -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) { @@ -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); diff --git a/tests/router_sync.test.ts b/tests/router_sync.test.ts index 455827e..b7cfad1 100644 --- a/tests/router_sync.test.ts +++ b/tests/router_sync.test.ts @@ -285,8 +285,8 @@ describe("SkillOps Multi-Runtime Add & Remove Engine", () => { expect(targetOptions[3]?.value).toBe("antigravity"); const catalogOptions = buildInteractiveCatalogOptions(CATALOG_ROOT); - expect(catalogOptions.length).toBe(2); - expect(catalogOptions.filter((o) => o.kind === "skill").length).toBe(1); + expect(catalogOptions.length).toBe(3); + expect(catalogOptions.filter((o) => o.kind === "skill").length).toBe(2); expect(catalogOptions.filter((o) => o.kind === "agent").length).toBe(1); const tmpRepo = fs.mkdtempSync( @@ -334,12 +334,14 @@ describe("SkillOps Multi-Runtime Add & Remove Engine", () => { tmpRepo, CATALOG_ROOT, ); - expect(installedOpts.length).toBe(2); + expect(installedOpts.length).toBe(3); expect(installedOpts[0]?.value).toBe("mcp-architect"); expect(installedOpts[0]?.kind).toBe("skill"); expect(installedOpts[0]?.hint).toContain("[cursor, gemini]"); - expect(installedOpts[1]?.value).toBe("context-scout"); - expect(installedOpts[1]?.kind).toBe("agent"); + expect(installedOpts[1]?.value).toBe("env-secrets-guard"); + expect(installedOpts[1]?.kind).toBe("skill"); + expect(installedOpts[2]?.value).toBe("context-scout"); + expect(installedOpts[2]?.kind).toBe("agent"); expect( runCli([