mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-25 07:12:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d76b9a9b |
@@ -0,0 +1,118 @@
|
|||||||
|
"""Describe a validated sidecar release without executing image or repository code."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE = "ghcr.io/odysseus-dev/odysseus-searxng"
|
||||||
|
REPOSITORY = "odysseus-dev/odysseus"
|
||||||
|
SOURCE_FILES = (
|
||||||
|
"docker/searxng/Dockerfile",
|
||||||
|
"docker/searxng/repair_trust.py",
|
||||||
|
"docker/searxng/blocked-certificates.json",
|
||||||
|
"docker/searxng/validate_image.py",
|
||||||
|
"tests/fixtures/ca-trust/incident.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def revision(value):
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{40}", value):
|
||||||
|
raise ValueError("invalid source revision")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def digest(value):
|
||||||
|
if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value):
|
||||||
|
raise ValueError("invalid image digest")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def source_snapshot(root, source_revision, repository):
|
||||||
|
revision(source_revision)
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
|
||||||
|
raise ValueError("invalid source repository")
|
||||||
|
dockerfile = (root / SOURCE_FILES[0]).read_text()
|
||||||
|
match = re.search(r"^FROM (\S+) AS base$", dockerfile, re.M)
|
||||||
|
if not match or not re.fullmatch(r"docker\.io/searxng/searxng:[^\s@]+@sha256:[0-9a-f]{64}", match[1]):
|
||||||
|
raise ValueError("SearXNG base must have an immutable upstream digest")
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"repository": repository,
|
||||||
|
"source_revision": source_revision,
|
||||||
|
"upstream_image": match[1],
|
||||||
|
"source_inputs": {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in SOURCE_FILES},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def release_descriptor(amd64, arm64, manifest, source_revision, run_id, tag, arch_digests):
|
||||||
|
revision(source_revision)
|
||||||
|
if amd64 != arm64:
|
||||||
|
raise ValueError("native architecture builds used different source inputs")
|
||||||
|
if amd64.get("repository") != REPOSITORY or amd64.get("source_revision") != source_revision:
|
||||||
|
raise ValueError("release source does not match the canonical workflow revision")
|
||||||
|
if amd64.get("schema_version") != 1 or set(amd64.get("source_inputs", {})) != set(SOURCE_FILES):
|
||||||
|
raise ValueError("incomplete source inventory")
|
||||||
|
if any(not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value) for value in amd64["source_inputs"].values()):
|
||||||
|
raise ValueError("invalid source checksum")
|
||||||
|
if not str(run_id).isdigit() or not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}", tag):
|
||||||
|
raise ValueError("invalid workflow identity or image tag")
|
||||||
|
if manifest.get("mediaType") not in (
|
||||||
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||||
|
):
|
||||||
|
raise ValueError("release is not a multiarch image index")
|
||||||
|
children = manifest.get("manifests", [])
|
||||||
|
platforms = sorted(f"{entry.get('platform', {}).get('os')}/{entry.get('platform', {}).get('architecture')}" for entry in children)
|
||||||
|
if platforms != ["linux/amd64", "linux/arm64"]:
|
||||||
|
raise ValueError("release must contain exactly the two validated Linux architectures")
|
||||||
|
if len({digest(entry.get("digest")) for entry in children}) != 2:
|
||||||
|
raise ValueError("architecture digests must be distinct")
|
||||||
|
expected = {arch: digest(value) for arch, value in arch_digests.items()}
|
||||||
|
actual = {entry["platform"]["architecture"]: entry["digest"] for entry in children}
|
||||||
|
if actual != expected:
|
||||||
|
raise ValueError("index does not reference the exact validated architecture images")
|
||||||
|
return {
|
||||||
|
**amd64,
|
||||||
|
"image": f"{IMAGE}@{digest(manifest.get('digest'))}",
|
||||||
|
"tag": tag,
|
||||||
|
"platforms": platforms,
|
||||||
|
"architecture_digests": actual,
|
||||||
|
"workflow_run": f"https://github.com/{REPOSITORY}/actions/runs/{run_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
inputs = sub.add_parser("inputs")
|
||||||
|
inputs.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
|
||||||
|
inputs.add_argument("--revision", required=True)
|
||||||
|
inputs.add_argument("--repository", required=True)
|
||||||
|
inputs.add_argument("--output", type=Path, required=True)
|
||||||
|
release = sub.add_parser("release")
|
||||||
|
release.add_argument("--amd64", type=Path, required=True)
|
||||||
|
release.add_argument("--arm64", type=Path, required=True)
|
||||||
|
release.add_argument("--manifest", type=Path, required=True)
|
||||||
|
release.add_argument("--amd64-digest", required=True)
|
||||||
|
release.add_argument("--arm64-digest", required=True)
|
||||||
|
release.add_argument("--revision", required=True)
|
||||||
|
release.add_argument("--run-id", required=True)
|
||||||
|
release.add_argument("--tag", required=True)
|
||||||
|
release.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.command == "inputs":
|
||||||
|
value = source_snapshot(args.root, args.revision, args.repository)
|
||||||
|
else:
|
||||||
|
value = release_descriptor(
|
||||||
|
json.loads(args.amd64.read_text()), json.loads(args.arm64.read_text()),
|
||||||
|
json.loads(args.manifest.read_text()), args.revision, args.run_id, args.tag,
|
||||||
|
{"amd64": args.amd64_digest, "arm64": args.arm64_digest},
|
||||||
|
)
|
||||||
|
args.output.write_text(json.dumps(value, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -34,7 +34,8 @@ searxng_cleanup() {
|
|||||||
}
|
}
|
||||||
trap searxng_cleanup EXIT
|
trap searxng_cleanup EXIT
|
||||||
|
|
||||||
install -m 0644 "$searxng_repo/scripts/check_ca_trust.py" \
|
install -m 0644 "$searxng_repo/docker/searxng/repair_trust.py" \
|
||||||
|
"$searxng_repo/docker/searxng/blocked-certificates.json" \
|
||||||
"$searxng_repo/docker/searxng/validate_image.py" "$searxng_checks/"
|
"$searxng_repo/docker/searxng/validate_image.py" "$searxng_checks/"
|
||||||
install -m 0644 "$searxng_repo/tests/fixtures/ca-trust/incident.json" "$searxng_checks/incident.json"
|
install -m 0644 "$searxng_repo/tests/fixtures/ca-trust/incident.json" "$searxng_checks/incident.json"
|
||||||
install -m 0644 "$searxng_repo/config/searxng/settings.yml" "$searxng_checks/settings.yml"
|
install -m 0644 "$searxng_repo/config/searxng/settings.yml" "$searxng_checks/settings.yml"
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
# Byte-compile sources — catches syntax errors without installing deps.
|
# Byte-compile sources — catches syntax errors without installing deps.
|
||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: "22.23.4"
|
node-version: "22.23.4"
|
||||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
@@ -151,7 +151,7 @@ jobs:
|
|||||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
@@ -168,7 +168,7 @@ jobs:
|
|||||||
|
|
||||||
# uv resolves the same versions as pip but installs ~5x faster, and its
|
# uv resolves the same versions as pip but installs ~5x faster, and its
|
||||||
# cache keeps each shard from re-downloading the whole requirement set.
|
# cache keeps each shard from re-downloading the whole requirement set.
|
||||||
- uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1
|
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
version: "0.13.5"
|
version: "0.13.5"
|
||||||
@@ -180,7 +180,7 @@ jobs:
|
|||||||
python scripts/check_ca_trust.py --python
|
python scripts/check_ca_trust.py --python
|
||||||
uv pip install --system -r requirements.txt
|
uv pip install --system -r requirements.txt
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
node-version: "22.23.4"
|
node-version: "22.23.4"
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: none
|
build-mode: none
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||||
with:
|
with:
|
||||||
category: "/language:${{ matrix.language }}"
|
category: "/language:${{ matrix.language }}"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Lint Dockerfile
|
- name: Lint Dockerfile
|
||||||
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
||||||
with:
|
with:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# DL3008: pinning apt package versions is impractical on a -slim base
|
# DL3008: pinning apt package versions is impractical on a -slim base
|
||||||
|
|||||||
@@ -61,14 +61,14 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
with:
|
with:
|
||||||
driver: docker
|
driver: docker
|
||||||
|
|
||||||
# Build without pushing so a broken Dockerfile is caught here, and the
|
# Build without pushing so a broken Dockerfile is caught here, and the
|
||||||
# exact image we ship is what gets scanned.
|
# exact image we ship is what gets scanned.
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
@@ -107,12 +107,12 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
with:
|
with:
|
||||||
driver: docker
|
driver: docker
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
@@ -133,7 +133,7 @@ jobs:
|
|||||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||||
|
|
||||||
- name: Upload Trivy results
|
- name: Upload Trivy results
|
||||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||||
with:
|
with:
|
||||||
sarif_file: trivy-results.sarif
|
sarif_file: trivy-results.sarif
|
||||||
category: trivy-image
|
category: trivy-image
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
pull: true
|
pull: true
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
digest="${{ steps.build.outputs.digest }}"
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: digest-${{ matrix.arch }}
|
name: digest-${{ matrix.arch }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
@@ -107,13 +107,13 @@ jobs:
|
|||||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digest-*
|
pattern: digest-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
name: ci / SearXNG trust
|
name: ci / SearXNG image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'docker/searxng/**'
|
- 'docker/searxng/**'
|
||||||
- 'scripts/check_ca_trust.py'
|
|
||||||
- 'tests/fixtures/ca-trust/**'
|
- 'tests/fixtures/ca-trust/**'
|
||||||
- 'tests/test_searxng*'
|
- 'tests/test_searxng*'
|
||||||
- 'docker-compose*.yml'
|
- 'docker-compose*.yml'
|
||||||
@@ -14,10 +13,9 @@ on:
|
|||||||
- '.github/searxng-validation.compose.yml'
|
- '.github/searxng-validation.compose.yml'
|
||||||
- '.github/workflows/searxng-image.yml'
|
- '.github/workflows/searxng-image.yml'
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
branches: [dev, main, release/searxng-ca-trust]
|
||||||
paths:
|
paths:
|
||||||
- 'docker/searxng/**'
|
- 'docker/searxng/**'
|
||||||
- 'scripts/check_ca_trust.py'
|
|
||||||
- 'tests/fixtures/ca-trust/**'
|
- 'tests/fixtures/ca-trust/**'
|
||||||
- 'docker-compose*.yml'
|
- 'docker-compose*.yml'
|
||||||
- 'config/searxng/**'
|
- 'config/searxng/**'
|
||||||
@@ -26,6 +24,11 @@ on:
|
|||||||
- '.github/searxng-validation.compose.yml'
|
- '.github/searxng-validation.compose.yml'
|
||||||
- '.github/workflows/searxng-image.yml'
|
- '.github/workflows/searxng-image.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
publish:
|
||||||
|
description: Publish after native validation (canonical trusted branches only)
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: searxng-image-${{ github.event.pull_request.number || github.ref }}
|
group: searxng-image-${{ github.event.pull_request.number || github.ref }}
|
||||||
@@ -53,27 +56,188 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Select the immutable upstream image from Compose
|
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
id: image
|
- name: Reproduce affected base
|
||||||
run: |
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
docker compose --env-file /dev/null -f docker-compose.yml config --format json |
|
with:
|
||||||
python3 -c 'import json, re, sys; image = json.load(sys.stdin)["services"]["searxng"]["image"]; re.fullmatch(r"docker\.io/searxng/searxng:[0-9]{4}\.[^@\s]+@sha256:[0-9a-f]{64}", image) or sys.exit("Invalid SearXNG pin"); print("image=" + image)' >> "$GITHUB_OUTPUT"
|
context: .
|
||||||
- name: Pull the upstream image without registry credentials
|
file: docker/searxng/Dockerfile
|
||||||
env:
|
target: baseline-validation
|
||||||
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
|
network: none
|
||||||
run: docker pull "$SEARXNG_TEST_IMAGE"
|
platforms: ${{ matrix.platform }}
|
||||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
no-cache-filters: baseline-validation
|
||||||
- name: Check unmodified upstream trust and actual entrypoint offline
|
cache-from: type=gha,scope=searxng-${{ matrix.arch }}
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
- name: Validate repaired stores and upstream startup
|
||||||
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/searxng/Dockerfile
|
file: docker/searxng/Dockerfile
|
||||||
target: validation
|
target: validation
|
||||||
build-args: SEARXNG_IMAGE=${{ steps.image.outputs.image }}
|
|
||||||
network: none
|
network: none
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
no-cache-filters: validation
|
no-cache-filters: validation
|
||||||
- name: Validate the selected upstream runtime through Compose and live HTTPS
|
cache-from: type=gha,scope=searxng-${{ matrix.arch }}
|
||||||
|
- name: Build runtime without registry credentials
|
||||||
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/searxng/Dockerfile
|
||||||
|
target: runtime
|
||||||
|
network: none
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
load: true
|
||||||
|
tags: localhost/odysseus-searxng:ci-${{ matrix.arch }}
|
||||||
|
provenance: false
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
cache-from: type=gha,scope=searxng-${{ matrix.arch }}
|
||||||
|
cache-to: type=gha,mode=max,scope=searxng-${{ matrix.arch }}
|
||||||
|
- name: Validate actual runtime through Compose and live HTTPS
|
||||||
env:
|
env:
|
||||||
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
|
SEARXNG_TEST_IMAGE: localhost/odysseus-searxng:ci-${{ matrix.arch }}
|
||||||
run: bash .github/scripts/validate-searxng-image.sh "$SEARXNG_TEST_IMAGE"
|
run: bash .github/scripts/validate-searxng-image.sh "$SEARXNG_TEST_IMAGE"
|
||||||
|
- name: Export validated runtime and input checksums
|
||||||
|
env:
|
||||||
|
ARCH: ${{ matrix.arch }}
|
||||||
|
run: |
|
||||||
|
docker save --output "$RUNNER_TEMP/runtime-${ARCH}.tar" "localhost/odysseus-searxng:ci-${ARCH}"
|
||||||
|
python3 .github/scripts/searxng_release.py inputs --revision "$GITHUB_SHA" \
|
||||||
|
--repository "$GITHUB_REPOSITORY" --output "$RUNNER_TEMP/inputs-${ARCH}.json"
|
||||||
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: searxng-runtime-${{ matrix.arch }}
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/runtime-${{ matrix.arch }}.tar
|
||||||
|
${{ runner.temp }}/inputs-${{ matrix.arch }}.json
|
||||||
|
compression-level: 0
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish validated SearXNG architectures and index
|
||||||
|
needs: validate
|
||||||
|
if: >-
|
||||||
|
github.repository == 'odysseus-dev/odysseus' &&
|
||||||
|
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish)) &&
|
||||||
|
contains(fromJSON('["refs/heads/dev","refs/heads/main","refs/heads/release/searxng-ca-trust"]'), github.ref)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
outputs:
|
||||||
|
image: ${{ steps.release.outputs.image }}
|
||||||
|
env:
|
||||||
|
SEARXNG_IMAGE: ghcr.io/odysseus-dev/odysseus-searxng
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/searxng-artifacts
|
||||||
|
pattern: searxng-runtime-*
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Load and inspect the validated artifacts before registry login
|
||||||
|
run: |
|
||||||
|
for arch in amd64 arm64; do
|
||||||
|
docker load --input "$RUNNER_TEMP/searxng-artifacts/runtime-${arch}.tar"
|
||||||
|
image="localhost/odysseus-searxng:ci-${arch}"
|
||||||
|
test "$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = "linux/${arch}"
|
||||||
|
test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$GITHUB_SHA"
|
||||||
|
done
|
||||||
|
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Publish the exact validated artifacts
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
tag="sha-${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
refs=()
|
||||||
|
for arch in amd64 arm64; do
|
||||||
|
remote="${SEARXNG_IMAGE}:${tag}-${arch}"
|
||||||
|
docker tag "localhost/odysseus-searxng:ci-${arch}" "$remote"
|
||||||
|
docker push "$remote"
|
||||||
|
digests=$(docker image inspect --format '{{json .RepoDigests}}' "$remote")
|
||||||
|
ref=$(jq -er --arg prefix "${SEARXNG_IMAGE}@" '.[] | select(startswith($prefix))' <<< "$digests")
|
||||||
|
[[ "$ref" =~ ^ghcr.io/odysseus-dev/odysseus-searxng@sha256:[0-9a-f]{64}$ ]]
|
||||||
|
refs+=("$ref")
|
||||||
|
done
|
||||||
|
docker buildx imagetools create --tag "${SEARXNG_IMAGE}:${tag}" "${refs[@]}"
|
||||||
|
docker buildx imagetools inspect "${SEARXNG_IMAGE}:${tag}" --format '{{json .Manifest}}' > "$RUNNER_TEMP/searxng-manifest.json"
|
||||||
|
python3 .github/scripts/searxng_release.py release \
|
||||||
|
--amd64 "$RUNNER_TEMP/searxng-artifacts/inputs-amd64.json" \
|
||||||
|
--arm64 "$RUNNER_TEMP/searxng-artifacts/inputs-arm64.json" \
|
||||||
|
--manifest "$RUNNER_TEMP/searxng-manifest.json" --revision "$GITHUB_SHA" \
|
||||||
|
--amd64-digest "${refs[0]#*@}" --arm64-digest "${refs[1]#*@}" \
|
||||||
|
--run-id "$GITHUB_RUN_ID" --tag "$tag" --output "$RUNNER_TEMP/searxng-release.json"
|
||||||
|
image=$(jq -er .image "$RUNNER_TEMP/searxng-release.json")
|
||||||
|
echo "image=$image" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: searxng-release-candidate
|
||||||
|
path: ${{ runner.temp }}/searxng-release.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
verify-published:
|
||||||
|
name: Verify public SearXNG pull (${{ matrix.arch }})
|
||||||
|
needs: publish
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Pull the published index without registry credentials
|
||||||
|
env:
|
||||||
|
SEARXNG_TEST_IMAGE: ${{ needs.publish.outputs.image }}
|
||||||
|
run: |
|
||||||
|
if ! docker pull "$SEARXNG_TEST_IMAGE"; then
|
||||||
|
echo '::error::Anonymous GHCR pull failed. A newly created package defaults to private: make odysseus-searxng public, then rerun the failed verification jobs.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- name: Validate the pulled runtime through Compose
|
||||||
|
env:
|
||||||
|
SEARXNG_TEST_IMAGE: ${{ needs.publish.outputs.image }}
|
||||||
|
run: bash .github/scripts/validate-searxng-image.sh "$SEARXNG_TEST_IMAGE"
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Record verified Compose image reference
|
||||||
|
needs: [publish, verify-published]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: searxng-release-candidate
|
||||||
|
path: ${{ runner.temp }}/searxng-release
|
||||||
|
- name: Record the verified image
|
||||||
|
env:
|
||||||
|
SEARXNG_VERIFIED_IMAGE: ${{ needs.publish.outputs.image }}
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo '## Verified SearXNG image'
|
||||||
|
echo
|
||||||
|
echo "\`$SEARXNG_VERIFIED_IMAGE\`"
|
||||||
|
echo
|
||||||
|
echo 'Both native architectures passed Compose startup, fourteen incident-chain/HTTPS checks and live search before and after publication.'
|
||||||
|
echo 'Pin this reference in the base and standalone GPU Compose files and commit the release descriptor.'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: searxng-release
|
||||||
|
path: ${{ runner.temp }}/searxng-release/searxng-release.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ services:
|
|||||||
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
||||||
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
||||||
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
||||||
# Update all default/GPU pins together after native startup and trust checks.
|
# Bump this deliberately after verifying a newer tag boots clean.
|
||||||
image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8
|
image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
|
||||||
entrypoint:
|
entrypoint:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ services:
|
|||||||
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
||||||
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
||||||
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
||||||
# Update all default/GPU pins together after native startup and trust checks.
|
# Bump this deliberately after verifying a newer tag boots clean.
|
||||||
image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8
|
image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
|
||||||
entrypoint:
|
entrypoint:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
|
|||||||
+2
-2
@@ -126,8 +126,8 @@ services:
|
|||||||
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
||||||
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
||||||
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
||||||
# Update all default/GPU pins together after native startup and trust checks.
|
# Bump this deliberately after verifying a newer tag boots clean.
|
||||||
image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8
|
image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
|
||||||
entrypoint:
|
entrypoint:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
# Validation only. Deploy the unmodified upstream image selected by Compose.
|
# Keep the application version unchanged while correcting its independent trust.
|
||||||
ARG SEARXNG_IMAGE
|
FROM docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 AS base
|
||||||
FROM ${SEARXNG_IMAGE} AS validation
|
|
||||||
COPY scripts/check_ca_trust.py docker/searxng/validate_image.py /opt/odysseus-trust/
|
FROM base AS baseline-validation
|
||||||
|
COPY docker/searxng/repair_trust.py docker/searxng/blocked-certificates.json docker/searxng/validate_image.py /opt/odysseus-trust/
|
||||||
COPY tests/fixtures/ca-trust/incident.json /opt/odysseus-trust/incident.json
|
COPY tests/fixtures/ca-trust/incident.json /opt/odysseus-trust/incident.json
|
||||||
|
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --baseline
|
||||||
|
|
||||||
|
FROM base AS repaired
|
||||||
|
COPY docker/searxng/repair_trust.py docker/searxng/blocked-certificates.json /opt/odysseus-trust/
|
||||||
|
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/repair_trust.py
|
||||||
|
|
||||||
|
# Optional offline validation runs inside the actual SearXNG filesystem.
|
||||||
|
FROM repaired AS validation
|
||||||
|
COPY tests/fixtures/ca-trust/incident.json /opt/odysseus-trust/incident.json
|
||||||
|
COPY docker/searxng/validate_image.py /opt/odysseus-trust/validate_image.py
|
||||||
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py
|
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py
|
||||||
|
|
||||||
FROM validation AS live-validation
|
FROM validation AS live-validation
|
||||||
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --live
|
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --live
|
||||||
|
|
||||||
|
FROM repaired AS runtime
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
**
|
**
|
||||||
!scripts/
|
|
||||||
!scripts/check_ca_trust.py
|
|
||||||
!docker/
|
!docker/
|
||||||
!docker/searxng/
|
!docker/searxng/
|
||||||
!docker/searxng/**
|
!docker/searxng/**
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3",
|
||||||
|
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
|
||||||
|
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
|
||||||
|
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
|
||||||
|
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (cross-signed)",
|
||||||
|
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
|
||||||
|
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
|
||||||
|
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
|
||||||
|
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
|
||||||
|
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
|
||||||
|
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
|
||||||
|
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
|
||||||
|
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
|
||||||
|
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
|
||||||
|
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
|
||||||
|
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
|
||||||
|
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
|
||||||
|
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
|
||||||
|
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
|
||||||
|
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
|
||||||
|
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Opt in to the locally built trust-corrected image without publishing a tag.
|
||||||
|
# Run from the repository root with the base Compose file first.
|
||||||
|
services:
|
||||||
|
searxng:
|
||||||
|
image: localhost/odysseus-searxng:2026.9.25-ca-trust
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/searxng/Dockerfile
|
||||||
|
target: runtime
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Remove known incident certificates without downloading packages or roots.
|
||||||
|
|
||||||
|
Inventory provenance: https://emudhra.we-have-a-problem.com/roots.json and
|
||||||
|
https://emudhra.we-have-a-problem.com/impacted-intermediates.json; the additional
|
||||||
|
cross-signed issuer is from the incident's public chains.zip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
BLOCKED = json.loads(Path(__file__).with_name("blocked-certificates.json").read_text())
|
||||||
|
PEM = re.compile(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def certificates(text):
|
||||||
|
blocks = PEM.findall(text)
|
||||||
|
if not blocks or "-----BEGIN" in PEM.sub("", text):
|
||||||
|
raise ValueError("empty or malformed certificate input")
|
||||||
|
for block in blocks:
|
||||||
|
# Validate the X.509 object as well as its base64 encoding.
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
context.load_verify_locations(cadata=block)
|
||||||
|
yield block, hashlib.sha256(ssl.PEM_cert_to_DER_cert(block)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def filter_bundle(path, *, allow_empty=False):
|
||||||
|
original = list(certificates(path.read_text()))
|
||||||
|
retained = [block for block, digest in original if digest not in BLOCKED]
|
||||||
|
removed = len(original) - len(retained)
|
||||||
|
if not retained and not allow_empty:
|
||||||
|
raise ValueError(f"refusing to empty trust bundle: {path}")
|
||||||
|
if removed:
|
||||||
|
if retained:
|
||||||
|
path.write_text("\n".join(retained) + "\n")
|
||||||
|
else:
|
||||||
|
path.unlink()
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def check_bundle(path):
|
||||||
|
blocks = list(certificates(path.read_text()))
|
||||||
|
if any(digest in BLOCKED for _, digest in blocks):
|
||||||
|
raise ValueError(f"incident certificate remains: {path}")
|
||||||
|
return len(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import certifi
|
||||||
|
|
||||||
|
sources = list(Path("/usr/share/ca-certificates").rglob("*.crt"))
|
||||||
|
sources += list(Path("/usr/local/share/ca-certificates").rglob("*.crt"))
|
||||||
|
if not sources:
|
||||||
|
raise ValueError("no system CA sources found")
|
||||||
|
removed = sum(filter_bundle(path, allow_empty=True) for path in sources)
|
||||||
|
removed += filter_bundle(Path(certifi.where()))
|
||||||
|
# The upstream entrypoint also regenerates trust at root startup. Removing
|
||||||
|
# source certificates ensures that it cannot restore the incident roots.
|
||||||
|
subprocess.run(["update-ca-certificates", "--fresh"], check=True)
|
||||||
|
paths = [Path("/etc/ssl/certs/ca-certificates.crt"), Path(certifi.where())]
|
||||||
|
paths += [p for p in Path("/etc/ssl/certs").iterdir()
|
||||||
|
if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", p.name)]
|
||||||
|
if len(paths) <= 2:
|
||||||
|
raise ValueError("no hashed system CA entries generated")
|
||||||
|
for path in paths:
|
||||||
|
check_bundle(path)
|
||||||
|
print(f"Removed {removed} incident certificates; checked {len(paths)} trust files")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Verify incident distrust and startup in the unmodified upstream image."""
|
"""Offline incident-chain checks and loopback boot smoke for the derived image."""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -16,18 +16,19 @@ import urllib.request
|
|||||||
|
|
||||||
import certifi
|
import certifi
|
||||||
|
|
||||||
from check_ca_trust import check_bundle
|
from repair_trust import check_bundle
|
||||||
|
|
||||||
|
|
||||||
def check_chains():
|
def check_chains(baseline=False):
|
||||||
incident = json.loads(Path(__file__).with_name("incident.json").read_text())
|
incident = json.loads(Path(__file__).with_name("incident.json").read_text())
|
||||||
system = Path(os.environ["SSL_CERT_FILE"])
|
system = Path(os.environ["SSL_CERT_FILE"])
|
||||||
capath = os.environ["SSL_CERT_DIR"]
|
capath = os.environ["SSL_CERT_DIR"]
|
||||||
stores = [["-CAfile", str(system), "-no-CApath"],
|
stores = [["-CAfile", str(system), "-no-CApath"],
|
||||||
["-CAfile", certifi.where(), "-no-CApath"],
|
["-CAfile", certifi.where(), "-no-CApath"],
|
||||||
["-no-CAfile", "-CApath", capath]]
|
["-no-CAfile", "-CApath", capath]]
|
||||||
check_bundle(system)
|
if not baseline:
|
||||||
check_bundle(Path(certifi.where()))
|
check_bundle(system)
|
||||||
|
check_bundle(Path(certifi.where()))
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
directory = Path(directory)
|
directory = Path(directory)
|
||||||
for fingerprint, pem in incident["certificates"].items():
|
for fingerprint, pem in incident["certificates"].items():
|
||||||
@@ -42,7 +43,8 @@ def check_chains():
|
|||||||
base = ["openssl", "verify", "-no-CAstore", "-attime", str(incident["verification_time"]),
|
base = ["openssl", "verify", "-no-CAstore", "-attime", str(incident["verification_time"]),
|
||||||
"-purpose", "sslserver", "-verify_hostname", hostname,
|
"-purpose", "sslserver", "-verify_hostname", hostname,
|
||||||
"-x509_strict", "-partial_chain", "-trusted_first", "-untrusted", str(untrusted)]
|
"-x509_strict", "-partial_chain", "-trusted_first", "-untrusted", str(untrusted)]
|
||||||
expectations = [(selection, False) for selection in stores]
|
expectations = [(s, baseline and (index != 1 or "-aaa-" not in hostname))
|
||||||
|
for index, s in enumerate(stores)]
|
||||||
for selection, expected in expectations + [
|
for selection, expected in expectations + [
|
||||||
(["-CAfile", str(positive), "-no-CApath"], True)]:
|
(["-CAfile", str(positive), "-no-CApath"], True)]:
|
||||||
result = subprocess.run(base + selection + [str(directory / chain[0])],
|
result = subprocess.run(base + selection + [str(directory / chain[0])],
|
||||||
@@ -53,7 +55,12 @@ def check_chains():
|
|||||||
# certificate errors, rather than tool or fixture failures.
|
# certificate errors, rather than tool or fixture failures.
|
||||||
if not expected and not re.search(r"error (?:2|18|19|20|21|27) at \d+ depth lookup", result.stderr):
|
if not expected and not re.search(r"error (?:2|18|19|20|21|27) at \d+ depth lookup", result.stderr):
|
||||||
raise RuntimeError(f"not a certificate trust rejection: {hostname}: {result.stderr}")
|
raise RuntimeError(f"not a certificate trust rejection: {hostname}: {result.stderr}")
|
||||||
print(f"{len(incident['chains'])}/{len(incident['chains'])} incident chains rejected by system PEM, hashed directory and certifi; affected controls accepted")
|
if baseline:
|
||||||
|
total = len(incident["chains"])
|
||||||
|
certifi_accepted = sum("-aaa-" not in host for host in incident["chains"])
|
||||||
|
print(f"Affected base reproduced: system PEM and directory accept {total}/{total}; certifi accepts {certifi_accepted}/{total}")
|
||||||
|
else:
|
||||||
|
print(f"{len(incident['chains'])}/{len(incident['chains'])} incident chains rejected by system PEM, hashed directory and certifi; affected controls accepted")
|
||||||
|
|
||||||
|
|
||||||
def check_boot(live=False):
|
def check_boot(live=False):
|
||||||
@@ -154,9 +161,13 @@ async def check_https():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
baseline = "--baseline" in sys.argv
|
||||||
live = "--live" in sys.argv
|
live = "--live" in sys.argv
|
||||||
if "--running" in sys.argv:
|
if "--running" in sys.argv:
|
||||||
|
if baseline:
|
||||||
|
raise ValueError("--running cannot validate an affected baseline")
|
||||||
check_running(live=live)
|
check_running(live=live)
|
||||||
else:
|
else:
|
||||||
check_chains()
|
check_chains(baseline=baseline)
|
||||||
check_boot(live=live)
|
if not baseline:
|
||||||
|
check_boot(live=live)
|
||||||
|
|||||||
+32
-21
@@ -1,39 +1,50 @@
|
|||||||
# Upstream SearXNG trust validation
|
# SearXNG image trust correction
|
||||||
|
|
||||||
SearXNG owns independent system and Python certificate stores. Updating Odysseus's Python environment does not update this sidecar. The base and both standalone GPU Compose files now select the official `docker.io/searxng/searxng:2026.10.24-ef0ec03da` image at immutable multiarch digest `sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8`. Its system and certifi stores remove the affected eMudhra trust. The [upstream certifi update](https://github.com/searxng/searxng/pull/6881) is included in this release.
|
SearXNG has independent system and Python certificate stores. Updating Odysseus's Python environment does not update this sidecar. The pinned SearXNG image includes certificate authorities listed in the public eMudhra incident inventory. `docker/searxng/Dockerfile` derives a corrected image from the same immutable application base, without downloading dependencies or changing the SearXNG version.
|
||||||
|
|
||||||
Deploy the upstream image directly. The validation Dockerfile adds public fixtures and diagnostics only; no repair layer, custom runtime image or project registry publisher is required. `scripts/check_ca_trust.py` owns the known-certificate inventory for both the application and SearXNG checks.
|
The build removes the 21 known incident CA certificates by SHA-256 fingerprint from system CA source files and certifi, regenerates the system PEM bundle and hashed CA directory, and checks the generated stores. Only certificates present in the base are removed: the inspected amd64 image loses two system roots and one certifi root. Unaffected certificates remain. The image's existing certifi package version metadata is unchanged; this is an image-owned trust-content correction rather than a package upgrade.
|
||||||
|
|
||||||
## Validate a pin update
|
The runtime is Void Linux with its package manager removed. Do not substitute Alpine, Debian or XBPS package-install commands. Removing affected source certificates is necessary because the upstream root entrypoint runs `update-ca-certificates` again at startup.
|
||||||
|
|
||||||
Run from the repository root on a disposable Docker host. The helper uses a fresh configuration volume, omits published ports and does not load the operator's `.env`. It retains the base Compose settings wrapper, healthcheck and capabilities. Outbound network access and working search engines are required for the live checks.
|
## Build and validate
|
||||||
|
|
||||||
|
Run these commands from the repository root on a Docker host. The build context allowlist excludes credentials and unrelated application files. The repair requires no build-step network; pulling the pinned base still needs registry access.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
image=$(docker compose --env-file /dev/null -f docker-compose.yml config --format json |
|
# Reproduce the affected upstream behavior with offline public chain fixtures.
|
||||||
python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["searxng"]["image"])')
|
docker build --network=none --target baseline-validation -f docker/searxng/Dockerfile .
|
||||||
|
|
||||||
# Offline build-step checks, including actual entrypoint and loopback health.
|
# Check corrected trust and actual upstream entrypoint/loopback health.
|
||||||
# Pulling the selected base still requires registry access.
|
docker build --network=none --target validation -f docker/searxng/Dockerfile .
|
||||||
docker build --network=none --target validation \
|
|
||||||
--build-arg "SEARXNG_IMAGE=$image" -f docker/searxng/Dockerfile .
|
|
||||||
|
|
||||||
# Exercise the exact unmodified deployment image after an anonymous pull.
|
# Also exercise verified HTTPS through SearXNG's own curl client and a real search.
|
||||||
docker pull "$image"
|
# Requires outbound networking and working upstream engines.
|
||||||
bash .github/scripts/validate-searxng-image.sh "$image"
|
docker build --target live-validation -f docker/searxng/Dockerfile .
|
||||||
|
|
||||||
|
# Build the runtime image and select it through the opt-in Compose overlay.
|
||||||
|
docker compose -f docker-compose.yml -f docker/searxng/compose.yml build searxng
|
||||||
|
docker compose -f docker-compose.yml -f docker/searxng/compose.yml up -d --no-deps searxng
|
||||||
|
docker compose -f docker-compose.yml -f docker/searxng/compose.yml ps searxng
|
||||||
```
|
```
|
||||||
|
|
||||||
The offline checks require all fourteen incident chains to fail certificate verification through the system PEM bundle, hashed CA directory and certifi. The affected positive-control roots must accept those same chains. Each fixture's DER fingerprint is verified first, and a fixed verification timestamp prevents expiry from hiding a broken mitigation. Checks run again after the real upstream root entrypoint regenerates trust.
|
The overlay retains the base service's settings migration, persistent configuration, healthcheck, ports and capability limits. All Compose commands managing this local image must include the overlay; using only the base file selects its original image. Validation fixtures and validation scripts are excluded from the runtime stage.
|
||||||
|
|
||||||
The live checks use SearXNG's actual curl client factory. Ordinary verified HTTPS must succeed, all fourteen incident hosts must fail with curl certificate error 60, and a JSON search must return results. DNS failures, timeouts and generic handshake failures are inconclusive and fail validation. The checks inspect the already-running service without changing its trust stores.
|
The offline checks use fourteen public incident chains, including two without Certificate Transparency entries, with a frozen verification time, hostname verification and affected positive controls. They independently exercise the system PEM, system hashed directory and certifi bundle. The unpatched baseline must accept 14/14, 14/14 and 7/14 respectively; the corrected image must reject all fourteen in every store. The startup check rechecks system trust after the upstream entrypoint regenerates it. Live validation requires an ordinary verified HTTPS request, certificate-verification failures for all fourteen incident hosts through SearXNG's own curl client after startup, and nonempty SearXNG JSON search results; network and engine availability can cause that optional target to fail.
|
||||||
|
|
||||||
The `ci / SearXNG trust` workflow runs these offline and Compose/live checks on native amd64 and arm64 runners. It selects the same immutable image as base Compose, pulls without registry credentials, and has read-only repository permissions. It neither publishes a replacement image nor requires package-write credentials. Update all three deployment pins together; the alignment regression guards the standalone GPU files.
|
## Scope and rollout
|
||||||
|
|
||||||
## Rollout and scope
|
The `ci / SearXNG image` workflow builds on native amd64 and arm64 runners. Each architecture reproduces the affected upstream stores, checks the repaired image with build-step networking disabled, then starts the actual runtime through the base Compose service's settings wrapper, capabilities and healthcheck. The CI override removes published ports and uses a fresh temporary configuration volume; it does not replace the service's startup path. Live checks run against that existing service without repairing its trust stores. Pull requests perform these checks without registry credentials or publication.
|
||||||
|
|
||||||
Changing a Compose pin does not replace running containers. After updating the checkout, run `docker compose pull searxng` and `docker compose up -d --no-deps searxng`, then inspect health, retained settings and actual Odysseus search integration. Use the appropriate standalone GPU Compose file when that is the deployment being managed.
|
On the canonical repository's `dev`, `main` or initial `release/searxng-ca-trust` branch, successful native checks allow a separate job to publish the exact exported images to `ghcr.io/odysseus-dev/odysseus-searxng`. Explicit workflow runs on those branches can also request publication. The publisher verifies source revisions, source-input checksums and architecture digests before recording the immutable multiarch reference. Tags identify the commit and workflow attempt; no moving `latest` or `dev` tag is changed. Fresh native jobs then pull anonymously and repeat Compose/live validation. Only after both pass does the workflow produce the `searxng-release` artifact with `searxng-release.json` for the coordinated Compose pin update.
|
||||||
|
|
||||||
Operator-mounted trust, custom engines, persisted packages and independently managed clients need separate effective-client checks. An amd64 result does not establish arm64 behavior. The fixture-based check detects known certificate fingerprints; it does not model every native revocation mechanism or arbitrary reissued certificates sharing a compromised key. See [outbound TLS trust](tls-trust.md) for the application, bootstrap, Chromium and other runtime boundaries.
|
For the first publication, a repository administrator must make the new GHCR package public before anonymous verification can succeed. Rerun the failed verification jobs after changing package visibility. The initial release branch permits this bootstrap before the workflow reaches the default branch; it does not merge or deploy the application. Future publication can use the normal trusted-branch push or explicit workflow trigger.
|
||||||
|
|
||||||
|
Validate each target architecture on a native builder or an explicitly configured emulation builder. An amd64 success does not establish arm64 support. This patch preserves the upstream default identity and entrypoint; it does not alter host trust, existing running containers or operator-mounted/custom trust stores. Operators must inspect those overrides separately. Removing AAA Certificate Services can also affect unrelated legacy certificate chains anchored solely there; test configured engines and any private integrations before rollout.
|
||||||
|
|
||||||
|
After architecture validation and independent review, publish an approved multi-architecture runtime image, record its immutable digest, and update the base and standalone GPU Compose pins together. The opt-in overlay permits local validation before a published reference exists. Recreate affected services after selecting the replacement. Keep the broader incident remediation tracker open for deployment and other runtime work.
|
||||||
|
|
||||||
## Public fixture provenance
|
## Public fixture provenance
|
||||||
|
|
||||||
The public CA inventory comes from the incident's [roots](https://emudhra.we-have-a-problem.com/roots.json) and [intermediates](https://emudhra.we-have-a-problem.com/impacted-intermediates.json). The additional cross-signed issuer and fourteen chain fixtures come from [chains.zip](https://emudhra.we-have-a-problem.com/chains.zip), SHA-256 `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`. Fixtures contain public certificates only. See [the fixture notes](../tests/fixtures/ca-trust/README.md).
|
Fingerprint inventory: <https://emudhra.we-have-a-problem.com/roots.json> and <https://emudhra.we-have-a-problem.com/impacted-intermediates.json>. The cross-signed issuer and chain fixtures come from <https://emudhra.we-have-a-problem.com/chains.zip> (archive SHA-256 `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`). Fixtures contain public certificates only. Each fixture certificate's fingerprint is verified before chain checks.
|
||||||
|
|
||||||
|
The same incident follow-up updates Odysseus's application bootstrap and independent bundled clients; see [outbound TLS trust](tls-trust.md). Those package updates do not change the derived SearXNG application/package versions. The base-image alignment regression requires its immutable upstream base to match the deployment pins until a verified derived release is selected, with the local overlay tag matching the application date.
|
||||||
|
|||||||
+1
-3
@@ -70,6 +70,4 @@ For behavioral validation, all fourteen [published test chains](https://emudhra.
|
|||||||
|
|
||||||
The maintained bootstrap selects certifi 2026.10.23 with its verified wheel hash; pip remains 26.2.2. The certifi update removes the C1/C3 roots in addition to the earlier incident changes. CI uses uv 0.13.5, which adds verifier-level rejection of the compromised intermediate keys and updated managed CPython builds. Existing virtual environments do not update automatically. See the [certifi change](https://github.com/certifi/python-certifi/commit/b60466c9f2882d9cf088943c650b0faac06c080c), [uv release](https://github.com/astral-sh/uv/releases/tag/0.13.5), and [Python security releases](https://blog.python.org/2026/10/python-3151-3149-31317/). The pinned bootstrap still replaces the selected versions exactly; preserving newer installed versions and private-mirror support remain separate maintenance.
|
The maintained bootstrap selects certifi 2026.10.23 with its verified wheel hash; pip remains 26.2.2. The certifi update removes the C1/C3 roots in addition to the earlier incident changes. CI uses uv 0.13.5, which adds verifier-level rejection of the compromised intermediate keys and updated managed CPython builds. Existing virtual environments do not update automatically. See the [certifi change](https://github.com/certifi/python-certifi/commit/b60466c9f2882d9cf088943c650b0faac06c080c), [uv release](https://github.com/astral-sh/uv/releases/tag/0.13.5), and [Python security releases](https://blog.python.org/2026/10/python-3151-3149-31317/). The pinned bootstrap still replaces the selected versions exactly; preserving newer installed versions and private-mirror support remain separate maintenance.
|
||||||
|
|
||||||
SearXNG remains an independent trust owner. Use the [upstream-image validation and rollout instructions](searxng-trust-image.md); updating these application packages does not repair an existing sidecar. Host daemons, native browser policy, optional compiled clients and independently managed integrations require their own effective-client checks.
|
SearXNG remains an independent trust owner. Use the [derived-image validation and rollout instructions](searxng-trust-image.md); updating these application packages does not repair an existing sidecar. Host daemons, native browser policy, optional compiled clients and independently managed integrations require their own effective-client checks.
|
||||||
|
|
||||||
CPython's [intermediate-key verifier block](https://github.com/python/cpython/pull/160042) and its [3.14 backport](https://github.com/python/cpython/pull/160569) merged on October 24. They are distinct from Python 3.14.9's bundled-pip update. No released 3.14 interpreter containing that verifier change was identified when this image pin was selected; retain the corrected effective trust stores and track the subsequent security release. hf-xet's [trust fix](https://github.com/huggingface/xet-core/pull/1038) is also merged but not included in the latest published 1.7.0 release at that check. Agent-browser 0.39.2 and primp 2.0.1 are not established incident fixes for their independent native trust paths. A version bump alone does not establish that the active client uses corrected trust.
|
|
||||||
|
|||||||
Vendored
+3
-1
@@ -14,4 +14,6 @@ Downloaded source SHA-256 values:
|
|||||||
|
|
||||||
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
|
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
|
||||||
|
|
||||||
The upstream SearXNG image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
|
The SearXNG derived-image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
|
||||||
|
|
||||||
|
`emsign-g1.txt` is the unaffected public emSign Root CA - G1 certificate in PEM format, used by the SearXNG regressions to verify preservation of unrelated trust. Its fingerprint is SHA-256 `40f6af0346a99aa1cd1d555a4e9cce62c7f9634603ee406615833dc8c8d00367`.
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD
|
||||||
|
VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU
|
||||||
|
ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH
|
||||||
|
MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO
|
||||||
|
MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv
|
||||||
|
Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN
|
||||||
|
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz
|
||||||
|
f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO
|
||||||
|
8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq
|
||||||
|
d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM
|
||||||
|
tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt
|
||||||
|
Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB
|
||||||
|
o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD
|
||||||
|
AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x
|
||||||
|
PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM
|
||||||
|
wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d
|
||||||
|
GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH
|
||||||
|
6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby
|
||||||
|
RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
|
||||||
|
iN66zB+Afko=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Exercise removal using public X.509 certificates, without network access."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import ssl
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SPEC = importlib.util.spec_from_file_location("searxng_repair", ROOT / "docker/searxng/repair_trust.py")
|
||||||
|
repair = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(repair)
|
||||||
|
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
class SearxngTrustTests(unittest.TestCase):
|
||||||
|
def test_inventory_matches_all_published_fixture_ca_fingerprints(self):
|
||||||
|
expected = set(INCIDENT["roots"]) | set(INCIDENT["intermediates"]) | set(INCIDENT["cross_signed_roots"])
|
||||||
|
self.assertEqual(set(repair.BLOCKED), expected)
|
||||||
|
self.assertEqual(len(expected), 21)
|
||||||
|
for fingerprint in expected:
|
||||||
|
self.assertEqual(hashlib.sha256(ssl.PEM_cert_to_DER_cert(INCIDENT["certificates"][fingerprint])).hexdigest(), fingerprint)
|
||||||
|
|
||||||
|
def test_all_known_incident_certificates_are_removed_preserving_other_certificates(self):
|
||||||
|
unaffected = (ROOT / "tests/fixtures/ca-trust/emsign-g1.txt").read_text()
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "bundle.pem"
|
||||||
|
for fingerprint in repair.BLOCKED:
|
||||||
|
with self.subTest(fingerprint=fingerprint):
|
||||||
|
path.write_text(INCIDENT["certificates"][fingerprint] + "\n" + unaffected)
|
||||||
|
self.assertEqual(repair.filter_bundle(path), 1)
|
||||||
|
self.assertEqual(path.read_text().strip(), unaffected.strip())
|
||||||
|
self.assertEqual(repair.check_bundle(path), 1)
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
context.load_verify_locations(cafile=str(path))
|
||||||
|
self.assertEqual(len(context.get_ca_certs()), 1)
|
||||||
|
self.assertEqual(repair.filter_bundle(path), 0)
|
||||||
|
|
||||||
|
def test_system_source_is_deleted_so_regeneration_cannot_restore_it(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "root.crt"
|
||||||
|
path.write_text(INCIDENT["certificates"][next(iter(repair.BLOCKED))])
|
||||||
|
self.assertEqual(repair.filter_bundle(path, allow_empty=True), 1)
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
|
||||||
|
def test_bundle_cannot_be_emptied(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "bundle.pem"
|
||||||
|
original = INCIDENT["certificates"][next(iter(repair.BLOCKED))]
|
||||||
|
path.write_text(original)
|
||||||
|
with self.assertRaisesRegex(ValueError, "refusing to empty"):
|
||||||
|
repair.filter_bundle(path)
|
||||||
|
self.assertEqual(path.read_text(), original)
|
||||||
|
|
||||||
|
def test_derived_base_matches_every_compose_pin(self):
|
||||||
|
# The overlay corrects whatever image Compose selects; a pin bump that
|
||||||
|
# skips the derived Dockerfile would silently run the older base.
|
||||||
|
base = re.search(r"^FROM (\S+) AS base$", (ROOT / "docker/searxng/Dockerfile").read_text(), re.M)
|
||||||
|
self.assertIsNotNone(base)
|
||||||
|
for name in ("docker-compose.yml", "docker-compose.gpu-amd.yml", "docker-compose.gpu-nvidia.yml"):
|
||||||
|
with self.subTest(compose=name):
|
||||||
|
pins = re.findall(r"image:\s*(\S*searxng/searxng:\S+)", (ROOT / name).read_text())
|
||||||
|
self.assertEqual(pins, [base.group(1)])
|
||||||
|
version = base.group(1).split(":", 1)[1].split("-", 1)[0]
|
||||||
|
overlay = (ROOT / "docker/searxng/compose.yml").read_text()
|
||||||
|
self.assertIn(f"image: localhost/odysseus-searxng:{version}-ca-trust", overlay)
|
||||||
|
|
||||||
|
def test_empty_and_malformed_input_fail_closed(self):
|
||||||
|
for text in ("", "not a certificate", "-----BEGIN CERTIFICATE-----\nbroken\n-----END CERTIFICATE-----"):
|
||||||
|
with self.subTest(text=text), self.assertRaises((ValueError, OSError)):
|
||||||
|
list(repair.certificates(text))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -18,13 +18,13 @@ INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def validator():
|
def validator():
|
||||||
checker = ModuleType("check_ca_trust")
|
repair = ModuleType("repair_trust")
|
||||||
checker.check_bundle = lambda path: None
|
repair.check_bundle = lambda path: None
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
"searxng_validation", ROOT / "docker/searxng/validate_image.py"
|
"searxng_validation", ROOT / "docker/searxng/validate_image.py"
|
||||||
)
|
)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
with patch.dict(sys.modules, {"check_ca_trust": checker}):
|
with patch.dict(sys.modules, {"repair_trust": repair}):
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
module.__file__ = str(ROOT / "tests/fixtures/ca-trust/validate_image.py")
|
module.__file__ = str(ROOT / "tests/fixtures/ca-trust/validate_image.py")
|
||||||
return module
|
return module
|
||||||
|
|||||||
@@ -10,22 +10,17 @@ startup. This guards that the pin stays in place.
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
COMPOSE = Path(__file__).resolve().parent.parent / "docker-compose.yml"
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
COMPOSE = ROOT / "docker-compose.yml"
|
|
||||||
|
|
||||||
|
|
||||||
def test_searxng_image_is_pinned_not_latest():
|
def test_searxng_image_is_pinned_not_latest():
|
||||||
text = COMPOSE.read_text(encoding="utf-8")
|
text = COMPOSE.read_text(encoding="utf-8")
|
||||||
image = yaml.safe_load(text)["services"]["searxng"]["image"]
|
m = re.search(r"image:\s*\S*searxng/searxng:(\S+)", text)
|
||||||
assert re.fullmatch(
|
assert m, "searxng image line not found in docker-compose.yml"
|
||||||
r"docker\.io/searxng/searxng:\d{4}\.\d[^\s@]*@sha256:[0-9a-f]{64}", image
|
tag = m.group(1)
|
||||||
), f"SearXNG must use a versioned immutable upstream image: {image!r}"
|
assert tag != "latest", (
|
||||||
|
"SearXNG must be pinned, not ':latest' — odysseus startup depends on its "
|
||||||
|
"healthcheck, so a broken upstream latest tag blocks the app (issue #1414)"
|
||||||
def test_all_standalone_compose_files_select_the_same_searxng_image():
|
)
|
||||||
expected = yaml.safe_load(COMPOSE.read_text())["services"]["searxng"]["image"]
|
# A real version tag (date-based, e.g. 2026.5.31-7159b8aed), not a moving ref.
|
||||||
for name in ("docker-compose.gpu-amd.yml", "docker-compose.gpu-nvidia.yml"):
|
assert re.match(r"\d{4}\.\d", tag), f"expected a versioned tag, got {tag!r}"
|
||||||
actual = yaml.safe_load((ROOT / name).read_text())["services"]["searxng"]["image"]
|
|
||||||
assert actual == expected, f"{name} selects a different SearXNG image"
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Publication must preserve the images that passed native, credential-free checks."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SPEC = importlib.util.spec_from_file_location("searxng_release", ROOT / ".github/scripts/searxng_release.py")
|
||||||
|
release = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(release)
|
||||||
|
SHA = "a" * 40
|
||||||
|
DIGESTS = {"amd64": "sha256:" + "1" * 64, "arm64": "sha256:" + "2" * 64}
|
||||||
|
|
||||||
|
|
||||||
|
def descriptor(snapshot=None, manifest=None, digests=None):
|
||||||
|
snapshot = snapshot or release.source_snapshot(ROOT, SHA, release.REPOSITORY)
|
||||||
|
manifest = manifest or {
|
||||||
|
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||||
|
"digest": "sha256:" + "3" * 64,
|
||||||
|
"manifests": [
|
||||||
|
{"digest": value, "platform": {"os": "linux", "architecture": arch}}
|
||||||
|
for arch, value in DIGESTS.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return release.release_descriptor(snapshot, copy.deepcopy(snapshot), manifest, SHA, "123", "sha-test", digests or DIGESTS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_binds_both_native_artifacts_and_immutable_index():
|
||||||
|
value = descriptor()
|
||||||
|
assert value["image"] == release.IMAGE + "@sha256:" + "3" * 64
|
||||||
|
assert value["architecture_digests"] == DIGESTS
|
||||||
|
assert value["platforms"] == ["linux/amd64", "linux/arm64"]
|
||||||
|
assert set(value["source_inputs"]) == set(release.SOURCE_FILES)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change", ["fork", "revision", "missing-input", "checksum"])
|
||||||
|
def test_release_refuses_unmatched_source(change):
|
||||||
|
snapshot = release.source_snapshot(ROOT, SHA, release.REPOSITORY)
|
||||||
|
if change == "fork":
|
||||||
|
snapshot["repository"] = "fork/odysseus"
|
||||||
|
elif change == "revision":
|
||||||
|
snapshot["source_revision"] = "b" * 40
|
||||||
|
elif change == "missing-input":
|
||||||
|
snapshot["source_inputs"].pop(release.SOURCE_FILES[0])
|
||||||
|
else:
|
||||||
|
snapshot["source_inputs"][release.SOURCE_FILES[0]] = "invalid"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
descriptor(snapshot=snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_refuses_different_native_build_inputs():
|
||||||
|
snapshot = release.source_snapshot(ROOT, SHA, release.REPOSITORY)
|
||||||
|
other = copy.deepcopy(snapshot)
|
||||||
|
other["source_inputs"][release.SOURCE_FILES[0]] = "f" * 64
|
||||||
|
with pytest.raises(ValueError, match="different source inputs"):
|
||||||
|
release.release_descriptor(snapshot, other, {}, SHA, "123", "test", DIGESTS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change", ["missing-arm", "extra-platform", "wrong-os", "duplicate-digest", "wrong-digest", "moving-index"])
|
||||||
|
def test_release_refuses_incomplete_or_substituted_index(change):
|
||||||
|
manifest = {
|
||||||
|
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||||
|
"digest": "sha256:" + "3" * 64,
|
||||||
|
"manifests": [{"digest": value, "platform": {"os": "linux", "architecture": arch}} for arch, value in DIGESTS.items()],
|
||||||
|
}
|
||||||
|
if change == "missing-arm":
|
||||||
|
manifest["manifests"].pop()
|
||||||
|
elif change == "extra-platform":
|
||||||
|
manifest["manifests"].append({"digest": "sha256:" + "4" * 64, "platform": {"os": "unknown", "architecture": "unknown"}})
|
||||||
|
elif change == "wrong-os":
|
||||||
|
manifest["manifests"][1]["platform"]["os"] = "windows"
|
||||||
|
elif change == "duplicate-digest":
|
||||||
|
manifest["manifests"][1]["digest"] = DIGESTS["amd64"]
|
||||||
|
elif change == "wrong-digest":
|
||||||
|
manifest["manifests"][1]["digest"] = "sha256:" + "4" * 64
|
||||||
|
else:
|
||||||
|
manifest["digest"] = "latest"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
descriptor(manifest=manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ci_publication_boundary_and_native_checks():
|
||||||
|
workflow = yaml.safe_load((ROOT / ".github/workflows/searxng-image.yml").read_text())
|
||||||
|
events = workflow.get("on", workflow.get(True))
|
||||||
|
assert "pull_request_target" not in events
|
||||||
|
assert workflow["permissions"] == {"contents": "read"}
|
||||||
|
jobs = workflow["jobs"]
|
||||||
|
assert [name for name, job in jobs.items() if job.get("permissions", {}).get("packages") == "write"] == ["publish"]
|
||||||
|
publish = jobs["publish"]
|
||||||
|
assert publish["needs"] == "validate"
|
||||||
|
assert "github.repository == 'odysseus-dev/odysseus'" in publish["if"]
|
||||||
|
assert "github.event_name == 'push'" in publish["if"]
|
||||||
|
assert "refs/heads/release/searxng-ca-trust" in publish["if"]
|
||||||
|
assert "github.ref)" in publish["if"]
|
||||||
|
steps = publish["steps"]
|
||||||
|
assert next(i for i, step in enumerate(steps) if "docker load" in step.get("run", "")) < next(i for i, step in enumerate(steps) if "docker/login-action" in step.get("uses", ""))
|
||||||
|
for name in ("validate", "verify-published"):
|
||||||
|
matrix = jobs[name]["strategy"]["matrix"]["include"]
|
||||||
|
assert {(row["arch"], row["runner"]) for row in matrix} == {("amd64", "ubuntu-latest"), ("arm64", "ubuntu-24.04-arm")}
|
||||||
|
assert any("validate-searxng-image.sh" in step.get("run", "") for step in jobs[name]["steps"])
|
||||||
|
assert not any("login-action" in step.get("uses", "") for step in jobs["verify-published"]["steps"])
|
||||||
|
assert set(jobs["release"]["needs"]) == {"publish", "verify-published"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure,expected", [("none", 0), ("exec", 7), ("up", 1), ("existing-volume", 1)])
|
||||||
|
def test_compose_probe_owns_only_its_temporary_resources(tmp_path, failure, expected):
|
||||||
|
"""Exercise the real helper with a Docker double, including failure cleanup."""
|
||||||
|
binary = tmp_path / "docker"
|
||||||
|
calls = tmp_path / "calls.jsonl"
|
||||||
|
binary.write_text('''#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
args = sys.argv[1:]
|
||||||
|
with open(os.environ["DOCKER_CALLS"], "a") as log:
|
||||||
|
log.write(json.dumps({"args": args, "volume": os.environ["SEARXNG_VALIDATION_VOLUME"], "secret": os.environ["SEARXNG_SECRET"]}) + "\\n")
|
||||||
|
failure = os.environ["DOCKER_FAILURE"]
|
||||||
|
if args[:2] == ["volume", "inspect"]:
|
||||||
|
sys.exit(0 if failure == "existing-volume" else 1)
|
||||||
|
if "exec" in args and failure == "exec":
|
||||||
|
sys.exit(7)
|
||||||
|
if "up" in args and failure == "up":
|
||||||
|
sys.exit(9)
|
||||||
|
''')
|
||||||
|
binary.chmod(0o755)
|
||||||
|
result = subprocess.run(["bash", str(ROOT / ".github/scripts/validate-searxng-image.sh"), "localhost/searxng:validated"], env={**os.environ, "PATH": str(tmp_path) + ":" + os.environ["PATH"], "DOCKER_CALLS": str(calls), "DOCKER_FAILURE": failure, "SEARXNG_SECRET": "operator-secret-must-not-propagate"}, capture_output=True, text=True)
|
||||||
|
assert result.returncode == expected, result.stderr
|
||||||
|
entries = [json.loads(line) for line in calls.read_text().splitlines()]
|
||||||
|
assert all(entry["secret"] == "searxng-ci-validation-only" for entry in entries)
|
||||||
|
commands = [entry["args"] for entry in entries]
|
||||||
|
if failure == "existing-volume":
|
||||||
|
assert len(commands) == 1
|
||||||
|
return
|
||||||
|
up = next(command for command in commands if "up" in command)
|
||||||
|
assert up[up.index("--env-file") + 1] == "/dev/null"
|
||||||
|
assert "--no-deps" in up and "--no-build" in up and "--wait" in up
|
||||||
|
assert up[up.index("--pull") + 1] == "never"
|
||||||
|
down = next(command for command in commands if "down" in command)
|
||||||
|
assert "--volumes" not in down
|
||||||
|
assert commands[-1] == ["volume", "rm", entries[-1]["volume"]]
|
||||||
|
if failure != "up":
|
||||||
|
execute = next(command for command in commands if "exec" in command)
|
||||||
|
assert execute[-2:] == ["--running", "--live"]
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""Native checks must validate the selected runtime without registry credentials."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_ci_checks_the_pinned_upstream_image_on_both_native_architectures():
|
|
||||||
workflow = yaml.safe_load((ROOT / ".github/workflows/searxng-image.yml").read_text())
|
|
||||||
events = workflow.get("on", workflow.get(True))
|
|
||||||
assert "pull_request_target" not in events
|
|
||||||
assert workflow["permissions"] == {"contents": "read"}
|
|
||||||
assert set(workflow["jobs"]) == {"validate"}
|
|
||||||
job = workflow["jobs"]["validate"]
|
|
||||||
matrix = job["strategy"]["matrix"]["include"]
|
|
||||||
assert {(row["arch"], row["runner"]) for row in matrix} == {
|
|
||||||
("amd64", "ubuntu-latest"), ("arm64", "ubuntu-24.04-arm")
|
|
||||||
}
|
|
||||||
steps = job["steps"]
|
|
||||||
assert not any("login-action" in step.get("uses", "") for step in steps)
|
|
||||||
assert all(step.get("with", {}).get("persist-credentials") is False
|
|
||||||
for step in steps if "actions/checkout@" in step.get("uses", ""))
|
|
||||||
offline = next(step for step in steps if "docker/build-push-action@" in step.get("uses", ""))
|
|
||||||
assert offline["with"]["network"] == "none"
|
|
||||||
assert offline["with"]["build-args"] == "SEARXNG_IMAGE=${{ steps.image.outputs.image }}"
|
|
||||||
live = next(step for step in steps if "validate-searxng-image.sh" in step.get("run", ""))
|
|
||||||
assert live["env"]["SEARXNG_TEST_IMAGE"] == "${{ steps.image.outputs.image }}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("failure,expected", [("none", 0), ("exec", 7), ("up", 1), ("existing-volume", 1)])
|
|
||||||
def test_compose_probe_owns_only_its_temporary_resources(tmp_path, failure, expected):
|
|
||||||
"""Exercise the real helper with a Docker double, including failure cleanup."""
|
|
||||||
binary = tmp_path / "docker"
|
|
||||||
calls = tmp_path / "calls.jsonl"
|
|
||||||
binary.write_text('''#!/usr/bin/env python3
|
|
||||||
import json, os, sys
|
|
||||||
args = sys.argv[1:]
|
|
||||||
with open(os.environ["DOCKER_CALLS"], "a") as log:
|
|
||||||
log.write(json.dumps({"args": args, "volume": os.environ["SEARXNG_VALIDATION_VOLUME"], "secret": os.environ["SEARXNG_SECRET"]}) + "\\n")
|
|
||||||
failure = os.environ["DOCKER_FAILURE"]
|
|
||||||
if args[:2] == ["volume", "inspect"]:
|
|
||||||
sys.exit(0 if failure == "existing-volume" else 1)
|
|
||||||
if "exec" in args and failure == "exec":
|
|
||||||
sys.exit(7)
|
|
||||||
if "up" in args and failure == "up":
|
|
||||||
sys.exit(9)
|
|
||||||
''')
|
|
||||||
binary.chmod(0o755)
|
|
||||||
result = subprocess.run(["bash", str(ROOT / ".github/scripts/validate-searxng-image.sh"), "localhost/searxng:validated"], env={**os.environ, "PATH": str(tmp_path) + ":" + os.environ["PATH"], "DOCKER_CALLS": str(calls), "DOCKER_FAILURE": failure, "SEARXNG_SECRET": "operator-secret-must-not-propagate"}, capture_output=True, text=True)
|
|
||||||
assert result.returncode == expected, result.stderr
|
|
||||||
entries = [json.loads(line) for line in calls.read_text().splitlines()]
|
|
||||||
assert all(entry["secret"] == "searxng-ci-validation-only" for entry in entries)
|
|
||||||
commands = [entry["args"] for entry in entries]
|
|
||||||
if failure == "existing-volume":
|
|
||||||
assert len(commands) == 1
|
|
||||||
return
|
|
||||||
up = next(command for command in commands if "up" in command)
|
|
||||||
assert up[up.index("--env-file") + 1] == "/dev/null"
|
|
||||||
assert "--no-deps" in up and "--no-build" in up and "--wait" in up
|
|
||||||
assert up[up.index("--pull") + 1] == "never"
|
|
||||||
down = next(command for command in commands if "down" in command)
|
|
||||||
assert "--volumes" not in down
|
|
||||||
assert commands[-1] == ["volume", "rm", entries[-1]["volume"]]
|
|
||||||
if failure != "up":
|
|
||||||
execute = next(command for command in commands if "exec" in command)
|
|
||||||
assert execute[-2:] == ["--running", "--live"]
|
|
||||||
Reference in New Issue
Block a user