Complete RC6 supply chain gates
This commit is contained in:
@@ -1861,3 +1861,18 @@ Runtime controls are `OFFICIAL_VECTOR_ENABLED`, `BWK_WFS_URL`,
|
||||
`OFFICIAL_VECTOR_TIMEOUT_SECONDS`, `OFFICIAL_VECTOR_MAX_RESPONSE_MB`,
|
||||
`OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB` and
|
||||
`OFFICIAL_VECTOR_CACHE_TTL_HOURS`.
|
||||
|
||||
## Locked CI dependencies
|
||||
|
||||
The release image installs the hashed Linux/Python 3.11 base/GIS graph from
|
||||
`requirements-runtime.lock`; CI adds test tools through
|
||||
`requirements-ci.lock`. Both deliberately exclude the optional `ai` extra.
|
||||
Regenerate and validate them from the repository root with:
|
||||
|
||||
```bash
|
||||
bash scripts/generate_python_lock.sh
|
||||
python scripts/verify_python_lock.py
|
||||
```
|
||||
|
||||
The complete gate and vulnerability/SBOM policy are documented in
|
||||
`docs/CI_SUPPLY_CHAIN.md`.
|
||||
|
||||
+28
-2
@@ -22,6 +22,7 @@ from app.services.runtime_reconciliation_service import RuntimeReconciliationSer
|
||||
|
||||
logger = logging.getLogger("geointel")
|
||||
SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
UNSAFE_HOST = re.compile(r"[/\\@\s\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def _to_error_payload(
|
||||
@@ -100,14 +101,39 @@ def create_app() -> FastAPI:
|
||||
request.state.request_id = request_id
|
||||
token = set_request_id(request_id)
|
||||
started_at = time.perf_counter()
|
||||
raw_path = str(request.scope.get("path") or "")
|
||||
try:
|
||||
host = request.headers.get("host", "")
|
||||
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
if not raw_path.startswith("/") or not host or UNSAFE_HOST.search(host):
|
||||
response = JSONResponse(
|
||||
status_code=400,
|
||||
content=_to_error_payload(
|
||||
"INVALID_REQUEST_TARGET",
|
||||
"The request target or Host header is invalid",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
if content_type == "application/x-www-form-urlencoded":
|
||||
response = JSONResponse(
|
||||
status_code=415,
|
||||
content=_to_error_payload(
|
||||
"UNSUPPORTED_CONTENT_TYPE",
|
||||
"URL-encoded form bodies are not supported",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
response = await call_next(request)
|
||||
response.headers["x-request-id"] = request_id
|
||||
logger.info(
|
||||
"request_complete request_id=%s method=%s path=%s status=%s duration_ms=%.1f",
|
||||
request_id,
|
||||
request.method,
|
||||
request.url.path,
|
||||
raw_path,
|
||||
response.status_code,
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
)
|
||||
@@ -165,7 +191,7 @@ def create_app() -> FastAPI:
|
||||
"Unhandled request error request_id=%s method=%s path=%s",
|
||||
request.state.request_id,
|
||||
request.method,
|
||||
request.url.path,
|
||||
str(request.scope.get("path") or ""),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -47,10 +47,12 @@ def test_all_in_one_dockerfile_can_opt_into_ai_dependencies_without_base_install
|
||||
|
||||
assert "ARG GEOINTEL_INSTALL_AI=false" in dockerfile
|
||||
assert "COPY backend/pyproject.toml /app/" in dockerfile
|
||||
assert "COPY backend/requirements-runtime.lock /app/" in dockerfile
|
||||
assert "COPY backend/pyproject.toml backend/README.md /app/" not in dockerfile
|
||||
assert "GeoIntel backend package metadata" in dockerfile
|
||||
assert 'extras=".[gis]"' in dockerfile
|
||||
assert 'extras=".[gis,ai]"' in dockerfile
|
||||
assert "--require-hashes -r requirements-runtime.lock" in dockerfile
|
||||
assert "ARG GEOINTEL_ULTRALYTICS_VERSION=" in dockerfile
|
||||
assert '"ultralytics==$GEOINTEL_ULTRALYTICS_VERSION"' in dockerfile
|
||||
assert "python scripts/gis_import_smoke.py" in dockerfile
|
||||
assert "yolo_preflight.py" in dockerfile
|
||||
assert "libxcb1" in dockerfile
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_python_ci_lock_is_hashed_linux_311_and_excludes_ai() -> None:
|
||||
for lock_path in (
|
||||
"backend/requirements-runtime.lock",
|
||||
"backend/requirements-ci.lock",
|
||||
):
|
||||
lock = read(lock_path)
|
||||
assert "pip-compile with Python 3.11" in lock
|
||||
assert "# geointel-input-sha256: " in lock
|
||||
assert "--generate-hashes" in lock
|
||||
assert "\ntorch==" not in lock
|
||||
assert "\nultralytics==" not in lock
|
||||
|
||||
|
||||
def test_lock_generator_uses_pinned_linux_runtime_and_verifies_policy() -> None:
|
||||
generator = read("scripts/generate_python_lock.sh")
|
||||
|
||||
assert "python:3.11-bookworm@sha256:" in generator
|
||||
assert 'PIP_TOOLS_VERSION="7.5.3"' in generator
|
||||
assert "--extra gis" in generator
|
||||
assert "--extra dev" in generator
|
||||
assert "requirements-runtime.lock" in generator
|
||||
assert "requirements-ci.lock" in generator
|
||||
assert "--generate-hashes" in generator
|
||||
assert "verify_python_lock.py --stamp" in generator
|
||||
|
||||
|
||||
def test_ci_runs_complete_release_and_supply_chain_gates() -> None:
|
||||
for workflow_path, context in (
|
||||
(".github/workflows/release-gates.yml", "github.sha"),
|
||||
(".gitea/workflows/release-gates.yml", "gitea.sha"),
|
||||
):
|
||||
workflow = read(workflow_path)
|
||||
assert "backend/requirements-ci.lock" in workflow
|
||||
assert "scripts/verify_python_lock.py" in workflow
|
||||
assert "scripts/run_readiness_check.sh" in workflow
|
||||
assert "python -m alembic upgrade head --sql" in workflow
|
||||
assert "docker compose config" in workflow
|
||||
assert "pip-audit==2.10.1" in workflow
|
||||
assert "audit_python_dependencies.sh" in workflow
|
||||
assert "npm audit --audit-level=high" in workflow
|
||||
assert "GEOINTEL_INSTALL_AI=false" in workflow
|
||||
assert "generate_container_sbom.sh" in workflow
|
||||
assert "scan_container_image.sh" in workflow
|
||||
assert "actions/upload-artifact@v4" in workflow
|
||||
assert context in workflow
|
||||
|
||||
|
||||
def test_scanner_images_are_versioned_and_digest_pinned() -> None:
|
||||
sbom = read("scripts/generate_container_sbom.sh")
|
||||
scan = read("scripts/scan_container_image.sh")
|
||||
|
||||
assert "anchore/syft:v1.44.0@sha256:" in sbom
|
||||
assert "aquasec/trivy:0.70.0@sha256:" in scan
|
||||
assert "--severity HIGH,CRITICAL" in scan
|
||||
assert "--ignore-unfixed" in scan
|
||||
assert "--timeout 20m" in scan
|
||||
assert "--scanners vuln" in scan
|
||||
assert "geointel-container-vulnerabilities.json" in scan
|
||||
|
||||
|
||||
def test_readiness_guards_lock_and_supply_chain_entrypoints() -> None:
|
||||
readiness = read("scripts/run_readiness_check.sh")
|
||||
|
||||
assert "scripts/verify_python_lock.py" in readiness
|
||||
assert "scripts/verify_security_exceptions.py" in readiness
|
||||
for path in (
|
||||
"scripts/generate_python_lock.sh",
|
||||
"scripts/generate_container_sbom.sh",
|
||||
"scripts/scan_container_image.sh",
|
||||
"scripts/audit_python_dependencies.sh",
|
||||
):
|
||||
assert f"bash -n {path}" in readiness
|
||||
|
||||
|
||||
def test_python_audit_exceptions_are_timeboxed_and_full_evidence_is_kept() -> None:
|
||||
policy = read("security/pip-audit-exceptions.json")
|
||||
audit_script = read("scripts/audit_python_dependencies.sh")
|
||||
|
||||
assert '"review_by": "2026-08-31"' in policy
|
||||
assert "pip-audit-full.json" in audit_script
|
||||
assert "pip-audit-policy.json" in audit_script
|
||||
assert "--ignore-vuln" in audit_script
|
||||
assert "verify_security_exceptions.py" in audit_script
|
||||
|
||||
|
||||
def test_release_image_uses_locked_non_ai_dependencies_and_npm_ci() -> None:
|
||||
dockerfile = read("deploy/unraid/Dockerfile.all-in-one")
|
||||
|
||||
assert "RUN npm ci" in dockerfile
|
||||
assert "COPY backend/requirements-runtime.lock /app/" in dockerfile
|
||||
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 "&& pip check" in dockerfile
|
||||
@@ -0,0 +1,36 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_invalid_host_request_target_is_rejected_canonically() -> None:
|
||||
response = client.get("/health/live", headers={"host": "trusted.example/@admin"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.headers["x-request-id"]
|
||||
assert response.json()["error"] == "INVALID_REQUEST_TARGET"
|
||||
assert response.json()["request_id"] == response.headers["x-request-id"]
|
||||
|
||||
|
||||
def test_urlencoded_form_body_is_rejected_before_starlette_form_parsing() -> None:
|
||||
response = client.post(
|
||||
"/api/v1/datasets/upload",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
content="dataset_type=vector&field=" + ("x" * 10_000),
|
||||
)
|
||||
|
||||
assert response.status_code == 415
|
||||
assert response.json()["error"] == "UNSUPPORTED_CONTENT_TYPE"
|
||||
|
||||
|
||||
def test_multipart_upload_contract_remains_available() -> None:
|
||||
response = client.post(
|
||||
"/health/live",
|
||||
files={"file": ("empty.geojson", b"{}", "application/geo+json")},
|
||||
data={"dataset_type": "vector"},
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
Reference in New Issue
Block a user