From a65c92257d7eb60f52c4fa864372c2c09a6f5cb8 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Wed, 19 Aug 2026 02:59:26 -0300 Subject: [PATCH] Stop build.py sweeping secrets and bytecode into gen/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gen// is the docker build context and soleprint/Dockerfile is `COPY . .`, so anything reaching gen/ reaches an image layer — and registry.mcrn.ar is public-read. station/tools/tester/.env has been gitignored since the last incident, but .gitignore does not bind shutil: copy_path() called shutil.copytree() with no ignore=, so the key was copied into every built room. Verified extractable from soleprint_localtest-soleprint:latest (built 8 days ago) at /app/station/tools/tester/.env. ctrl/deploy.sh's --exclude='.env' is why this looked handled; it only covers the rsync path, not the build-and-push path. Two layers now: - copy_path()/merge_into() filter .env, __pycache__, *.pyc, .git, node_modules and virtualenvs out of bulk directory copies. Single-file copies named by a caller are untouched, so cfg//.env.example still ships. - soleprint/.dockerignore repeats the rule at the docker boundary and is copied into the context beside the Dockerfile. Follows the convention soleprint/atlas/.dockerignore already set (.env, .env.*, !.env.example). Runtime is unaffected: no Dockerfile COPYs a .env, and the room compose files supply it with `env_file: - .env`, read from the host at run time. The key itself still needs rotating — it remains in git history. Co-Authored-By: Claude Opus 5 (1M context) --- build.py | 29 ++++++++++++++++++++++++++++- soleprint/.dockerignore | 24 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 soleprint/.dockerignore diff --git a/build.py b/build.py index c3530c5..e155025 100644 --- a/build.py +++ b/build.py @@ -80,6 +80,30 @@ def _rmtree_resilient(path: Path): ) +# Never swept into a built room, wherever they appear in a source tree. +# +# This is a SECURITY boundary, not tidiness. gen// is the docker build +# context, soleprint/Dockerfile is `COPY . .`, and there is no .dockerignore — +# so anything that reaches gen/ reaches an image layer, and registry.mcrn.ar is +# public-read. That is how station/tools/tester/.env, gitignored since the last +# incident, still ended up baked into soleprint_localtest-soleprint:latest with +# its API key intact. .gitignore does not bind shutil. +# +# Applied to bulk directory copies only. A caller naming a single file is making +# an explicit request (cfg//.env.example is the one that matters) and is +# left alone. +ALWAYS_IGNORE = {".git", "__pycache__", "node_modules", ".venv", "venv", ".env"} +ALWAYS_IGNORE_SUFFIXES = (".pyc", ".pyo") + + +def is_ignored(name: str) -> bool: + return name in ALWAYS_IGNORE or name.endswith(ALWAYS_IGNORE_SUFFIXES) + + +def _copytree_ignore(directory, files): + return {f for f in files if is_ignored(f)} + + def copy_path(source: Path, target: Path, quiet: bool = False): """Copy file or directory, resolving symlinks.""" if target.is_symlink(): @@ -91,7 +115,7 @@ def copy_path(source: Path, target: Path, quiet: bool = False): target.unlink() if source.is_dir(): - shutil.copytree(source, target, symlinks=False) + shutil.copytree(source, target, symlinks=False, ignore=_copytree_ignore) if not quiet: log.info(f" {target.name}/") else: @@ -111,6 +135,8 @@ def merge_into(source: Path, target: Path): for item in source.rglob("*"): if item.is_file(): rel = item.relative_to(source) + if any(is_ignored(part) for part in rel.parts): + continue dest = target / rel dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(item, dest) @@ -687,6 +713,7 @@ def build_soleprint(output_dir: Path, room: str): "index.html", "requirements.txt", "Dockerfile", + ".dockerignore", ]: if (soleprint / name).exists(): copy_path(soleprint / name, output_dir / name) diff --git a/soleprint/.dockerignore b/soleprint/.dockerignore new file mode 100644 index 0000000..47f779b --- /dev/null +++ b/soleprint/.dockerignore @@ -0,0 +1,24 @@ +# The build context is gen//, and the Dockerfile is `COPY . .` — so this +# file is the last thing standing between a stray secret and a public image +# layer. build.py already filters these out of the copy into gen/; this repeats +# the rule at the docker boundary so a hand-built context, or a future copy path +# that forgets, still cannot bake one in. +# +# Copied into gen// by build.py's named-file list alongside the Dockerfile. + +.env +.env.* +**/.env +**/.env.* +!.env.example +!**/.env.example + +__pycache__/ +**/__pycache__/ +*.pyc +*.pyo + +.git/ +.venv/ +venv/ +node_modules/