Stop build.py sweeping secrets and bytecode into gen/

gen/<room>/ 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/<room>/.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) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 02:59:26 -03:00
parent 78595f1bd9
commit a65c92257d
2 changed files with 52 additions and 1 deletions

View File

@@ -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/<room>/ 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/<room>/.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): def copy_path(source: Path, target: Path, quiet: bool = False):
"""Copy file or directory, resolving symlinks.""" """Copy file or directory, resolving symlinks."""
if target.is_symlink(): if target.is_symlink():
@@ -91,7 +115,7 @@ def copy_path(source: Path, target: Path, quiet: bool = False):
target.unlink() target.unlink()
if source.is_dir(): if source.is_dir():
shutil.copytree(source, target, symlinks=False) shutil.copytree(source, target, symlinks=False, ignore=_copytree_ignore)
if not quiet: if not quiet:
log.info(f" {target.name}/") log.info(f" {target.name}/")
else: else:
@@ -111,6 +135,8 @@ def merge_into(source: Path, target: Path):
for item in source.rglob("*"): for item in source.rglob("*"):
if item.is_file(): if item.is_file():
rel = item.relative_to(source) rel = item.relative_to(source)
if any(is_ignored(part) for part in rel.parts):
continue
dest = target / rel dest = target / rel
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, dest) shutil.copy2(item, dest)
@@ -687,6 +713,7 @@ def build_soleprint(output_dir: Path, room: str):
"index.html", "index.html",
"requirements.txt", "requirements.txt",
"Dockerfile", "Dockerfile",
".dockerignore",
]: ]:
if (soleprint / name).exists(): if (soleprint / name).exists():
copy_path(soleprint / name, output_dir / name) copy_path(soleprint / name, output_dir / name)

24
soleprint/.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
# The build context is gen/<room>/, 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/<room>/ 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/