
--- name: dotenv-config-linter description: Lints .env and .env.example files for secrets committed by mistake, weak names, duplicate keys, export prefixes, spaces around equals, empty required values, and unsafe interpolation, then suggests a cleaned example file. Use when a user asks "is this .env safe to commit?", "why won't dotenv load?", or "diff my .env.example". Includes a tested stdlib Python linter. --- # Dotenv Config and Secrets Linter You help people keep environment files correct and safe. You distinguish secrets that must never be committed from placeholders that belong in `.env.example`, catch parse problems that break `dotenv` loaders, and propose a cleaned example file. You never ask the user to paste production secrets into chat if a redacted sample will do. ## Files in this skill - `scripts/dotenv_lint.py` - parses dotenv syntax, reports HIGH/WARN/INFO findings, and can emit a sanitized `.env.example` (Python 3 standard library only) - `references/dotenv-rules.md` - syntax and safety rules the linter encodes - `references/secret-patterns.md` - common secret shapes and how to replace them with placeholders - `templates/lint-report.md` - the report shape to return - `examples/example-webapp-env-review.md` - a worked review of a messy web app env file ## Workflow ### 1. Collect the facts - Whether the file is `.env` (local secrets), `.env.example` (placeholders), or CI env config. - Which keys are required at boot. - Whether the repo already has a secret scanner (gitleaks, trufflehog). ### 2. Run the linter ```bash python3 scripts/dotenv_lint.py .env python3 scripts/dotenv_lint.py .env.example --example-mode python3 scripts/dotenv_lint.py app.env --emit-example cleaned.env.example python3 scripts/dotenv_lint.py - --json < sample.env ``` Prefer redacted samples. If the user pastes real secrets, warn them to rotate anything that may have leaked. ### 3. Fix 1. Treat HIGH findings (likely real secrets in an example file, duplicate keys, unquoted values with spaces) first. 2. Align `.env.example` with required keys using placeholders from `references/secret-patterns.md`. 3. Deliver with `templates/lint-report.md`. ## Rules - Never recommend committing a real `.env` with live credentials. - Do not print full secret values in summaries; show masked forms (`sk-…XXXX`). - Keep key names stable unless the user wants a rename for clarity. FILE:references/dotenv-rules.md # Dotenv rules this skill enforces ## Syntax - One `KEY=VALUE` per line; `#` starts a comment. - Prefer no spaces around `=`. `KEY = VALUE` is WARN (some loaders accept it, many do not). - Values with spaces or `#` should be single- or double-quoted. - `export KEY=VALUE` is INFO (works in shells, redundant for most dotenv parsers). - Duplicate keys: last one wins in many loaders — flag as HIGH because intent is unclear. ## Safety - `.env.example` must not contain values that look like live secrets (long random tokens, private keys, `sk-`/`ghp_` prefixes, AWS-shaped keys). - Empty values for keys listed as required → WARN. - Interpolation `${OTHER}` is INFO; confirm the referenced key exists. ## Naming - Prefer `SCREAMING_SNAKE_CASE`. - Avoid generic names like `PASSWORD` without a service prefix (`DB_PASSWORD`). FILE:references/secret-patterns.md # Secret patterns → placeholders | Looks like | Placeholder | |---|---| | `sk-` + long alnum | `sk-your-openai-key` | | `ghp_` / `github_pat_` | `ghp_your_token` | | `AKIA` + 16 chars | `AKIA_YOUR_ACCESS_KEY` | | `-----BEGIN` private key block | omit from example; document path instead | | Long base64-ish 32+ chars | `replace-me` or `changeme_<purpose>` | | Database URLs with passwords | `postgres://USER:PASSWORD@host:5432/db` | When emitting an example file, replace secret-looking values with placeholders and keep non-secret config (ports, feature flags, public URLs) as-is if they are clearly non-sensitive. FILE:templates/lint-report.md # Dotenv lint report ## File reviewed - Path / role: `.env` | `.env.example` | other ## Summary - HIGH: … - WARN: … - INFO: … ## Findings | Severity | Line | Key | Issue | Fix | |---|---|---|---|---| ## Suggested `.env.example` ```dotenv … ``` ## Rotate if exposed - … FILE:examples/example-webapp-env-review.md # Example: messy webapp `.env.example` **Input (bad):** ``` export DB_URL=postgres://app:SuperSecret123@db:5432/app API_KEY = sk-proj-abcdefghijklmnopqrstuvwxyz012345 PORT=3000 PORT=3001 FEATURE_X=true ``` **Findings:** - HIGH: `API_KEY` looks like a live OpenAI-style key in an example file. - HIGH: duplicate `PORT`. - WARN: spaces around `=` on `API_KEY`. - INFO: `export` prefix on `DB_URL`. - HIGH: database password embedded in example URL. **Cleaned example:** ``` DB_URL=postgres://USER:PASSWORD@db:5432/app API_KEY=sk-your-openai-key PORT=3000 FEATURE_X=true ``` FILE:scripts/dotenv_lint.py #!/usr/bin/env python3 """Lint .env / .env.example files for syntax and likely secrets. Usage: python3 dotenv_lint.py .env python3 dotenv_lint.py .env.example --example-mode python3 dotenv_lint.py app.env --emit-example cleaned.env.example python3 dotenv_lint.py - --json < sample.env """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path SECRET_RES = [ (re.compile(r"sk-[A-Za-z0-9-]{20,}"), "openai-like key"), (re.compile(r"ghp_[A-Za-z0-9]{20,}"), "github token"), (re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "github pat"), (re.compile(r"AKIA[0-9A-Z]{16}"), "aws access key id"), (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "private key block"), (re.compile(r"(?i)password=([^&\s]{8,})"), "password in URL"), (re.compile(r"(?i)://[^:/@]+:[^@/]{8,}@"), "credentials embedded in URL"), ] LINE_RE = re.compile( r"^(?P<export>export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<val>.*)$" ) def mask(val: str) -> str: if len(val) <= 8: return "***" return val[:3] + "…" + val[-4:] def looks_placeholder(val: str) -> bool: v = val.strip().strip("'\"") low = v.lower() if ( not v or "your-" in low or "changeme" in low or "replace" in low or "example" in low or low in {"password", "user", "todo", "xxx"} or "user:password@" in low or "://user:password@" in low ): return True # URL with obvious placeholders if "://" in v and "@" in v: if re.search(r"://(USER|user|USERNAME|username):(PASSWORD|password|PASS|pass|secret)@", v): return True return False def parse_file(text: str) -> tuple[list[dict], list[dict]]: findings = [] entries = [] seen = {} for i, line in enumerate(text.splitlines(), 1): raw = line.rstrip("\n") stripped = raw.strip() if not stripped or stripped.startswith("#"): continue m = LINE_RE.match(stripped) if not m: findings.append({"severity": "HIGH", "line": i, "key": None, "issue": "unparseable line", "fix": "use KEY=VALUE"}) continue key, val, export = m.group("key"), m.group("val"), m.group("export") if re.search(r"\s=\s|=\s|\s=", raw.split("#")[0]) and " = " in raw: findings.append({"severity": "WARN", "line": i, "key": key, "issue": "spaces around equals", "fix": f"{key}={val.strip()}"}) if export: findings.append({"severity": "INFO", "line": i, "key": key, "issue": "export prefix", "fix": "drop export for dotenv parsers"}) if key in seen: findings.append({"severity": "HIGH", "line": i, "key": key, "issue": f"duplicate key (also line {seen[key]})", "fix": "keep a single definition"}) seen[key] = i # strip inline comments for unquoted values store_val = val if val and not (val.startswith("'") or val.startswith('"')): if " #" in val: store_val = val.split(" #", 1)[0].rstrip() entries.append({"line": i, "key": key, "value": store_val, "raw": raw}) return findings, entries def secret_findings(entries: list[dict], example_mode: bool) -> list[dict]: out = [] for e in entries: val = e["value"].strip().strip("'\"") if looks_placeholder(val): continue for rx, label in SECRET_RES: if rx.search(val): sev = "HIGH" if example_mode else "WARN" out.append({"severity": sev, "line": e["line"], "key": e["key"], "issue": f"likely secret ({label}): {mask(val)}", "fix": "replace with placeholder in example files; rotate if committed"}) break else: # long random-ish token if example_mode and len(val) >= 32 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", val): out.append({"severity": "HIGH", "line": e["line"], "key": e["key"], "issue": f"long token-like value: {mask(val)}", "fix": "use a placeholder like replace-me"}) return out def emit_example(entries: list[dict]) -> str: lines = [] seen = set() for e in entries: if e["key"] in seen: continue seen.add(e["key"]) val = e["value"].strip().strip("'\"") secret = False if not looks_placeholder(val): for rx, _ in SECRET_RES: if rx.search(val): secret = True break if len(val) >= 32 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", val): secret = True if secret or (val and not looks_placeholder(val) and re.search(r"(?i)secret|password|token|api[_-]?key", e["key"])): if re.search(r"(?i)api[_-]?key|token", e["key"]): new_v = "replace-me-token" elif re.search(r"(?i)password|secret", e["key"]): new_v = "changeme" elif "://" in val and "@" in val: new_v = re.sub(r"://([^:/@]+):([^@/]+)@", r"://USER:PASSWORD@", val) else: new_v = "replace-me" else: new_v = val lines.append(f"{e['key']}={new_v}") return "\n".join(lines) + ("\n" if lines else "") def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("path", help="Env file path, or - for stdin") ap.add_argument("--example-mode", action="store_true", help="Treat file as .env.example (secrets are HIGH)") ap.add_argument("--emit-example", metavar="PATH", help="Write sanitized example file") ap.add_argument("--json", action="store_true") args = ap.parse_args(argv) if args.path == "-": text = sys.stdin.read() else: path = Path(args.path) if not path.is_file(): print(f"error: file not found: {args.path}", file=sys.stderr) return 2 text = path.read_text(encoding="utf-8") example_mode = args.example_mode or (args.path != "-" and Path(args.path).name.endswith(".example")) findings, entries = parse_file(text) findings.extend(secret_findings(entries, example_mode)) findings.sort(key=lambda f: ({"HIGH": 0, "WARN": 1, "INFO": 2}[f["severity"]], f["line"] or 0)) example = emit_example(entries) if args.emit_example: Path(args.emit_example).write_text(example, encoding="utf-8") summary = {"HIGH": 0, "WARN": 0, "INFO": 0} for f in findings: summary[f["severity"]] += 1 if args.json: json.dump({"summary": summary, "findings": findings, "example": example}, sys.stdout, indent=2) sys.stdout.write("\n") else: print(f"summary: HIGH={summary['HIGH']} WARN={summary['WARN']} INFO={summary['INFO']}") for f in findings: key = f["key"] or "-" print(f"[{f['severity']}] line {f['line']}: {key}: {f['issue']} → {f['fix']}") if args.emit_example: print(f"wrote example: {args.emit_example}") return 1 if summary["HIGH"] else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
Describe one change and free AI rewrites this prompt for you.
ChatGPT, Gemini, Claude - copy and paste. For images, use it with Midjourney, DALL-E, or Gemini image mode.
Tip: swap names, colors, or details to make it yours. Shorter prompts often work better - remove lines you don't need.







This prompt is part of our Best ChatGPT Prompts collection, the closest match for this style. You can also browse more ChatGPT prompts like this one, where every prompt is free to copy.
For ideas beyond this topic, explore the newest prompts on the homepage.
Gemini doesn't allow auto-fill from links, so paste it yourself - takes 2 seconds:
More ideas for the same category, ready to copy.