Skip to content

Commit 34d2957

Browse files
authored
Merge pull request #1 from rwbot/catch-dotenv
2 parents e5e94e8 + 206d0a3 commit 34d2957

File tree

6 files changed

+776
-0
lines changed

6 files changed

+776
-0
lines changed

.pre-commit-hooks.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
entry: check-builtin-literals
2424
language: python
2525
types: [python]
26+
- id: catch-dotenv
27+
name: catch dotenv files
28+
description: blocks committing .env files. optionally creates value-sanitized .env.example.
29+
entry: catch-dotenv
30+
language: python
31+
pass_filenames: true
32+
always_run: false
2633
- id: check-case-conflict
2734
name: check for case conflicts
2835
description: checks for files that would conflict in case-insensitive filesystems.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ Require literal syntax when initializing empty or zero Python builtin types.
4242
- Ignore this requirement for specific builtin types with `--ignore=type1,type2,…`.
4343
- Forbid `dict` keyword syntax with `--no-allow-dict-kwargs`.
4444

45+
#### `catch-dotenv`
46+
Prevents committing `.env` files to version control and optionally generates `.env.example` files.
47+
- Use `--create-example` to generate a `.env.example` file with variable names but no values.
48+
- Automatically adds `.env` to `.gitignore` if not already present.
49+
- Helps prevent accidental exposure of secrets and sensitive configuration.
50+
4551
#### `check-case-conflict`
4652
Check for files with names that would conflict on a case-insensitive filesystem like MacOS HFS+ or Windows FAT.
4753

pre_commit_hooks/catch_dotenv.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#!/usr/bin/env python
2+
from __future__ import annotations
3+
4+
import argparse
5+
import os
6+
import re
7+
import sys
8+
import tempfile
9+
from collections.abc import Iterable
10+
from collections.abc import Sequence
11+
12+
# Defaults / constants
13+
DEFAULT_ENV_FILE = '.env'
14+
DEFAULT_GITIGNORE_FILE = '.gitignore'
15+
DEFAULT_EXAMPLE_ENV_FILE = '.env.example'
16+
GITIGNORE_BANNER = '# Added by pre-commit hook to prevent committing secrets'
17+
18+
_KEY_REGEX = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=')
19+
20+
21+
def _atomic_write(path: str, data: str) -> None:
22+
"""Atomically (best-effort) write text.
23+
24+
Writes to a same-directory temporary file then replaces the target with
25+
os.replace(). This is a slight divergence from most existing hooks which
26+
write directly, but here we intentionally reduce the (small) risk of
27+
partially-written files because the hook may be invoked rapidly / in
28+
parallel (tests exercise concurrent normalization). Keeping this helper
29+
local avoids adding any dependency.
30+
"""
31+
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or '.')
32+
try:
33+
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as tmp_f:
34+
tmp_f.write(data)
35+
os.replace(tmp_path, path)
36+
finally: # Clean up if replace failed
37+
if os.path.exists(tmp_path): # (rare failure case)
38+
try:
39+
os.remove(tmp_path)
40+
except OSError:
41+
pass
42+
43+
44+
def _read_gitignore(gitignore_file: str) -> tuple[str, list[str]]:
45+
"""Read and parse .gitignore file content."""
46+
try:
47+
if os.path.exists(gitignore_file):
48+
with open(gitignore_file, encoding='utf-8') as f:
49+
original_text = f.read()
50+
lines = original_text.splitlines()
51+
else:
52+
original_text = ''
53+
lines = []
54+
except OSError as exc:
55+
print(
56+
f"ERROR: unable to read {gitignore_file}: {exc}",
57+
file=sys.stderr,
58+
)
59+
raise
60+
return original_text, lines
61+
62+
63+
def _normalize_gitignore_lines(
64+
lines: list[str],
65+
env_file: str,
66+
banner: str,
67+
) -> list[str]:
68+
"""Normalize .gitignore lines by removing duplicates and canonical tail."""
69+
# Trim trailing blank lines
70+
while lines and not lines[-1].strip():
71+
lines.pop()
72+
73+
# Remove existing occurrences
74+
filtered: list[str] = [
75+
ln for ln in lines if ln.strip() not in {env_file, banner}
76+
]
77+
78+
if filtered and filtered[-1].strip():
79+
filtered.append('') # ensure single blank before banner
80+
elif not filtered: # empty file -> still separate section visually
81+
filtered.append('')
82+
83+
filtered.append(banner)
84+
filtered.append(env_file)
85+
return filtered
86+
87+
88+
def ensure_env_in_gitignore(
89+
env_file: str,
90+
gitignore_file: str,
91+
banner: str,
92+
) -> bool:
93+
"""Ensure canonical banner + env tail in .gitignore.
94+
95+
Returns True only when the file content was changed. Returns False both
96+
when unchanged and on IO errors (we intentionally conflate for the simple
97+
hook contract; errors are still surfaced via stderr output).
98+
"""
99+
try:
100+
original_content_str, lines = _read_gitignore(gitignore_file)
101+
except OSError:
102+
return False
103+
104+
filtered = _normalize_gitignore_lines(lines, env_file, banner)
105+
new_content = '\n'.join(filtered) + '\n'
106+
107+
# Normalize original content to a single trailing newline for comparison
108+
normalized_original = original_content_str
109+
if normalized_original and not normalized_original.endswith('\n'):
110+
normalized_original += '\n'
111+
if new_content == normalized_original:
112+
return False
113+
114+
try:
115+
_atomic_write(gitignore_file, new_content)
116+
return True
117+
except OSError as exc:
118+
print(
119+
f"ERROR: unable to write {gitignore_file}: {exc}",
120+
file=sys.stderr,
121+
)
122+
return False
123+
124+
125+
def create_example_env(src_env: str, example_file: str) -> bool:
126+
"""Generate .env.example with unique KEY= lines (no values)."""
127+
try:
128+
with open(src_env, encoding='utf-8') as f_env:
129+
lines = f_env.readlines()
130+
except OSError as exc:
131+
print(f"ERROR: unable to read {src_env}: {exc}", file=sys.stderr)
132+
return False
133+
134+
seen: set[str] = set()
135+
keys: list[str] = []
136+
for line in lines:
137+
stripped = line.strip()
138+
if not stripped or stripped.startswith('#'):
139+
continue
140+
m = _KEY_REGEX.match(stripped)
141+
if not m:
142+
continue
143+
key = m.group(1)
144+
if key not in seen:
145+
seen.add(key)
146+
keys.append(key)
147+
148+
header = [
149+
'# Generated by catch-dotenv hook.',
150+
'# Variable names only – fill in sample values as needed.',
151+
'',
152+
]
153+
body = [f"{k}=" for k in keys]
154+
try:
155+
_atomic_write(example_file, '\n'.join(header + body) + '\n')
156+
return True
157+
except OSError as exc: # pragma: no cover
158+
print(
159+
f"ERROR: unable to write '{example_file}': {exc}",
160+
file=sys.stderr,
161+
)
162+
return False
163+
164+
165+
def _has_env(filenames: Iterable[str], env_file: str) -> bool:
166+
"""Return True if any staged path refers to target env file by basename."""
167+
return any(os.path.basename(name) == env_file for name in filenames)
168+
169+
170+
def _print_failure(
171+
env_file: str,
172+
gitignore_file: str,
173+
example_created: bool,
174+
gitignore_modified: bool,
175+
) -> None:
176+
# Match typical hook output style: one short line per action.
177+
print(f"Blocked committing {env_file}.")
178+
if gitignore_modified:
179+
print(f"Updated {gitignore_file}.")
180+
if example_created:
181+
print('Generated .env.example.')
182+
print(f"Remove {env_file} from the commit and retry.")
183+
184+
185+
def main(argv: Sequence[str] | None = None) -> int:
186+
"""Hook entry-point."""
187+
parser = argparse.ArgumentParser(
188+
description='Blocks committing .env files.',
189+
)
190+
parser.add_argument(
191+
'filenames',
192+
nargs='*',
193+
help='Staged filenames (supplied by pre-commit).',
194+
)
195+
parser.add_argument(
196+
'--create-example',
197+
action='store_true',
198+
help='Generate example env file (.env.example).',
199+
)
200+
args = parser.parse_args(argv)
201+
env_file = DEFAULT_ENV_FILE
202+
# Use current working directory as repository root (pre-commit executes
203+
# hooks from the repo root).
204+
repo_root = os.getcwd()
205+
gitignore_file = os.path.join(repo_root, DEFAULT_GITIGNORE_FILE)
206+
example_file = os.path.join(repo_root, DEFAULT_EXAMPLE_ENV_FILE)
207+
env_abspath = os.path.join(repo_root, env_file)
208+
209+
if not _has_env(args.filenames, env_file):
210+
return 0
211+
212+
gitignore_modified = ensure_env_in_gitignore(
213+
env_file,
214+
gitignore_file,
215+
GITIGNORE_BANNER,
216+
)
217+
example_created = False
218+
if args.create_example:
219+
# Source env is always looked up relative to repo root
220+
if os.path.exists(env_abspath):
221+
example_created = create_example_env(
222+
env_abspath,
223+
example_file,
224+
)
225+
226+
_print_failure(
227+
env_file,
228+
gitignore_file,
229+
example_created,
230+
gitignore_modified,
231+
)
232+
return 1 # Block commit
233+
234+
235+
if __name__ == '__main__':
236+
raise SystemExit(main())

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ exclude =
2929

3030
[options.entry_points]
3131
console_scripts =
32+
catch-dotenv = pre_commit_hooks.catch_dotenv:main
3233
check-added-large-files = pre_commit_hooks.check_added_large_files:main
3334
check-ast = pre_commit_hooks.check_ast:main
3435
check-builtin-literals = pre_commit_hooks.check_builtin_literals:main

testing/resources/test.env

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# =============================================================================
2+
# DUMMY SECRETS FOR DOTENV TEST
3+
# =============================================================================
4+
5+
# Container Internal Ports (what each service listens on inside containers)
6+
BACKEND_CONTAINER_PORT=3000 # FastAPI server internal port
7+
FRONTEND_CONTAINER_PORT=3001 # Vite dev server internal port
8+
9+
# External Access (what users/browsers connect to)
10+
CADDY_EXTERNAL_PORT=80 # External port exposed to host system
11+
12+
# URLs (how different components reference each other)
13+
BASE_HOSTNAME=http://localhost
14+
PUBLIC_FRONTEND_URL=${BASE_HOSTNAME}:${CADDY_EXTERNAL_PORT}
15+
LEGACY_BACKEND_DIRECT_URL=${BASE_HOSTNAME}:${BACKEND_CONTAINER_PORT} # Deprecated: direct backend access
16+
VITE_BROWSER_API_URL=${BASE_HOSTNAME}:${CADDY_EXTERNAL_PORT}/api # Frontend API calls through Caddy
17+
18+
# Environment
19+
NODE_ENV=development
20+
# Supabase
21+
SUPABASE_PROJECT_ID=979090c33e5da06f67921e70
22+
SUPABASE_PASSWORD=1bbad0861dbca0bad3bd58ac90fd87e1cfd13ebbbeaed730868a11fa38bf6a65
23+
SUPABASE_URL=https://${SUPABASE_PROJECT_ID}.supabase.co
24+
DATABASE_URL=postgresql://postgres.${SUPABASE_PROJECT_ID}:${SUPABASE_PASSWORD}@aws-0-us-west-1.pooler.supabase.com:5432/postgres
25+
SUPABASE_SERVICE_KEY=f37f35e070475d4003ea0973cc15ef8bd9956fd140c80d247a187f8e5b0d69d70a9555decd28ea405051bf31d1d1f949dba277f058ba7c0279359ccdeda0f0696ea803403b8ad76dbbf45c4220b45a44a66e643bf0ca575dffc69f22a57c7d6c693e4d55b5f02e8a0da192065a38b24cbed2234d005661beba6d58e3ef234e0f
26+
SUPABASE_S3_STORAGE_ENDPOINT=${SUPABASE_URL}/storage/v1/s3
27+
SUPABASE_STORAGE_BUCKET=my-bucket
28+
SUPABASE_REGION=us-west-1
29+
SUPABASE_S3_ACCESS_KEY_ID=323157dcde28202bda94ff4db4be5266
30+
SUPABASE_S3_SECRET_ACCESS_KEY=d37c900e43e9dfb2c9998fa65aaeea703014504bbfebfddbcf286ee7197dc975
31+
32+
# Storage (aliases for compatibility)
33+
STORAGE_URL=https://b8991834720f5477910eded7.supabase.co/storage/v1/s3
34+
STORAGE_BUCKET=my-bucket
35+
STORAGE_ACCESS_KEY=FEvMws2HMGW96oBMx6Cg98pP8k3h4eki
36+
STORAGE_SECRET_KEY=shq7peEUeYkdzuUDohoK6qx9Zpjvjq6Zz2coUDvyQARM3qk9QryKZmQqRmz4szzM
37+
STORAGE_REGION=us-west-1
38+
STORAGE_SKIP_BUCKET_CHECK=true
39+
40+
# Authentication
41+
ACCESS_TOKEN_SECRET=ghp_c9d4307ceb82d06b522c1a5e37a8b5d0BMwJpgMT
42+
REFRESH_TOKEN_SECRET=09cb1b7920aea0d2b63ae3264e27595225ca7132f92f4cc5eff6dc066957118d
43+
JWT_ALGORITHM=HS256
44+
45+
# Mail
46+
47+
48+
# Chrome Browser
49+
CHROME_TOKEN=ac126eb015837628b05ff2f0f568ff46
50+
CHROME_PROXY_HOST=chrome
51+
CHROME_PROXY_PORT=3002
52+
CHROME_PROXY_SSL=false
53+
CHROME_HEALTH=true
54+
CHROME_PORT=8080
55+
56+
# Test Configuration (for e2e)
57+
TEST_HOST=${BASE_HOSTNAME}
58+
TEST_TIMEOUT=35
59+
60+
TEST_PASSWORD=changeme
61+
POSTGRES_PORT=5432
62+
MINIO_PORT=9000
63+
REDIS_PORT=6379
64+
65+
# Database and Storage Paths
66+
SQLITE_DB_PATH=database.db
67+
TEST_DB_PATH=tests/testdb.duckdb
68+
STATIC_FILES_DIR=/app/static
69+
70+
# AI
71+
OPENAI_API_KEY = "sk-proj-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
72+
COHERE_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
73+
OR_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
74+
AZURE_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
75+
GEMINI_API_KEY = "AIzaSyA1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r"
76+
VERTEXAI_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
77+
REPLICATE_API_KEY = "r8_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9"
78+
REPLICATE_API_TOKEN = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
79+
ANTHROPIC_API_KEY = "sk-ant-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
80+
INFISICAL_TOKEN = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
81+
NOVITA_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
82+
INFINITY_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"

0 commit comments

Comments
 (0)