From 78bb5649d4499b57db4452035fba82800b6329ba Mon Sep 17 00:00:00 2001 From: Steve Pelech Date: Wed, 28 Oct 2026 10:04:20 -0500 Subject: [PATCH 1/2] fix(deploy): reject stale dashboard bundles in overlay builds Add a fresh-build-only manifest and verified build/sync pipeline. Validate source inputs and exact bundle files before installing the dashboard in both served directories, removing base-image leftovers. Refs #53 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 4 + Dockerfile.overlay | 15 ++- docs/guide/enterprise-build.md | 53 ++++++++- scripts/frontend_artifact.py | 114 ++++++++++++++++++ tests/test_frontend_artifact.py | 200 ++++++++++++++++++++++++++++++++ 5 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 scripts/frontend_artifact.py create mode 100644 tests/test_frontend_artifact.py diff --git a/.gitattributes b/.gitattributes index 393d4c6..bdff674 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ *.sh text eol=lf docker-entrypoint.sh text eol=lf + +# Preserve the exact bytes covered by dashboard build manifests. +frontend/dist/** -text +www/** -text diff --git a/Dockerfile.overlay b/Dockerfile.overlay index 00a33fc..b03db34 100644 --- a/Dockerfile.overlay +++ b/Dockerfile.overlay @@ -2,6 +2,14 @@ # check=skip=InvalidDefaultArgInFrom # Supply an approved ContextCortex release image, preferably pinned by digest. ARG RUNTIME_IMAGE +FROM ${RUNTIME_IMAGE} AS dashboard-check +WORKDIR /dashboard-check +COPY scripts/frontend_artifact.py ./scripts/frontend_artifact.py +COPY frontend/ ./frontend/ +COPY www/ ./www/ +# Never attest the checked-in assets here: only a successful fresh build may do so. +RUN python scripts/frontend_artifact.py verify + FROM ${RUNTIME_IMAGE} WORKDIR /app @@ -14,9 +22,10 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ COPY app/ ./app/ COPY main.py ./main.py -# The server prefers frontend/dist over www; keep both on this checkout's UI. -COPY www/ ./www/ -COPY www/ ./frontend/dist/ +# Remove base-image leftovers; the server prefers frontend/dist over www. +RUN rm -rf /app/www /app/frontend/dist +COPY --from=dashboard-check /dashboard-check/www/ ./www/ +COPY --from=dashboard-check /dashboard-check/www/ ./frontend/dist/ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh diff --git a/docs/guide/enterprise-build.md b/docs/guide/enterprise-build.md index e420aa2..aca01d5 100644 --- a/docs/guide/enterprise-build.md +++ b/docs/guide/enterprise-build.md @@ -168,7 +168,49 @@ credential-bearing proxy URLs in the image. ## Build an overlay without package-registry access Use this when you already have an approved ContextCortex release image with -compatible dependencies and want to build these integration changes locally: +compatible dependencies and want to build these integration changes locally. +The overlay itself needs no npm access, but it **requires a dashboard built from +the matching frontend inputs**. It fails closed for missing provenance, changed +source/configuration/lockfiles, or missing, modified, or extra output files. +The historical checked-in bundle has no such provenance and is rejected. + +On an approved build host with Python 3.11+, Node 22, and the locked frontend +dependencies installed, publish the dashboard from the checkout root: + +```powershell +# Only when dependencies are not already installed; use your approved npm config. +npm ci --prefix frontend +python scripts\frontend_artifact.py build +python scripts\frontend_artifact.py sync +python scripts\frontend_artifact.py verify +``` + +`build` deletes old `frontend/dist`, runs the existing `npm run build`, and writes +`build-manifest.json` only after successful compilation with unchanged inputs. +`sync` verifies that output and replaces `www` exactly, removing obsolete assets. +There is deliberately no command to stamp an existing bundle. Plain `npm run build` +still supports local development, but does not publish an overlay artifact. +Review and commit the regenerated `frontend/dist` and `www` assets, including +their manifests, together with the frontend change. Transfer the matching checkout +and generated assets to an offline overlay builder if needed. + +The manifest detects accidental staleness and output corruption; it is not a +signature or a substitute for trusted build provenance. It hashes frontend root +files and `src`, `public`, `packages`, and `scripts`, excluding npm credentials, +logs, and TypeScript build caches. Keep frontend build inputs in these locations. +UTF-8 source line endings are normalized for Windows/Linux checkouts; output +hashes cover exact bytes, preserved by `.gitattributes` for generated assets. +Frontend `.env` inputs excluded by `.dockerignore` cannot be verified in the +overlay; do not use them for a published overlay build. Root runtime `.env` is +unaffected. + +If the approved mirror lacks an exact locked dependency (for example +`@antfu/install-pkg@2.1.0`), arrange mirror approval/import before building. +Do not downgrade dependencies, bypass the mirror, disable TLS/integrity checks, +or create a manifest for the old bundle. Until a genuine build is available, +the overlay is intentionally unavailable rather than serving a pre-auth dashboard. + +Then build the overlay: ```powershell docker build --file Dockerfile.overlay --tag contextcortex-enterprise:local ` @@ -178,9 +220,12 @@ docker build --file Dockerfile.overlay --tag contextcortex-enterprise:local ` `RUNTIME_IMAGE` is intentionally required; there is no implicit mutable `latest` fallback. Use a release image matching this checkout's requirements and verify compatibility. Review/scan/promote the base image according to enterprise policy. -This build uses the checked-in dashboard bundle, not a new npm build, and does -not upgrade dependencies. Dependency upgrades require a new approved base/source -build. Private CAs and the runtime entrypoint are included. +This build verifies the published dashboard before copying the same exact files +into both `/app/www` and the server-preferred `/app/frontend/dist`, removing any +base-image leftovers. It does not run npm or upgrade dependencies. Dependency +upgrades require a new approved base/source build. Private CAs and the runtime +entrypoint are included. The full source `Dockerfile` continues to compile the +frontend directly in its build stage instead of consuming `www`. ## Run the built image diff --git a/scripts/frontend_artifact.py b/scripts/frontend_artifact.py new file mode 100644 index 0000000..5c1f126 --- /dev/null +++ b/scripts/frontend_artifact.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Build, synchronize, and verify the dashboard shipped by image overlays.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +MANIFEST = "build-manifest.json" + + +def inventory(directory: Path, *, sources: bool = False) -> dict[str, str]: + if sources: + paths = [path for path in directory.iterdir() if path.is_file()] + for name in ("src", "public", "packages", "scripts"): + paths.extend((directory / name).rglob("*")) + paths = [ + path for path in paths + if path.name != ".npmrc" + and not path.name.endswith((".tsbuildinfo", ".log")) + ] + else: + paths = [path for path in directory.rglob("*") if path != directory / MANIFEST] + result = {} + for path in sorted(paths): + if not path.is_file(): + continue + content = path.read_bytes() + if sources: + try: + content.decode("utf-8") + except UnicodeDecodeError: + pass + else: + content = content.replace(b"\r\n", b"\n") + result[path.relative_to(directory).as_posix()] = hashlib.sha256(content).hexdigest() + return result + + +def verify(root: Path, bundle: Path) -> None: + manifest_path = bundle / MANIFEST + if not manifest_path.is_file(): + raise ValueError( + "Unverified dashboard: missing build-manifest.json. " + "Run python scripts/frontend_artifact.py build, then sync; " + "do not stamp an existing bundle." + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(manifest, dict) or manifest.get("schema") != 1: + raise ValueError("Unverified dashboard: unsupported build manifest.") + if manifest.get("sources") != inventory(root / "frontend", sources=True): + raise ValueError("Stale dashboard: frontend inputs changed; rebuild and sync.") + if not (bundle / "index.html").is_file() or manifest.get("files") != inventory(bundle): + raise ValueError("Invalid dashboard: output files changed; rebuild and sync.") + + +def build(root: Path) -> None: + frontend = root / "frontend" + dist = frontend / "dist" + before = inventory(frontend, sources=True) + if dist.exists(): + shutil.rmtree(dist) + npm = shutil.which("npm.cmd" if os.name == "nt" else "npm") + if not npm: + raise ValueError("Dashboard build requires npm and the locked frontend dependencies.") + subprocess.run([npm, "run", "build"], cwd=frontend, check=True) + if before != inventory(frontend, sources=True): + raise ValueError("Dashboard source changed during build; retry with stable inputs.") + if not (dist / "index.html").is_file(): + raise ValueError("Dashboard build did not produce dist/index.html.") + manifest = {"schema": 1, "sources": before, "files": inventory(dist)} + (dist / MANIFEST).write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + verify(root, dist) + + +def sync(root: Path) -> None: + dist = root / "frontend" / "dist" + verify(root, dist) + destination = root / "www" + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(dist, destination) + verify(root, destination) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("build", "sync", "verify")) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args() + root = args.root.resolve() + try: + if args.command == "build": + build(root) + elif args.command == "sync": + sync(root) + else: + verify(root, root / "www") + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print(f"Dashboard artifact error: {error}", file=sys.stderr) + return 1 + print(f"Dashboard artifact {args.command}: OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_frontend_artifact.py b/tests/test_frontend_artifact.py new file mode 100644 index 0000000..c32dd7a --- /dev/null +++ b/tests/test_frontend_artifact.py @@ -0,0 +1,200 @@ +"""Exercise dashboard publication without installing frontend dependencies.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / "scripts" / "frontend_artifact.py" + + +@pytest.fixture +def checkout(tmp_path): + frontend = tmp_path / "frontend" + (frontend / "src").mkdir(parents=True) + (frontend / "src" / "app.js").write_text("current dashboard", encoding="utf-8") + (frontend / "package.json").write_text( + json.dumps({"scripts": {"build": "node build.cjs"}}), encoding="utf-8" + ) + (frontend / "package-lock.json").write_text("{}", encoding="utf-8") + (frontend / "index.html").write_text("
", encoding="utf-8") + # A dependency-free compiler fixture; the real npm process must succeed + # before publication, just as with the application's TypeScript/Vite build. + (frontend / "build.cjs").write_text( + "const fs = require('node:fs');\n" + "fs.mkdirSync('dist/assets', {recursive: true});\n" + "fs.copyFileSync('index.html', 'dist/index.html');\n" + "fs.copyFileSync('src/app.js', 'dist/assets/app.js');\n", + encoding="utf-8", + ) + (tmp_path / "www").mkdir() + (tmp_path / "www" / "obsolete.js").write_text("old dashboard", encoding="utf-8") + return tmp_path + + +def run_tool(root, command): + return subprocess.run( + [sys.executable, str(TOOL), command, "--root", str(root)], + capture_output=True, + text=True, + check=False, + ) + + +def build_and_sync(root): + for command in ("build", "sync"): + result = run_tool(root, command) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_fresh_build_syncs_exact_output_and_removes_obsolete_assets(checkout): + build_and_sync(checkout) + result = run_tool(checkout, "verify") + assert result.returncode == 0, result.stdout + result.stderr + assert (checkout / "www" / "assets" / "app.js").read_text() == "current dashboard" + assert not (checkout / "www" / "obsolete.js").exists() + assert sorted(p.relative_to(checkout / "www") for p in (checkout / "www").rglob("*")) == sorted( + p.relative_to(checkout / "frontend" / "dist") + for p in (checkout / "frontend" / "dist").rglob("*") + ) + + +def test_unverified_bundle_cannot_be_stamped_or_synced(checkout): + dist = checkout / "frontend" / "dist" + shutil.copytree(checkout / "www", dist) + for command in ("verify", "sync"): + result = run_tool(checkout, command) + assert result.returncode != 0 + assert "unverified dashboard" in result.stderr.lower() + assert not (dist / "build-manifest.json").exists() + assert (checkout / "www" / "obsolete.js").exists() + + +@pytest.mark.parametrize( + "relative_path", + [ + "frontend/src/app.js", + "frontend/package-lock.json", + "frontend/vite.config.js", + "frontend/public/new.svg", + "frontend/packages/helper.tgz", + "frontend/scripts/helper.js", + "frontend/.env.production", + "www/assets/app.js", + "www/assets/extra.js", + "www/assets/build-manifest.json", + "www/index.html", + ], +) +def test_source_or_output_changes_reject_previously_built_bundle(checkout, relative_path): + build_and_sync(checkout) + path = checkout / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("changed after build", encoding="utf-8") + result = run_tool(checkout, "verify") + assert result.returncode != 0 + assert "dashboard" in result.stderr.lower() + + +def test_deleted_asset_is_rejected(checkout): + build_and_sync(checkout) + (checkout / "www" / "assets" / "app.js").unlink() + assert run_tool(checkout, "verify").returncode != 0 + + +def test_source_line_endings_do_not_invalidate_transferred_artifacts(checkout): + source = checkout / "frontend" / "src" / "app.js" + source.write_bytes(b"current dashboard\n") + build_and_sync(checkout) + source.write_bytes(b"current dashboard\r\n") + result = run_tool(checkout, "verify") + assert result.returncode == 0, result.stdout + result.stderr + + +def test_failed_build_cannot_reuse_old_output_or_publish_manifest(checkout): + build_and_sync(checkout) + (checkout / "frontend" / "build.cjs").write_text("process.exit(7)", encoding="utf-8") + result = run_tool(checkout, "build") + assert result.returncode != 0 + assert not (checkout / "frontend" / "dist" / "build-manifest.json").exists() + assert run_tool(checkout, "sync").returncode != 0 + assert (checkout / "www" / "assets" / "app.js").read_text() == "current dashboard" + + +def test_source_change_during_build_cannot_be_attested(checkout): + with (checkout / "frontend" / "build.cjs").open("a", encoding="utf-8") as script: + script.write("fs.writeFileSync('src/app.js', 'raced source');\n") + result = run_tool(checkout, "build") + assert result.returncode != 0 + assert "source changed during build" in result.stderr.lower() + assert not (checkout / "frontend" / "dist" / "build-manifest.json").exists() + assert (checkout / "www" / "obsolete.js").exists() + + +def overlay_context(checkout): + for name in ("Dockerfile.overlay", ".dockerignore", "main.py", "docker-entrypoint.sh"): + shutil.copy2(ROOT / name, checkout / name) + (checkout / "scripts").mkdir() + shutil.copy2(TOOL, checkout / "scripts" / TOOL.name) + shutil.copytree(ROOT / "app", checkout / "app", ignore=shutil.ignore_patterns("__pycache__")) + (checkout / "certs").mkdir() + shutil.copy2(ROOT / "certs" / "README.md", checkout / "certs" / "README.md") + + +def docker_build(checkout): + runtime = os.environ.get("ISSUE53_RUNTIME_IMAGE") + if not runtime: + pytest.skip("Set ISSUE53_RUNTIME_IMAGE to a cached Python runtime for Docker regression") + overlay_context(checkout) + return subprocess.run( + [ + "docker", "build", "--network=none", "--pull=false", "--progress=plain", + "--file", "Dockerfile.overlay", + "--tag", "contextcortex-issue53-regression:test", + "--build-arg", f"RUNTIME_IMAGE={runtime}", ".", + ], + cwd=checkout, + capture_output=True, + text=True, + check=False, + ) + + +def test_overlay_build_rejects_unverified_dashboard(checkout): + result = docker_build(checkout) + output = result.stdout + result.stderr + assert result.returncode != 0, "Overlay silently accepted the stale dashboard" + assert "unverified dashboard" in output.lower(), output + + +def test_overlay_installs_verified_output_in_both_served_directories(checkout): + if not os.environ.get("ISSUE53_RUNTIME_IMAGE"): + pytest.skip("Set ISSUE53_RUNTIME_IMAGE to a cached Python runtime for Docker regression") + build_and_sync(checkout) + result = docker_build(checkout) + assert result.returncode == 0, result.stdout + result.stderr + probe = ( + "from pathlib import Path; " + "expected = {'index.html', 'assets/app.js', 'build-manifest.json'}; " + "bundles = [Path('/app/www'), Path('/app/frontend/dist')]; " + "assert all({p.relative_to(b).as_posix() for p in b.rglob('*') if p.is_file()} " + "== expected for b in bundles); " + "assert all((b / 'assets/app.js').read_text() == 'current dashboard' for b in bundles)" + ) + result = subprocess.run( + [ + "docker", "run", "--rm", "--network=none", + "--name", "contextcortex-issue53-verify", + "--entrypoint", "python", "contextcortex-issue53-regression:test", "-c", probe, + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr From 6109b6a182494037770602ae9a87d6d761523e73 Mon Sep 17 00:00:00 2001 From: Steve Pelech Date: Wed, 28 Oct 2026 10:21:44 -0500 Subject: [PATCH 2/2] docs: synchronize frontend artifact requirement inventory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- REQUIREMENTS.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 4b33fd0..9153f97 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **1115 Automated Tests** (757 Pytest Backend + 308 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1124 Automated Tests** (766 Pytest Backend + 308 Vitest Frontend + 50 Playwright E2E). --- @@ -1034,6 +1034,17 @@ Asserts both Markdown and PDF docs are matched under doc_type='doc'._ - `test_set_file_settings_persists` - `test_file_settings_api_endpoints` +#### `tests/test_frontend_artifact.py` (9 tests) +- `test_fresh_build_syncs_exact_output_and_removes_obsolete_assets` +- `test_unverified_bundle_cannot_be_stamped_or_synced` +- `test_source_or_output_changes_reject_previously_built_bundle` +- `test_deleted_asset_is_rejected` +- `test_source_line_endings_do_not_invalidate_transferred_artifacts` +- `test_failed_build_cannot_reuse_old_output_or_publish_manifest` +- `test_source_change_during_build_cannot_be_attested` +- `test_overlay_build_rejects_unverified_dashboard` +- `test_overlay_installs_verified_output_in_both_served_directories` + #### `tests/test_git_incremental.py` (6 tests) - `test_compute_git_repo_delta` - `test_compute_git_repo_delta_custom_extensions`