210 lines
8.4 KiB
Python
210 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail when a supported image reintroduces mutable build inputs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DOCKERFILES = (
|
|
Path("Dockerfile.gateway"),
|
|
Path("Dockerfile.orchestrator"),
|
|
Path("Dockerfile.orchestrator.fc"),
|
|
Path("bot_bottle/contrib/claude/Dockerfile"),
|
|
Path("bot_bottle/contrib/codex/Dockerfile"),
|
|
Path("bot_bottle/contrib/pi/Dockerfile"),
|
|
)
|
|
NPM_MANIFESTS = (
|
|
Path("bot_bottle/contrib/claude/package.json"),
|
|
Path("bot_bottle/contrib/pi/package.json"),
|
|
)
|
|
|
|
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
_EXACT_NPM_VERSION = re.compile(
|
|
r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$",
|
|
)
|
|
_SNAPSHOT = re.compile(r"ARG\s+DEBIAN_SNAPSHOT=\d{8}T\d{6}Z")
|
|
_NETWORK_TO_SHELL = re.compile(
|
|
r"(?:curl|wget)\b[^|\n]*\|\s*(?:ba)?sh\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _logical_lines(text: str) -> str:
|
|
"""Join Dockerfile continuations so policy regexes see one instruction."""
|
|
return re.sub(r"\\\r?\n", " ", text)
|
|
|
|
|
|
def check_dockerfile(path: Path, text: str) -> list[str]:
|
|
"""Return policy violations for one Dockerfile."""
|
|
problems: list[str] = []
|
|
logical = _logical_lines(text)
|
|
instructions = "\n".join(
|
|
line for line in logical.splitlines()
|
|
if not line.lstrip().startswith("#")
|
|
)
|
|
from_values = re.findall(r"(?im)^\s*FROM\s+(\S+)", logical)
|
|
if not from_values:
|
|
problems.append(f"{path}: missing FROM")
|
|
for value in from_values:
|
|
if value == "${ORCHESTRATOR_BASE_IMAGE}":
|
|
if not re.search(
|
|
r"(?m)^\s*ARG\s+ORCHESTRATOR_BASE_IMAGE\s*$",
|
|
text,
|
|
):
|
|
problems.append(
|
|
f"{path}: local base argument must have no mutable default",
|
|
)
|
|
continue
|
|
if "$" in value:
|
|
problems.append(f"{path}: dynamic base image is not content-pinned: {value}")
|
|
continue
|
|
try:
|
|
image, digest = value.rsplit("@sha256:", 1)
|
|
except ValueError:
|
|
problems.append(f"{path}: base image lacks a sha256 digest: {value}")
|
|
continue
|
|
leaf = image.rsplit("/", 1)[-1]
|
|
if ":" not in leaf:
|
|
problems.append(f"{path}: base image lacks a version-qualified tag: {value}")
|
|
elif leaf.rsplit(":", 1)[1] == "latest":
|
|
problems.append(f"{path}: base image uses latest: {value}")
|
|
if not _DIGEST.fullmatch(digest):
|
|
problems.append(f"{path}: base image digest is not 64 lowercase hex: {value}")
|
|
|
|
if _NETWORK_TO_SHELL.search(instructions):
|
|
problems.append(f"{path}: downloads network content directly into a shell")
|
|
if re.search(r"\b(?:npm\s+(?:i|install)|pi\s+install)\b", instructions):
|
|
problems.append(f"{path}: use npm ci with a committed integrity lock")
|
|
if "apt-get install" in instructions:
|
|
if not _SNAPSHOT.search(text):
|
|
problems.append(f"{path}: apt install lacks a fixed Debian snapshot")
|
|
if "snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" not in text:
|
|
problems.append(f"{path}: apt sources are not routed to the snapshot")
|
|
if "Acquire::Check-Valid-Until=false" not in text:
|
|
problems.append(f"{path}: snapshot apt update lacks validity override")
|
|
if path == Path("Dockerfile.gateway"):
|
|
if "--require-hashes" not in instructions:
|
|
problems.append(f"{path}: gateway Python install does not require hashes")
|
|
if "requirements.gateway.lock" not in text:
|
|
problems.append(f"{path}: gateway Python lock is not consumed")
|
|
if path == Path("bot_bottle/contrib/codex/Dockerfile"):
|
|
required = (
|
|
"install.sh.sha256",
|
|
"sha256sum -c",
|
|
'--release "${CODEX_VERSION}"',
|
|
)
|
|
for marker in required:
|
|
if marker not in instructions:
|
|
problems.append(f"{path}: verified pinned Codex install lacks {marker!r}")
|
|
return problems
|
|
|
|
|
|
def check_npm_manifest(root: Path, manifest_path: Path) -> list[str]:
|
|
"""Validate exact direct versions and integrity-complete npm locks."""
|
|
problems: list[str] = []
|
|
manifest_file = root / manifest_path
|
|
lock_file = manifest_file.with_name("package-lock.json")
|
|
try:
|
|
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
|
lock = json.loads(lock_file.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
return [f"{manifest_path}: cannot read manifest and lock: {exc}"]
|
|
|
|
direct = manifest.get("dependencies", {})
|
|
for package, version in direct.items():
|
|
if not isinstance(version, str) or not _EXACT_NPM_VERSION.fullmatch(version):
|
|
problems.append(
|
|
f"{manifest_path}: {package} is not an exact version: {version!r}",
|
|
)
|
|
root_lock = lock.get("packages", {}).get("", {}).get("dependencies", {})
|
|
if root_lock != direct:
|
|
problems.append(f"{lock_file.relative_to(root)}: root dependencies are stale")
|
|
if lock.get("lockfileVersion") != 3:
|
|
problems.append(f"{lock_file.relative_to(root)}: require lockfileVersion 3")
|
|
for package_path, package in lock.get("packages", {}).items():
|
|
if not package_path or package.get("link"):
|
|
continue
|
|
resolved = package.get("resolved", "")
|
|
if isinstance(resolved, str) and resolved.startswith(("http://", "https://")):
|
|
integrity = package.get("integrity", "")
|
|
if not isinstance(integrity, str) or not integrity.startswith("sha512-"):
|
|
problems.append(
|
|
f"{lock_file.relative_to(root)}: "
|
|
f"{package_path} lacks sha512 integrity",
|
|
)
|
|
return problems
|
|
|
|
|
|
def check_python_lock(root: Path) -> list[str]:
|
|
"""Validate that every gateway Python requirement is exact and hashed."""
|
|
path = root / "requirements.gateway.lock"
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError as exc:
|
|
return [f"requirements.gateway.lock: cannot read lock: {exc}"]
|
|
problems: list[str] = []
|
|
blocks = re.split(r"(?m)(?=^[A-Za-z0-9_.-]+==)", text)
|
|
requirements = [block for block in blocks if re.match(r"^[A-Za-z0-9_.-]+==", block)]
|
|
if not requirements:
|
|
problems.append("requirements.gateway.lock: contains no exact requirements")
|
|
for block in requirements:
|
|
name = block.split("==", 1)[0]
|
|
first_line = block.splitlines()[0]
|
|
if not re.match(r"^[A-Za-z0-9_.-]+==[^\s\\]+", first_line):
|
|
problems.append(f"requirements.gateway.lock: {name} is not exact")
|
|
if not re.search(r"--hash=sha256:[0-9a-f]{64}", block):
|
|
problems.append(f"requirements.gateway.lock: {name} lacks a sha256 hash")
|
|
try:
|
|
direct = [
|
|
line.strip()
|
|
for line in (root / "requirements.gateway.in").read_text().splitlines()
|
|
if line.strip() and not line.lstrip().startswith("#")
|
|
]
|
|
except OSError as exc:
|
|
problems.append(f"requirements.gateway.in: cannot read input: {exc}")
|
|
direct = []
|
|
for requirement in direct:
|
|
if not re.fullmatch(r"[A-Za-z0-9_.-]+==[^\s]+", requirement):
|
|
problems.append(
|
|
f"requirements.gateway.in: direct dependency is not exact: {requirement}",
|
|
)
|
|
if not re.search(rf"(?m)^{re.escape(requirement)}(?:\s|\\)", text):
|
|
problems.append(
|
|
f"requirements.gateway.lock: missing direct dependency {requirement}",
|
|
)
|
|
return problems
|
|
|
|
|
|
def check_repo(root: Path = REPO_ROOT) -> list[str]:
|
|
"""Return every repository image-input policy violation."""
|
|
problems: list[str] = []
|
|
for path in DOCKERFILES:
|
|
try:
|
|
text = (root / path).read_text(encoding="utf-8")
|
|
except OSError as exc:
|
|
problems.append(f"{path}: cannot read Dockerfile: {exc}")
|
|
continue
|
|
problems.extend(check_dockerfile(path, text))
|
|
for manifest in NPM_MANIFESTS:
|
|
problems.extend(check_npm_manifest(root, manifest))
|
|
problems.extend(check_python_lock(root))
|
|
return problems
|
|
|
|
|
|
def main() -> int:
|
|
problems = check_repo()
|
|
if problems:
|
|
for problem in problems:
|
|
print(f"image-input policy: {problem}", file=sys.stderr)
|
|
return 1
|
|
print("image-input policy: all supported image inputs are pinned and verified")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|