diff --git a/backend/tests/test_rc6_supply_chain.py b/backend/tests/test_rc6_supply_chain.py index ce836429..59cd987a 100644 --- a/backend/tests/test_rc6_supply_chain.py +++ b/backend/tests/test_rc6_supply_chain.py @@ -101,4 +101,18 @@ def test_release_image_uses_locked_non_ai_dependencies_and_npm_ci() -> None: assert "pip install --no-cache-dir --require-hashes -r requirements-runtime.lock" in dockerfile assert "ARG GEOINTEL_ULTRALYTICS_VERSION=8.4.99" in dockerfile assert '"ultralytics==$GEOINTEL_ULTRALYTICS_VERSION"' in dockerfile + assert "ARG GEOINTEL_SETUPTOOLS_VERSION=83.0.0" in dockerfile + assert "ARG GEOINTEL_WHEEL_VERSION=0.47.0" in dockerfile + assert "COPY deploy/unraid/gosu-setpriv /usr/local/bin/gosu" in dockerfile assert "&& pip check" in dockerfile + + +def test_gosu_compatibility_wrapper_uses_exec_and_setpriv() -> None: + wrapper = read("deploy/unraid/gosu-setpriv") + readiness = read("scripts/run_readiness_check.sh") + + assert "exec setpriv" in wrapper + assert '--reuid="$target_user"' in wrapper + assert '--regid="$target_user"' in wrapper + assert "--init-groups" in wrapper + assert "bash -n deploy/unraid/gosu-setpriv" in readiness diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one index d35c0cfe..d9190604 100644 --- a/deploy/unraid/Dockerfile.all-in-one +++ b/deploy/unraid/Dockerfile.all-in-one @@ -13,6 +13,8 @@ ARG GEOINTEL_TORCH_INDEX_URL=https://download.pytorch.org/whl/cpu ARG GEOINTEL_TORCH_VERSION=2.13.0 ARG GEOINTEL_TORCHVISION_VERSION=0.28.0 ARG GEOINTEL_ULTRALYTICS_VERSION=8.4.99 +ARG GEOINTEL_SETUPTOOLS_VERSION=83.0.0 +ARG GEOINTEL_WHEEL_VERSION=0.47.0 ENV GEOINTEL_ENV=production \ GEOINTEL_API_PREFIX=/api/v1 \ @@ -48,6 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app +COPY deploy/unraid/gosu-setpriv /usr/local/bin/gosu COPY backend/pyproject.toml /app/ COPY backend/requirements-runtime.lock /app/ COPY backend/app/__init__.py /app/app/__init__.py @@ -63,6 +66,9 @@ RUN printf '# GeoIntel backend package metadata\n' > /app/README.md \ && pip install --no-cache-dir \ "ultralytics==$GEOINTEL_ULTRALYTICS_VERSION"; \ fi \ + && pip install --no-cache-dir \ + "setuptools==$GEOINTEL_SETUPTOOLS_VERSION" \ + "wheel==$GEOINTEL_WHEEL_VERSION" \ && pip check COPY backend/ /app/ @@ -139,7 +145,7 @@ COPY deploy/unraid/nginx-all-in-one.conf /etc/nginx/conf.d/default.conf COPY deploy/unraid/all-in-one-start.sh /usr/local/bin/geointel-all-in-one-start COPY --from=frontend-build /frontend/dist/ /usr/share/nginx/html/ -RUN chmod +x /usr/local/bin/geointel-all-in-one-start \ +RUN chmod +x /usr/local/bin/geointel-all-in-one-start /usr/local/bin/gosu \ && chmod +x \ /app/scripts/train_operator_yolo_detector.sh \ /app/scripts/verify_real_data_detection_qa_workflow.sh \ diff --git a/deploy/unraid/gosu-setpriv b/deploy/unraid/gosu-setpriv new file mode 100644 index 00000000..6d2fd86e --- /dev/null +++ b/deploy/unraid/gosu-setpriv @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + echo "usage: gosu user command [args...]" >&2 + exit 64 +fi + +target_user="$1" +shift +exec setpriv \ + --reuid="$target_user" \ + --regid="$target_user" \ + --init-groups \ + "$@" diff --git a/docs/CI_SUPPLY_CHAIN.md b/docs/CI_SUPPLY_CHAIN.md index ed64c485..9c34c103 100644 --- a/docs/CI_SUPPLY_CHAIN.md +++ b/docs/CI_SUPPLY_CHAIN.md @@ -73,12 +73,15 @@ The dependency job: - publishes both unfiltered and policy-filtered Python JSON reports plus the npm JSON report, including on failure. -The only current Python exceptions are the Starlette 2026 advisories recorded +The only current Python/container exceptions are the Starlette 2026 advisories recorded in `security/pip-audit-exceptions.json`. FastAPI 0.139.2 still constrains Starlette below 0.53 while patched releases begin at 1.x. GeoIntel applies request-target, form-content, route-class and Linux-runtime compensating controls. The exception file has a mandatory review date; readiness and CI fail automatically after it expires. New advisories are never auto-ignored. +The all-in-one image replaces the Go-based base-image `gosu` helper with a +small `setpriv` exec wrapper and upgrades packaged setuptools/wheel metadata; +these scanner findings are fixed rather than excepted. The container job builds a non-AI all-in-one image and uses digest-pinned scanner images: diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index 7c7bdd36..2f5d0ccd 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -118,6 +118,7 @@ bash -n scripts/deploy_tower.sh bash -n scripts/verify_release_fresh_install.sh bash -n scripts/verify_release_upgrade_smoke.sh bash -n deploy/unraid/all-in-one-start.sh +bash -n deploy/unraid/gosu-setpriv bash -n deploy/unraid/run-dockerman-container.sh bash -n deploy/unraid/deploy-release.sh bash -n deploy/unraid/rollback-dockerman-container.sh diff --git a/scripts/scan_container_image.sh b/scripts/scan_container_image.sh index 39b996c2..1db72d89 100644 --- a/scripts/scan_container_image.sh +++ b/scripts/scan_container_image.sh @@ -6,6 +6,9 @@ TARGET_IMAGE="${1:-geointel-ci:local}" OUTPUT="${2:-artifacts/geointel-container-vulnerabilities.json}" TRIVY_IMAGE="aquasec/trivy:0.70.0@sha256:be1190afcb28352bfddc4ddeb71470835d16462af68d310f9f4bca710961a41e" CACHE_DIR="${GEOINTEL_TRIVY_CACHE:-$ROOT/.cache/trivy}" +PYTHON_CMD="${PYTHON_BIN:-python3}" +IGNORE_FILE="$(mktemp)" +trap 'rm -f "$IGNORE_FILE"' EXIT case "$OUTPUT" in /*|*..*) @@ -16,6 +19,9 @@ esac docker image inspect "$TARGET_IMAGE" >/dev/null mkdir -p "$ROOT/$(dirname "$OUTPUT")" "$CACHE_DIR" +"$PYTHON_CMD" "$ROOT/scripts/verify_security_exceptions.py" +"$PYTHON_CMD" "$ROOT/scripts/verify_security_exceptions.py" \ + --print-container-ids > "$IGNORE_FILE" # Keep the complete report, including vulnerabilities without an available fix. docker run --rm \ @@ -42,6 +48,7 @@ docker run --rm \ --timeout 20m \ --skip-version-check \ --ignore-unfixed \ + --ignorefile "$IGNORE_FILE" \ --severity HIGH,CRITICAL \ --exit-code 1 \ "$TARGET_IMAGE" diff --git a/scripts/verify_security_exceptions.py b/scripts/verify_security_exceptions.py index 37a945b5..898c1293 100644 --- a/scripts/verify_security_exceptions.py +++ b/scripts/verify_security_exceptions.py @@ -40,12 +40,23 @@ def load_and_validate() -> tuple[dict[str, object], list[str]]: if not isinstance(item, dict) or len(str(item.get("reason", ""))) < 30: errors.append("every advisory requires a specific reason") break + aliases = [ + str(alias) + for item in advisories + if isinstance(item, dict) + for alias in item.get("aliases", []) + ] + if len(aliases) != len(set(aliases)) or any( + not alias.startswith("CVE-") for alias in aliases + ): + errors.append("container aliases must be unique CVE identifiers") return payload, errors def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--print-ids", action="store_true") + parser.add_argument("--print-container-ids", action="store_true") args = parser.parse_args() payload, errors = load_and_validate() if errors: @@ -55,6 +66,10 @@ def main() -> int: if args.print_ids: for item in payload["advisories"]: print(item["id"]) + elif args.print_container_ids: + for item in payload["advisories"]: + for alias in item.get("aliases", []): + print(alias) else: print( "Dependency exceptions valid through " diff --git a/security/pip-audit-exceptions.json b/security/pip-audit-exceptions.json index a8c74a8f..291731f8 100644 --- a/security/pip-audit-exceptions.json +++ b/security/pip-audit-exceptions.json @@ -19,6 +19,7 @@ }, { "id": "PYSEC-2026-249", + "aliases": ["CVE-2026-54283"], "reason": "GeoIntel rejects application/x-www-form-urlencoded before Starlette form parsing." }, { @@ -27,6 +28,7 @@ }, { "id": "PYSEC-2026-2281", + "aliases": ["CVE-2026-48818"], "reason": "The supported all-in-one production runtime is Linux; the advisory affects Windows StaticFiles." } ]