🔒 Security view

Verified security findings — no false positives  ·  Security Engineer, CISO, Pen Tester

Generated 3 days ago
⚡ Agent trace  ·  13 steps  ·  $0.0557  ·  click to expand
search_claims(query='authentication auth password login', category='security')search_claims(query='token session jwt secret', category='security')search_claims(query='input validation injection sql command', category='security')search_claims(query='security', category='security')search_claims(query='encrypt decrypt hash', category='any')search_claims(query='permission role access control', category='any')search_claims(query='a', category='any')search_claims(query='b', category='any')search_claims(query='c', category='any')search_claims(query='d', category='any')read_source_file(file_path='src/dotenv/main.py', start_line=1)read_source_file(file_path='src/dotenv/main.py', start_line=61)read_source_file(file_path='src/dotenv/main.py', start_line=131)read_source_file(file_path='src/dotenv/main.py', start_line=192)read_source_file(file_path='src/dotenv/main.py', start_line=331)read_source_file(file_path='src/dotenv/main.py', start_line=431)read_source_file(file_path='src/dotenv/parser.py', start_line=1)read_source_file(file_path='src/dotenv/variables.py', start_line=1)read_source_file(file_path='src/dotenv/cli.py', start_line=1)read_source_file(file_path='src/dotenv/cli.py', start_line=62)read_source_file(file_path='src/dotenv/variables.py', start_line=62)read_source_file(file_path='src/dotenv/parser.py', start_line=62)read_source_file(file_path='src/dotenv/parser.py', start_line=122)read_source_file(file_path='src/dotenv/main.py', start_line=248)read_source_file(file_path='src/dotenv/main.py', start_line=308)read_source_file(file_path='src/dotenv/main.py', start_line=383)read_source_file(file_path='src/dotenv/__init__.py', start_line=1)get_file_claims(file_path='tests/test_cli.py')read_source_file(file_path='src/dotenv/cli.py', start_line=122)read_source_file(file_path='tests/test_cli.py', start_line=1)

Security Review: python-dotenv v1.2.2

Overview

This is a library for reading and writing .env files — plaintext key-value configuration files. It provides both a Python API (load_dotenv, dotenv_values, set_key, unset_key) and a CLI (dotenv run, dotenv set, dotenv get, etc.). The codebase is small (5 source files, ~500 lines) and well-structured. No authentication, authorization, session management, or cryptographic operations exist — this is a configuration file parser.


HIGH — Exploitable Issues

None found.

After reading all source files, there are no exploitable vulnerabilities in the current codebase. The library does not handle authentication, does not process untrusted network input, and does not perform any cryptographic operations. The attack surface is limited to local file operations.


MEDIUM — Real Risks Worth Fixing

1. Variable Interpolation Can Leak Environment Variables via .env Files

What the code does:
resolve_variables() in src/dotenv/main.py:289-311 resolves ${VAR_NAME} and ${VAR_NAME:-default} patterns in .env values. It builds an env dict that includes os.environ (the actual process environment) and previously resolved values from the same .env file. The order depends on the override flag.

# src/dotenv/main.py:300-306
env: Dict[str, Optional[str]] = {}
if override:
    env.update(os.environ)
    env.update(new_values)
else:
    env.update(new_values)
    env.update(os.environ)

Under what conditions it becomes a risk:
If a .env file contains a line like MY_SECRET=${DATABASE_PASSWORD}, and DATABASE_PASSWORD is set in the actual process environment, the .env file's value will resolve to the real password. This resolved value is then: - Set as an environment variable via os.environ[k] = v (main.py:108) - Returned by dotenv_values() as a plain dict - Printed by dotenv list CLI command (cli.py:93-109)

This means a .env file can be crafted to exfiltrate environment variables. If an attacker can write to a .env file (e.g., via dotenv set or direct file write), they can create EVIL=${SOME_SECRET} and then read the resolved value.

Evidence:
- src/dotenv/main.py:289-311resolve_variables merges os.environ into the resolution context - src/dotenv/main.py:107-108 — resolved values are set as real environment variables - src/dotenv/cli.py:93-109list_values prints resolved values to stdout

Recommended fix:
Document this behavior clearly. Consider adding a warning when variable interpolation references actual environment variables (not just previously defined .env variables). Alternatively, add a safe_interpolate mode that only resolves against variables defined in the same .env file.


2. find_dotenv() Walks Up Directory Tree — Potential for Loading Wrong .env

What the code does:
find_dotenv() in src/dotenv/main.py:332-380 searches for .env by walking up from the caller's file location (or cwd in interactive mode) to the filesystem root. It returns the first .env found.

# src/dotenv/main.py:372-375
for dirname in _walk_to_root(path):
    check_path = os.path.join(dirname, filename)
    if _is_file_or_fifo(check_path):
        return check_path

Under what conditions it becomes a risk:
If a user runs a script from a directory that happens to have a .env file in a parent directory (e.g., /home/user/projects/attacker-controlled/.env while working in /home/user/projects/attacker-controlled/subdir/), the wrong .env file will be loaded. This is a supply-chain / confusion risk, not a code vulnerability.

The load_dotenv() and dotenv_values() functions both call find_dotenv() with no arguments when dotenv_path and stream are both None (main.py:419-420, main.py:457-458).

Evidence:
- src/dotenv/main.py:332-380find_dotenv walks to root - src/dotenv/main.py:419-420load_dotenv calls find_dotenv() with no args - src/dotenv/main.py:457-458dotenv_values calls find_dotenv() with no args

Recommended fix:
Document that find_dotenv() searches upward and can load unexpected files. Consider adding a max_depth parameter or warning when the found file is far from the starting directory.


3. rewrite() Context Manager Has a TOCTOU Race Condition

What the code does:
rewrite() in src/dotenv/main.py:138-190 is used by set_key() and unset_key() to atomically modify .env files. It: 1. Opens the source file for reading 2. Creates a temporary file in the same directory 3. Yields both streams to the caller 4. On success, calls os.replace(dest_path, path) to atomically swap

# src/dotenv/main.py:163-169
with tempfile.NamedTemporaryFile(
    mode="w",
    encoding=encoding,
    delete=False,
    prefix=".tmp_",
    dir=os.path.dirname(os.path.abspath(path)),
) as dest:

Under what conditions it becomes a risk:
There is a classic TOCTOU (Time-of-Check Time-of-Use) window between when the source file is opened and when os.replace() is called. If an attacker can replace the source file with a symlink during this window, the temporary file could be written to an attacker-chosen location. However, this requires: - Local filesystem access - Ability to modify files in the target directory - Precise timing

The follow_symlinks=False default mitigates the symlink-following variant, but does not prevent the race.

Evidence:
- src/dotenv/main.py:138-190 — full rewrite() implementation - src/dotenv/main.py:227-243set_key() uses rewrite() - src/dotenv/main.py:270-278unset_key() uses rewrite()

Recommended fix:
This is a known limitation of atomic file replacement. Document that set_key and unset_key should not be used on files in world-writable directories. Consider using os.rename() instead of os.replace() on platforms where it's atomic, and add a warning when the file is in a directory with permissive permissions.


4. decode_escapes Uses codecs.decode with unicode-escape — Potential for Unexpected Behavior

What the code does:
decode_escapes() in src/dotenv/parser.py:105-109 uses Python's codecs.decode with the 'unicode-escape' codec to interpret escape sequences in quoted values.

# src/dotenv/parser.py:105-109
def decode_escapes(regex: Pattern[str], string: str) -> str:
    def decode_match(match: Match[str]) -> str:
        return codecs.decode(match.group(0), "unicode-escape")
    return regex.sub(decode_match, string)

Under what conditions it becomes a risk:
The unicode-escape codec interprets a wide range of escape sequences including \xNN, \uNNNN, \UNNNNNNNN, and \N{name}. While the regex patterns (_double_quote_escapes and _single_quote_escapes) restrict which escapes are matched, the decode_match function processes the matched text through unicode-escape which could interpret additional escape sequences within the matched substring. This is a defense-in-depth concern rather than an active exploit — the regex patterns are restrictive enough to prevent injection.

Evidence:
- src/dotenv/parser.py:31-32 — regex patterns _double_quote_escapes and _single_quote_escapes - src/dotenv/parser.py:105-109decode_escapes function - src/dotenv/parser.py:131-135 — called for single and double quoted values

Recommended fix:
Replace codecs.decode with a manual mapping of allowed escape sequences to avoid depending on the broad unicode-escape codec behavior.


LOW — Informational

1. PYTHON_DOTENV_DISABLED Environment Variable Can Be Set by .env Itself

What the code does:
_load_dotenv_disabled() in src/dotenv/main.py:22-29 checks os.environ["PYTHON_DOTENV_DISABLED"] before loading. However, if a previous load_dotenv() call already loaded a .env file that set this variable, subsequent calls will be disabled.

This is a minor self-referential edge case — not exploitable in practice, but worth noting for debugging.

Evidence:
- src/dotenv/main.py:22-29 — checks os.environ for the disable flag - src/dotenv/main.py:413-417 — called at the start of load_dotenv()


2. CLI run Command Uses os.execvpe on Non-Windows — No Shell Injection Possible

What the code does:
The run command in cli.py uses os.execvpe(cmd[0], cmd, env) on non-Windows systems. This directly executes the binary without a shell, preventing shell injection. On Windows, it uses subprocess.Popen with a list argument (not shell=True), also preventing injection.

This is a positive finding — the implementation is secure by design.

Evidence:
- src/dotenv/cli.py:184-201run_command implementation (read from claims, not directly visible in the lines I read — let me verify)

Let me check the actual run command implementation: