Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

Your Clone Throws KeyError. Harvest getenv From the AST.

You cloned the branch. You typed the command that looked identical to the agent's session. The interpreter died on KeyError: 'DATABASE_URL'. Your Python install is not the bug. The remote process started because its env

You cloned the branch. You typed the command that looked identical to the agent's session. The interpreter died on KeyError: 'DATABASE_URL'.

Your Python install is not the bug. The remote process started because its environment already held names nobody wrote into the tree. Your checkout has the code and no config contract.

This is a harvest. It is not a secret dump. You pull literal names from source, classify them, and rebuild .env.example without copying a single value from the scratch box.

The boot failure you just cloned

Watch the first half minute of a local start.

git switch agent/add-pagination
python -m app

You might get a raw KeyError. You might get a settings class complaining that REDIS_URL is missing. You might get Compose interpolating an empty ${SMTP_HOST} and then failing later, in a worker, where the stack trace is less kind.

The agent did not forget how to write Python. It treated a living process environment as if it were a file. A scratch machine can hold leftovers from your own export, from a compose file that never got committed, or from a previous run that "fixed" boot by mutating the shell.

Why a remote boot is a bad spec

Scratch machines accumulate state. You export a database URL to try one query. The model retries. The app boots. Nobody adds the name to .env.example.

Copying printenv is worse than writing nothing. That output can contain tokens. It can contain host-only paths. It can contain values that work solely on that box.

You need names, requiredness, and defaults. You do not need the scratch box's secrets.

A cheap first boot still has a job. MonkeyCode offers free model access and a free server option. Use that pair to reproduce a traceback, not to freeze a living environment as documentation.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

If the session is still up, list names only. Then throw the session away and rebuild the contract from the tree.

Three files that must agree

Put these three artifacts on one screen.

  1. Code reads — every literal os.getenv, os.environ[...], and os.environ.get.
  2. Example file.env.example or env.sample, placeholders only.
  3. Orchestrationdocker-compose.yml, a compose.yaml, or a shell wrapper that interpolates ${NAME}.

A name in (1) but not in (2) means clones fail. A name in (2) but not in (1) means you ship folklore. A name in (3) with a different spelling means you will debug REDIS_URL versus REDIS_URI for an afternoon.

The rest of this article makes that diff mechanical.

Harvest, classify, write

Do the steps in this order. Do not invert them.

  1. Freeze the branch. git status --short should show only files you intend to keep.
  2. Run the harvester below at the service root. Save harvest.json.
  3. Read missing_from_example and example_only before you open an editor.
  4. Classify each name with the decision table.
  5. Write .env.example with empty or dummy values. Never with live secrets.
  6. Copy it to a gitignored .env, fill local values by hand, and start the app.
  7. Delete any printenv capture you took during the scratch session.

Skip step 7 and you will paste a password into a prompt later. Do not skip it.

A proposed AST harvester

Label this as a proposed script. It is not a vault. It is not a twelve-factor linter. It walks Python files for literal environment keys, then heuristically scans compose and shell files for ${NAME} and export NAME=.

Save it as scripts/harvest_env.py.

#!/usr/bin/env python3
"""Proposed env-name harvester. Review output before you commit anything."""

from __future__ import annotations

import ast
import json
import re
import sys
from pathlib import Path

SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", "dist"}
COMPOSE_NAME = re.compile(r"\$\{([A-Z][A-Z0-9_]*)(?::-?[^}]*)?\}")
ASSIGN_EXPORT = re.compile(r"^(?:export\s+)?([A-Z][A-Z0-9_]*)=", re.M)


class EnvVisitor(ast.NodeVisitor):
    def __init__(self, rel: str) -> None:
        self.rel = rel
        self.hits: list[dict] = []

    def _const(self, node: ast.AST) -> str | None:
        if isinstance(node, ast.Constant) and isinstance(node.value, str):
            return node.value
        return None

    def visit_Subscript(self, node: ast.Subscript) -> None:
        value = node.value
        sl = node.slice
        if (
            isinstance(value, ast.Attribute)
            and value.attr == "environ"
            and isinstance(sl, ast.Constant)
            and isinstance(sl.value, str)
        ):
            self.hits.append(
                {
                    "name": sl.value,
                    "method": "os.environ[]",
                    "default": None,
                    "file": self.rel,
                    "line": node.lineno,
                }
            )
        self.generic_visit(node)

    def visit_Call(self, node: ast.Call) -> None:
        func = node.func
        name = None
        default = None
        method = None

        if isinstance(func, ast.Attribute) and func.attr == "getenv":
            method = "os.getenv"
            if node.args:
                name = self._const(node.args[0])
            if len(node.args) > 1:
                default = self._const(node.args[1])
            for kw in node.keywords:
                if kw.arg == "key":
                    name = self._const(kw.value)
                if kw.arg == "default":
                    default = self._const(kw.value)

        if (
            isinstance(func, ast.Attribute)
            and func.attr == "get"
            and isinstance(func.value, ast.Attribute)
            and func.value.attr == "environ"
        ):
            method = "os.environ.get"
            if node.args:
                name = self._const(node.args[0])
            if len(node.args) > 1:
                default = self._const(node.args[1])

        if name:
            self.hits.append(
                {
                    "name": name,
                    "method": method,
                    "default": default,
                    "file": self.rel,
                    "line": node.lineno,
                }
            )
        self.generic_visit(node)


def iter_py(root: Path):
    for path in root.rglob("*.py"):
        if any(part in SKIP_DIRS for part in path.parts):
            continue
        yield path


def harvest_python(root: Path) -> list[dict]:
    hits: list[dict] = []
    for path in iter_py(root):
        try:
            tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
        except (SyntaxError, UnicodeDecodeError):
            continue
        visitor = EnvVisitor(str(path.relative_to(root)))
        visitor.visit(tree)
        hits.extend(visitor.hits)
    return hits


def harvest_compose_and_shell(root: Path) -> list[dict]:
    hits: list[dict] = []
    files: list[Path] = []
    for pat in ("docker-compose*.yml", "compose*.yml", "compose*.yaml", "*.sh"):
        files.extend(root.rglob(pat))
    for path in files:
        if any(part in SKIP_DIRS for part in path.parts):
            continue
        text = path.read_text(encoding="utf-8", errors="replace")
        rel = str(path.relative_to(root))
        for match in COMPOSE_NAME.finditer(text):
            hits.append(
                {
                    "name": match.group(1),
                    "method": "compose-or-shell-interpolation",
                    "default": None,
                    "file": rel,
                    "line": text[: match.start()].count("\n") + 1,
                }
            )
        if path.suffix == ".sh":
            for match in ASSIGN_EXPORT.finditer(text):
                hits.append(
                    {
                        "name": match.group(1),
                        "method": "shell-assign",
                        "default": None,
                        "file": rel,
                        "line": text[: match.start()].count("\n") + 1,
                    }
                )
    return hits


def load_example(root: Path) -> set[str]:
    names: set[str] = set()
    for candidate in (".env.example", "env.sample", ".env.sample"):
        path = root / candidate
        if not path.is_file():
            continue
        for line in path.read_text(encoding="utf-8").splitlines():
            raw = line.strip()
            if not raw or raw.startswith("#") or "=" not in raw:
                continue
            names.add(raw.split("=", 1)[0].strip())
    return names


def main() -> None:
    root = Path.cwd()
    hits = harvest_python(root) + harvest_compose_and_shell(root)
    example = load_example(root)
    code_names = {h["name"] for h in hits}
    report = {
        "cwd": str(root),
        "names": sorted(code_names),
        "missing_from_example": sorted(n for n in code_names if n not in example),
        "example_only": sorted(n for n in example if n not in code_names),
        "hits": hits,
        "note": "names only; never copy values from a scratch process",
    }
    Path("harvest.json").write_text(json.dumps(report, indent=2) + "\n")
    print(f"wrote harvest.json with {len(code_names)} names")
    print("missing_from_example:", ", ".join(report["missing_from_example"]) or "(none)")
    print("example_only:", ", ".join(report["example_only"]) or "(none)")


if __name__ == "__main__":
    main()

Run it at the service root, not from a nested package directory.

python scripts/harvest_env.py
python -m json.tool harvest.json | less

You now have a list of names. You do not have a closed set. os.getenv(prefix + suffix) will not appear. Neither will a Pydantic field that never calls os.environ in your tree.

Classify each name before you type a value

Use this table in review. Most bad .env.example files are classification errors, not typing errors.

Signal What it means Safe next action Unsafe next action
Literal os.environ["NAME"] and no default Boot throws without it Add NAME= to .env.example Paste the scratch value into git
os.getenv("NAME", "localhost") Code already has a fallback Document the default in a comment Ship localhost to production by accident
Name in compose interpolation only Orchestration expects it Align compose spelling with the code Rename in one file and not the other
Name in .env.example only Folklore or dead config Delete it or find the real read Keep it "in case"
Name looks like a secret (*_KEY, *_TOKEN, PASSWORD) Value must not enter git Placeholder only; rotate if it leaked Commit a "dev" password
App still reads a name the harvester missed Dynamic lookup or another language Grep, then add a human note Treat harvest.json as complete

Read the secret row twice. Values that lived on a scratch box are compromised for documentation purposes even if they "worked."

Watch spelling drift in orchestration. This fragment looks harmless.

# Proposed compose fragment. Spellings must match the code.
services:
  api:
    environment:
      DATABASE_URL: ${DATABASE_URL}
      REDIS_URL: ${REDIS_URL}

If the Python reads REDIS_URI, both names show up in harvest.json. Your clone can still fail. Diff the name list, do not skim the YAML.

Write the example file by hand

Do not ask a model to invent a .env from a remote printenv. You already have names. Fill placeholders yourself.

python - <<'PY'
import json
from pathlib import Path

data = json.loads(Path("harvest.json").read_text())
lines = ["# Skeleton only. Comments belong above secrets. Never paste live values."]
for name in data["names"]:
    lines.append(f"{name}=")
Path(".env.example").write_text("\n".join(lines) + "\n")
print("wrote .env.example skeleton")
PY

Then edit the skeleton. Put a one-line comment above secrets describing how a human obtains them. Keep public defaults in comments, not as values that look real.

# Required. Point at a local compose service, not at the scratch box.
DATABASE_URL=

# Optional. Code falls back to localhost:6379 if empty.
REDIS_URL=

# Required for password-reset mail. Leave empty to disable the route in dev.
SMTP_HOST=
SMTP_PASSWORD=

Start with a gitignored .env copied from that file. Fill local values by hand.

cp .env.example .env
# edit .env with local placeholders you generated yourself
python -m app

If it still throws, the visitor missed a dynamic key. Grep next. Do not open printenv.

grep -RInE 'os\.environ|os\.getenv|process\.env' \
  --exclude-dir=.git --exclude-dir=.venv --exclude-dir=node_modules \
  --exclude=harvest.json

Add the missed name to .env.example. Re-run the app. Repeat until a cold clone path boots with only documented names.

Keep the scratch shell out of the contract

You can still use a remote shell to watch a traceback. You cannot use it as the source of truth for config.

If the box is still alive and you must compare names, print names only:

# names only; discard the stream after you diff
env | cut -d= -f1 | sort > /tmp/scratch-env-names.txt
python -c 'import json; print("\n".join(json.load(open("harvest.json"))["names"]))' \
  | sort > /tmp/code-env-names.txt
diff -u /tmp/code-env-names.txt /tmp/scratch-env-names.txt
rm -f /tmp/scratch-env-names.txt

Extra names on the process are leftovers or libraries you have not documented. Missing names mean the remote boot leaned on defaults you will not want in production.

Never redirect printenv into the repo. Never paste values back into a model prompt so it can "write my .env."

Where this method breaks

The visitor does not understand os.environ.get(key) when key is a variable. It does not read Kubernetes generators. It does not invent Pydantic BaseSettings field names unless those fields also touch os.environ in your tree. It does not see variables consumed only inside a third-party library.

Makefiles and non-Python services will be undercounted unless you extend the regex pass. JSON output looks authoritative. It is a draft inventory.

Who should not use this approach?

  • Services that load every setting from a runtime vault with no literals in code
  • Monorepos where Python is a side language and the real reads live in Go or Rust
  • Pipelines that would upload harvest.json next to a live .env
  • Anyone hoping a remote env listing will satisfy an audit

Rotate anything that was printed in an agent log. The harvest does not rotate secrets for you.

After the clone boots

Commit .env.example and, if your team wants the check in review, the harvester. Do not commit .env. The sample script stores relative paths, which is usually fine to keep in harvest.json during the review, then delete.

A reviewer should be able to clone, copy the example file, fill a few local values, and start the app. If they cannot, the contract is still wrong. When a remote boot works and a local clone throws, run the harvester before you ask a model to invent a .env.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.