Compare commits

..
Author SHA1 Message Date
RaresKeYandLéo c6d76b9a9b fix(security): refresh client trust and publish validated SearXNG images
Repair SearXNG system and certifi stores, validate native amd64/arm64 runtime images through Compose, and publish the exact verified artifacts through a trusted CI job. Adopt released Python, certifi, grpcio, uv and Docker CLI trust fixes, with strict controls for all fourteen public incident chains.

Part of #6784 and #6790.

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-10-23 22:32:33 +00:00
GrzegorzandClaude Opus 5.5 f8e043e8ca fix(approvals): seal approved bash/python and file writes in the agent workspace when no workspace is set (#6705)
#6683 made an exact approval seal a launch boundary (and a filesystem root)
in the request's workspace, but only when a workspace is bound. Agent mode
with no folder picked is the default, and there the approval still sealed
nothing, so "Allow" always ended in "Approved action has no sealed
process/job identity" (bash/python) or "Approved filesystem action has no
sealed resource identity" (write_file). In Docker every agent turn carries
the backend runtime context, so bash and write_file always ask for approval
and never ran.

With no workspace, seal the root in the agent workspace, which is where
agent_cwd() already runs bash/python in that case. Inherited (child)
authorities still cannot widen.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:19:04 -06:00
Isharakandisharak7m 98a16242c6 fix(security): add SSRF validation to manage_endpoints agent tool (#6327)
The manage_endpoints agent tool accepts arbitrary user-supplied base_urls
and writes them to the database without calling check_outbound_url(),
while the HTTP route (POST /api/model-endpoints) enforces full SSRF
hardening. A prompt-injection payload smuggled into a skill, note, fetched
page, or email could instruct the model to register a cloud-metadata or
internal-network endpoint via this tool path.

Add the same check_outbound_url() guard used by the HTTP route to the
agent tool's 'add' action. Also sanitize the error message in the except
block to avoid leaking raw exception text.

Add 8 regression tests covering: cloud metadata, non-HTTP schemes,
loopback (local-first), strict mode, safe URLs, empty URLs, gopher
scheme, and error message sanitization.

Co-authored-by: isharak7m <192635824+isharak7m@users.noreply.github.com>
2026-10-23 13:18:59 -06:00
IsharakSantoshkumarpuppalaisharak7m
0dbf457c00 fix(security): block SSRF via user-supplied model endpoint URLs (#6316)
* fix: block SSRF via user-supplied model endpoint URLs

* fix(test): stub DNS in loopback SSRF tests for deterministic results

Replace environment-dependent test_loopback_always_rejected with two
deterministic tests using a stub resolver:
- test_loopback_accepted_by_default (local-first, block_private=False)
- test_loopback_rejected_in_strict_mode (block_private=True)

The old test only passed when localhost resolved to ::1 (hit is_reserved),
not because loopback was actually always rejected.

Co-Authored-By: Santoshkumarpuppala

* fix: guard PATCH base_url against SSRF and add ipv6 loopback coverage

---------

Co-authored-by: isharak7m <192635824+isharak7m@users.noreply.github.com>
2026-10-23 13:18:54 -06:00
Raul JuarezandRaul b4ae7ebcc4 feat(agent): expose chat history with existing chat scope (#6443)
Co-authored-by: Raul <9117159+raultcj@users.noreply.github.com>
2026-10-23 13:18:49 -06:00
42 changed files with 2071 additions and 42 deletions
+118
View File
@@ -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()
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Exercise an already-built image through the normal Compose startup path.
set -euo pipefail
searxng_repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
searxng_image=${1:?usage: validate-searxng-image.sh IMAGE}
[[ "$searxng_image" =~ ^[a-zA-Z0-9_./:@+-]+$ ]] || {
echo "Invalid image reference" >&2
exit 2
}
searxng_checks=$(mktemp -d "${TMPDIR:-/tmp}/searxng-ci-XXXXXXXX")
searxng_project=$(basename "$searxng_checks" | tr '[:upper:]' '[:lower:]')
export SEARXNG_VALIDATION_IMAGE="$searxng_image"
export SEARXNG_VALIDATION_CHECKS="$searxng_checks"
export SEARXNG_VALIDATION_VOLUME="${searxng_project}-settings"
# Never forward an operator's existing secret or load their .env for a CI probe.
export SEARXNG_SECRET=searxng-ci-validation-only
searxng_volume_created=0
searxng_started=0
searxng_compose=(docker compose --env-file /dev/null --project-name "$searxng_project"
-f "$searxng_repo/docker-compose.yml"
-f "$searxng_repo/.github/searxng-validation.compose.yml")
searxng_cleanup() {
local searxng_exit=$?
if [ "$searxng_started" = 1 ]; then
"${searxng_compose[@]}" down --remove-orphans >/dev/null 2>&1 || true
fi
if [ "$searxng_volume_created" = 1 ]; then
docker volume rm "$SEARXNG_VALIDATION_VOLUME" >/dev/null 2>&1 || true
fi
rm -rf -- "$searxng_checks"
return "$searxng_exit"
}
trap searxng_cleanup EXIT
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/"
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/scripts/migrate_searxng_settings.py" "$searxng_checks/migrate_searxng_settings.py"
if docker volume inspect "$SEARXNG_VALIDATION_VOLUME" >/dev/null 2>&1; then
echo "Refusing to reuse an existing validation volume" >&2
exit 1
fi
docker volume create --label "odysseus.searxng-validation=$searxng_project" \
"$SEARXNG_VALIDATION_VOLUME" >/dev/null
searxng_volume_created=1
searxng_started=1
if ! "${searxng_compose[@]}" up --detach --no-deps --no-build --pull never \
--wait --wait-timeout 150 searxng; then
"${searxng_compose[@]}" logs --no-color searxng >&2 || true
exit 1
fi
"${searxng_compose[@]}" exec -T searxng /usr/local/searxng/.venv/bin/python \
/opt/odysseus-checks/validate_image.py --running --live
+21
View File
@@ -0,0 +1,21 @@
# CI-only isolation; retain the service's entrypoint, healthcheck and capabilities.
services:
searxng:
image: ${SEARXNG_VALIDATION_IMAGE:?set the image being validated}
ports: !reset []
volumes: !override
- searxng-validation-data:/etc/searxng
- ${SEARXNG_VALIDATION_CHECKS:?}/settings.yml:/tmp/searxng-settings.yml.template:ro
- ${SEARXNG_VALIDATION_CHECKS:?}/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro
- ${SEARXNG_VALIDATION_CHECKS:?}:/opt/odysseus-checks:ro
networks: !override
- default
restart: "no"
networks:
default: {}
volumes:
searxng-validation-data:
external: true
name: ${SEARXNG_VALIDATION_VOLUME:?}
+4 -4
View File
@@ -91,7 +91,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22.23.4"
- run: npm ci --ignore-scripts --no-audit --no-fund
@@ -168,10 +168,10 @@ jobs:
# uv resolves the same versions as pip but installs ~5x faster, and its
# cache keeps each shard from re-downloading the whole requirement set.
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
version: "0.13.4"
version: "0.13.5"
enable-cache: true
cache-dependency-glob: requirements.txt
prune-cache: false
@@ -180,7 +180,7 @@ jobs:
python scripts/check_ca_trust.py --python
uv pip install --system -r requirements.txt
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'
with:
node-version: "22.23.4"
+2 -2
View File
@@ -31,11 +31,11 @@ jobs:
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
persist-credentials: false
- name: Lint Dockerfile
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
with:
dockerfile: Dockerfile
# DL3008: pinning apt package versions is impractical on a -slim base
+5 -5
View File
@@ -61,14 +61,14 @@ jobs:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
with:
driver: docker
# Build without pushing so a broken Dockerfile is caught here, and the
# exact image we ship is what gets scanned.
- name: Build image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: false
@@ -107,12 +107,12 @@ jobs:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
with:
driver: docker
- name: Build image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: false
@@ -133,7 +133,7 @@ jobs:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-results.sarif
category: trivy-image
+1 -1
View File
@@ -47,4 +47,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+5 -5
View File
@@ -55,7 +55,7 @@ jobs:
shell: bash
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- 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
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
@@ -64,7 +64,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
pull: true
@@ -78,7 +78,7 @@ jobs:
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
@@ -107,13 +107,13 @@ jobs:
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- 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
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
+243
View File
@@ -0,0 +1,243 @@
name: ci / SearXNG image
on:
pull_request:
paths:
- 'docker/searxng/**'
- 'tests/fixtures/ca-trust/**'
- 'tests/test_searxng*'
- 'docker-compose*.yml'
- 'config/searxng/**'
- 'scripts/migrate_searxng_settings.py'
- '.github/scripts/*searxng*'
- '.github/searxng-validation.compose.yml'
- '.github/workflows/searxng-image.yml'
push:
branches: [dev, main, release/searxng-ca-trust]
paths:
- 'docker/searxng/**'
- 'tests/fixtures/ca-trust/**'
- 'docker-compose*.yml'
- 'config/searxng/**'
- 'scripts/migrate_searxng_settings.py'
- '.github/scripts/*searxng*'
- '.github/searxng-validation.compose.yml'
- '.github/workflows/searxng-image.yml'
workflow_dispatch:
inputs:
publish:
description: Publish after native validation (canonical trusted branches only)
type: boolean
default: false
concurrency:
group: searxng-image-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
validate:
name: SearXNG validate (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Reproduce affected base
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/searxng/Dockerfile
target: baseline-validation
network: none
platforms: ${{ matrix.platform }}
no-cache-filters: baseline-validation
cache-from: type=gha,scope=searxng-${{ matrix.arch }}
- name: Validate repaired stores and upstream startup
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/searxng/Dockerfile
target: validation
network: none
platforms: ${{ matrix.platform }}
no-cache-filters: validation
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:
SEARXNG_TEST_IMAGE: localhost/odysseus-searxng:ci-${{ matrix.arch }}
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
+6 -4
View File
@@ -3,7 +3,7 @@
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
# the final image / Cookbook never has to compile the broken sdists. See
# docker/build-realesrgan-wheels.sh for the full rationale.
FROM python:3.14-slim AS realesrgan-wheels
FROM python:3.14.9-slim AS realesrgan-wheels
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
@@ -14,7 +14,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
FROM python:3.14-slim
FROM python:3.14.9-slim
# System deps. tmux is required by Cookbook for background downloads/serves.
# openssh-client is required for Cookbook remote server tests, setup, probes,
@@ -92,7 +92,8 @@ RUN npm install -g agent-browser@0.35.0 --omit=dev --loglevel=error
# /var/run/docker.sock mount). The Debian `docker.io` package ships
# dockerd but not the client binary on slim, so grab the static client
# tarball from download.docker.com instead.
ARG DOCKER_CLI_VERSION=29.6.2
# 29.9.2 includes Go 1.26.10's eMudhra intermediate-key distrust.
ARG DOCKER_CLI_VERSION=29.9.2
RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) DARCH=x86_64 ;; \
@@ -129,7 +130,8 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
&& rm -rf /tmp/odysseus-wheels
# Check gRPC's effective defaults after all Python dependencies are installed.
# Its wheel can retain affected roots even when pip/certifi are current.
# The application minimum fixes its bundled roots; the checked override also
# keeps the effective Docker selection consistent with system trust.
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc
# Copy app code
+3 -1
View File
@@ -755,7 +755,8 @@ app.include_router(setup_research_routes(research_handler, session_manager=sessi
# History
from routes.history.history_routes import setup_history_routes
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
history_router = setup_history_routes(session_manager, upload_handler=upload_handler)
app.include_router(history_router)
# Search
from routes.search.search_routes import setup_search_routes
@@ -915,6 +916,7 @@ app.include_router(setup_codex_routes(
memory_router=memory_router,
calendar_router=calendar_router,
document_router=document_router,
history_router=history_router,
))
app.include_router(setup_claude_routes())
+22
View File
@@ -0,0 +1,22 @@
# Keep the application version unchanged while correcting its independent trust.
FROM docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 AS base
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
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
FROM validation AS live-validation
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --live
FROM repaired AS runtime
+8
View File
@@ -0,0 +1,8 @@
**
!docker/
!docker/searxng/
!docker/searxng/**
!tests/
!tests/fixtures/
!tests/fixtures/ca-trust/
!tests/fixtures/ca-trust/incident.json
+23
View File
@@ -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)"
}
+9
View File
@@ -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
+75
View File
@@ -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()
+173
View File
@@ -0,0 +1,173 @@
"""Offline incident-chain checks and loopback boot smoke for the derived image."""
import hashlib
import asyncio
import json
import os
from pathlib import Path
import re
import signal
import ssl
import subprocess
import sys
import tempfile
import time
import urllib.request
import certifi
from repair_trust import check_bundle
def check_chains(baseline=False):
incident = json.loads(Path(__file__).with_name("incident.json").read_text())
system = Path(os.environ["SSL_CERT_FILE"])
capath = os.environ["SSL_CERT_DIR"]
stores = [["-CAfile", str(system), "-no-CApath"],
["-CAfile", certifi.where(), "-no-CApath"],
["-no-CAfile", "-CApath", capath]]
if not baseline:
check_bundle(system)
check_bundle(Path(certifi.where()))
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
for fingerprint, pem in incident["certificates"].items():
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
raise ValueError(f"incident fixture fingerprint mismatch: {fingerprint}")
(directory / fingerprint).write_text(pem)
positive = directory / "affected.pem"
positive.write_text("\n".join(incident["certificates"][fp] for fp in incident["roots"]))
for hostname, chain in incident["chains"].items():
untrusted = directory / "untrusted.pem"
untrusted.write_text("\n".join(incident["certificates"][fp] for fp in chain[1:]))
base = ["openssl", "verify", "-no-CAstore", "-attime", str(incident["verification_time"]),
"-purpose", "sslserver", "-verify_hostname", hostname,
"-x509_strict", "-partial_chain", "-trusted_first", "-untrusted", str(untrusted)]
expectations = [(s, baseline and (index != 1 or "-aaa-" not in hostname))
for index, s in enumerate(stores)]
for selection, expected in expectations + [
(["-CAfile", str(positive), "-no-CApath"], True)]:
result = subprocess.run(base + selection + [str(directory / chain[0])],
capture_output=True, text=True)
if (result.returncode == 0) != expected:
raise RuntimeError(f"unexpected chain result: {hostname}: {selection}: {result.stderr}")
# Accept issuer lookup, self-signed and explicitly untrusted
# 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):
raise RuntimeError(f"not a certificate trust rejection: {hostname}: {result.stderr}")
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):
with tempfile.TemporaryDirectory() as directory:
settings = Path(directory) / "settings.yml"
settings.write_text("use_default_settings: true\nserver:\n secret_key: offline-build-validation-only\n limiter: false\nsearch:\n formats: [html, json]\n")
cache = Path(directory) / "cache"
cache.mkdir()
env = dict(os.environ, SEARXNG_SETTINGS_PATH=str(settings),
__SEARXNG_CONFIG_PATH=directory, __SEARXNG_DATA_PATH=str(cache),
GRANIAN_HOST="127.0.0.1", GRANIAN_PORT="18888")
# Do not expose a port or issue an outbound search during offline builds.
with tempfile.TemporaryFile() as log:
process = subprocess.Popen(["/usr/local/searxng/entrypoint.sh"],
cwd="/usr/local/searxng", env=env, stdout=log,
stderr=subprocess.STDOUT, start_new_session=True)
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
healthy = False
for _ in range(60):
if process.poll() is not None:
break
try:
with opener.open("http://127.0.0.1:18888/healthz", timeout=1) as response:
healthy = response.status == 200
except OSError:
pass
if healthy:
break
time.sleep(0.5)
if not healthy:
log.seek(0)
raise RuntimeError("SearXNG failed boot smoke: " + log.read().decode(errors="replace")[-6000:])
print("SearXNG loopback healthcheck passed")
# Its root entrypoint regenerates system trust. Repeat the
# behavioral checks after that real startup path completes.
check_running(live=live, port=18888)
finally:
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
def check_running(live=False, port=None):
"""Verify an existing service without repairing it or starting another one."""
port = int(os.environ.get("GRANIAN_PORT", "8080")) if port is None else port
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(f"http://127.0.0.1:{port}/healthz", timeout=5) as response:
if response.status != 200:
raise RuntimeError("SearXNG running-service healthcheck failed")
check_chains()
for path in Path(os.environ["SSL_CERT_DIR"]).iterdir():
if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", path.name):
check_bundle(path)
if live:
asyncio.run(check_https())
with opener.open(f"http://127.0.0.1:{port}/search?q=python+documentation&format=json", timeout=90) as search:
payload = json.load(search)
results = payload.get("results", [])
if not results:
raise RuntimeError("live search returned no results")
print(f"SearXNG live JSON search passed: {len(results)} results")
async def check_https():
# Exercise the actual pinned SearXNG client factory with its default CA
# selection, rather than a different HTTP library's trust configuration.
sys.path.insert(0, "/usr/local/searxng")
from curl_cffi.requests.exceptions import RequestException
from searx.network.client import new_client
client = new_client(enable_http=False, verify=True, enable_http2=True,
enable_http3=False, max_connections=2, proxies={},
local_address=None, max_redirects=3)
try:
response = await client.get("https://pypi.org/simple/certifi/", timeout=30)
if response.status_code != 200:
raise RuntimeError(f"ordinary HTTPS failed: {response.status_code}")
print("SearXNG actual curl client ordinary HTTPS passed with verification enabled")
incident = json.loads(Path(__file__).with_name("incident.json").read_text())
for hostname in incident["chains"]:
try:
await client.get(f"https://{hostname}/", timeout=30, allow_redirects=False)
except RequestException as exc:
# libcurl code 60 is peer certificate verification failure.
# DNS failures, timeouts and handshake EOF do not prove distrust.
if exc.code != 60:
raise RuntimeError(f"inconclusive incident TLS failure: {hostname}: {exc}") from exc
else:
raise RuntimeError(f"incident HTTPS unexpectedly accepted: {hostname}")
print(f"SearXNG actual curl client rejects {len(incident['chains'])}/{len(incident['chains'])} incident hosts with certificate errors after startup")
finally:
await client.aclose()
if __name__ == "__main__":
baseline = "--baseline" in sys.argv
live = "--live" in sys.argv
if "--running" in sys.argv:
if baseline:
raise ValueError("--running cannot validate an affected baseline")
check_running(live=live)
else:
check_chains(baseline=baseline)
if not baseline:
check_boot(live=live)
+50
View File
@@ -0,0 +1,50 @@
# SearXNG image trust correction
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.
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.
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.
## 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
# Reproduce the affected upstream behavior with offline public chain fixtures.
docker build --network=none --target baseline-validation -f docker/searxng/Dockerfile .
# Check corrected trust and actual upstream entrypoint/loopback health.
docker build --network=none --target validation -f docker/searxng/Dockerfile .
# Also exercise verified HTTPS through SearXNG's own curl client and a real search.
# Requires outbound networking and working upstream engines.
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 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 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.
## Scope and rollout
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.
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.
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
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.
+11 -3
View File
@@ -2,7 +2,7 @@
Odysseus uses both Python package CA bundles and system trust. HTTPX uses certifi by default; the guarded web/PDF fetcher and webhook delivery explicitly use Python's system SSL context. A configured `LLM_CA_BUNDLE` adds certificates to system trust for model calls. Upgrading certifi alone cannot repair every outbound HTTPS path.
For the October 2026 eMudhra incident, use certifi 2026.10.19 or newer, pip 26.2.2 or newer, and uv 0.13.4 or newer wherever uv is used. Pip has its own vendored certifi and also loads system roots. Update the operating system's certificate package separately. See the [Python security announcement](https://discuss.python.org/t/109576) and [incident details](https://emudhra.we-have-a-problem.com/).
For the October 2026 eMudhra incident, use certifi 2026.10.23 or newer, pip 26.2.2 or newer, and uv 0.13.5 or newer wherever uv is used. Pip has its own vendored certifi and also loads system roots. Update the operating system's certificate package separately. See the [Python security announcement](https://discuss.python.org/t/109576) and [incident details](https://emudhra.we-have-a-problem.com/).
Before other pip installs, bootstrap the selected environment with the integrity-pinned wheels:
@@ -27,7 +27,9 @@ Explicit PEM bundles can also be passed as positional arguments. The check rejec
This is an offline inventory check of known certificate fingerprints. It does not modify stores, reject requests at runtime, model native OS revocation, or detect arbitrary reissued certificates sharing the compromised keys. Chromium's policy separately matches public keys. Check mounted or replaced CA files again after deployment, remove affected trust through the owning platform/client configuration, and restart clients that cache SSL contexts. A passing build cannot validate custom files introduced later.
Both Docker build stages refresh system CA certificates and bootstrap pip/certifi before dependency installation. The build fails if known affected CA certificates remain. Node uses the corrected system store through `--use-openssl-ca`. Python gRPC uses that same PEM file through `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH`; updating certifi alone does not replace gRPC's independent wheel bundle. After dependency installation, the image checks gRPC's selected file as well. An explicit gRPC override must be readable and free of known affected CA certificates: gRPC can otherwise fall back to its affected bundled defaults. Upstream gRPC has [merged a bundle update](https://github.com/grpc/grpc/commit/8b32af3bf498c2ba9667410eaa8c836e700e97e6); as checked on 2026-10-21, its [release backport](https://github.com/grpc/grpc/pull/43700) is still open and the latest published grpcio wheel is 1.84.0. Retain the checked override until a released replacement's actual trust behavior has been verified.
Both Docker build stages use Python 3.14.9, refresh system CA certificates and bootstrap pip/certifi before dependency installation. The build fails if known affected CA certificates remain. Node uses the corrected system store through `--use-openssl-ca`. Python gRPC uses that same PEM file through `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH`; updating certifi alone does not replace gRPC's independent wheel bundle. After dependency installation, the image checks gRPC's selected file as well. An explicit gRPC override must be readable and free of known affected CA certificates. [grpcio 1.84.1](https://github.com/grpc/grpc/releases/tag/v1.84.1) includes the upstream root-bundle correction and is the application minimum. Retain the checked override as a consistent image trust selection; older persisted Python environments and explicit client roots still need independent checks.
The image also bundles Docker CLI [29.9.2](https://github.com/moby/moby/releases/tag/docker-v29.9.2), built with Go 1.26.10's distrust of the compromised intermediate keys. Cookbook uses this client for optional Docker operations. Default Compose does not mount the host Docker socket; local daemon access requires the explicit [host-Docker overlay](../docker/host-docker.yml), whose usual Unix-socket connection does not use TLS. The client update is maintenance of a shipped tool, not evidence that these local calls were exposed. It does not update the host daemon or other containers' clients and trust stores; assess those separately.
Chromium uses a separate trust store. The Docker image installs a managed `CADistrustedCertificates` policy for the eight compromised intermediate public keys, covering their cross-signs without depending on background revocation updates. It adds no trusted certificate and does not disable certificate verification. The policy and public-certificate provenance are documented in [the browser policy note](../docker/chromium-ca-policy.md). Native browser installations and independently managed profiles require their own platform policy or vendor update; this image policy does not configure host browsers.
@@ -62,4 +64,10 @@ For containers started non-root or without the SETUID/SETGID capabilities needed
SearXNG is a separate HTTPS client and image; updating the Odysseus image does not patch it. Inspect its effective CA path and update or rebuild that image independently. Also inspect persisted `/app/.local` packages, remote Cookbook environments, and externally configured MCP runtimes. Image replacement does not necessarily replace those environments.
For behavioral validation, all twelve [published test chains](https://emudhra.we-have-a-problem.com/chains.zip), including the AAA cross-signs, must fail certificate verification through the effective stores. Confirm that an ordinary known-good HTTPS request still succeeds. In Chromium, confirm the managed policy is loaded at `chrome://policy` and test the incident endpoints through the actual browser path with certificate verification enabled. The PEM checker cannot inspect Chromium's policy or its active verifier. Package versions and a successful image build alone do not prove that a running deployment selects the corrected store.
For behavioral validation, all fourteen [published test chains](https://emudhra.we-have-a-problem.com/chains.zip), including the AAA cross-signs and two chains without Certificate Transparency entries, must fail certificate verification through the effective stores. Confirm that an ordinary known-good HTTPS request still succeeds. In Chromium, confirm the managed policy is loaded at `chrome://policy` and test the incident endpoints through the actual browser path with certificate verification enabled. The PEM checker cannot inspect Chromium's policy or its active verifier. Package versions and a successful image build alone do not prove that a running deployment selects the corrected store.
## Follow-up releases
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 [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.
@@ -100,6 +100,14 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory
- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`.
- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`.
## Chat history
- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope.
```bash
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID
```
## Email draft + send
- Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Small Odysseus scoped API helper for Codex terminal sessions."""
"""Small Odysseus scoped API helper for Claude Code terminal sessions."""
from __future__ import annotations
@@ -44,7 +44,7 @@ def _config() -> tuple[str, str] | None:
if not token:
missing.append("ODYSSEUS_API_TOKEN")
if missing:
print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr)
print(f"missing {', '.join(missing)}; create a Claude Agent token in Odysseus Settings", file=sys.stderr)
return None
return base_url, token
@@ -100,6 +100,14 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex
- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`.
- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`.
## Chat history
- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope.
```bash
python3 integrations/codex/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID
```
## Email draft + send
- Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
+2 -2
View File
@@ -4,5 +4,5 @@
# Direct universal wheels avoid index resolution through the old trust store.
pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl \
--hash=sha256:fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70
certifi @ https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl \
--hash=sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b
certifi @ https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl \
--hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1
+3 -1
View File
@@ -4,7 +4,7 @@ python-multipart
python-dotenv
httpx
# HTTPS clients using certifi must distrust the compromised eMudhra CA paths.
certifi>=2026.10.19
certifi>=2026.10.23
httpcore>=1.0,<2.0
pydantic>=2.13.4
pydantic-settings>=2.14.1
@@ -23,6 +23,8 @@ numpy
# chromadb-client is the lightweight HTTP client (talks to a standalone
# ChromaDB service); fastembed runs local ONNX embeddings.
chromadb-client
# gRPC vendors independent roots; 1.84.1 removes the incident trust anchors.
grpcio>=1.84.1
fastembed
youtube-transcript-api
yt-dlp
+35
View File
@@ -35,6 +35,7 @@ CALENDAR_READ_SCOPES = {"calendar:read", "calendar:write"}
CALENDAR_WRITE_SCOPES = {"calendar:write"}
DOCS_READ_SCOPES = {"documents:read", "documents:write"}
DOCS_WRITE_SCOPES = {"documents:write"}
CHAT_READ_SCOPES = {"chat"}
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
@@ -161,6 +162,7 @@ def setup_codex_routes(
memory_router: APIRouter | None = None,
calendar_router: APIRouter | None = None,
document_router: APIRouter | None = None,
history_router: APIRouter | None = None,
) -> APIRouter:
router = APIRouter(prefix="/api/codex", tags=["codex"])
email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list")
@@ -174,6 +176,7 @@ def setup_codex_routes(
documents_library_endpoint = _find_endpoint(document_router, "GET", "/api/documents/library")
documents_get_endpoint = _find_endpoint(document_router, "GET", "/api/document/{doc_id}")
documents_create_endpoint = _find_endpoint(document_router, "POST", "/api/document")
chat_history_endpoint = _find_endpoint(history_router, "GET", "/api/history/{session_id}")
@router.get("/capabilities")
def capabilities(request: Request):
@@ -219,6 +222,11 @@ def setup_codex_routes(
"launch": scoped(COOKBOOK_LAUNCH_SCOPES),
"actions": ["tasks", "servers", "output", "serve", "stop"],
},
"chat": {
"read": scoped(CHAT_READ_SCOPES),
"actions": ["read"],
"available": chat_history_endpoint is not None,
},
},
"safety": {
"email_send_requires_confirmation": True,
@@ -226,6 +234,33 @@ def setup_codex_routes(
},
}
@router.get("/chat/{session_id}")
async def read_chat(
request: Request,
session_id: str,
offset: int = 0,
limit: int = 100,
):
_scope_owner(request, CHAT_READ_SCOPES)
if chat_history_endpoint is None:
raise HTTPException(503, "chat history unavailable")
offset, limit = _clamp_pagination(
offset,
limit,
default_limit=100,
max_limit=100,
)
result = await chat_history_endpoint(request, session_id, limit, offset)
return {
"session_id": session_id,
"name": result["name"],
"model": result["model"],
"messages": result["history"],
"total": result["total"],
"offset": result["offset"],
"limit": result["limit"],
}
@router.get("/plugin.zip")
def plugin_zip(request: Request):
require_authenticated_request(request)
+34
View File
@@ -47,6 +47,7 @@ from src.endpoint_resolver import (
build_headers,
)
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
from src.url_safety import check_outbound_url
logger = logging.getLogger(__name__)
@@ -2163,6 +2164,19 @@ def setup_model_routes(model_discovery):
# keep those container-local when the frontend marks them as such.
base_url = _rewrite_loopback_for_docker(base_url, container_local=_truthy(container_local))
# SSRF hardening: validate the user-supplied URL before any outbound
# request. Local-first means loopback/LAN endpoints are allowed by
# default; non-HTTP(S) schemes and the cloud metadata range are always
# rejected. Set MODELENDPOINT_BLOCK_PRIVATE_IPS=true for full lockdown.
# Note: the outbound fetches rely on httpx's default
# follow_redirects=False — a redirect target is never dereferenced.
ok, reason = check_outbound_url(
base_url,
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless"
# Auto-generate name from URL if not provided
if not name.strip():
@@ -2399,6 +2413,16 @@ def setup_model_routes(model_discovery):
from src.endpoint_resolver import resolve_url
base_url = resolve_url(base_url)
base_url = _rewrite_loopback_for_docker(base_url)
# SSRF hardening: validate the user-supplied URL before any outbound
# request. Local-first means loopback/LAN endpoints are allowed by
# default; non-HTTP(S) schemes and the cloud metadata range are always
# rejected. Set MODELENDPOINT_BLOCK_PRIVATE_IPS=true for full lockdown.
ok, reason = check_outbound_url(
base_url,
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
requested_kind = _normalize_endpoint_kind(endpoint_kind)
is_featherless = _host_match(base_url, "featherless.ai") or _safe_detect_provider(base_url) == "featherless"
if is_featherless and requested_kind == "auto":
@@ -2889,6 +2913,16 @@ def setup_model_routes(model_discovery):
_new_base = _new_base[: -len(_suffix)].rstrip("/")
_new_base = _normalize_base(_new_base)
if _new_base:
# SSRF hardening: PATCH rewrites the stored base_url, so
# it needs the same guard as POST create/test — no
# bypass through the update path. Follow-up fetches
# rely on httpx's default follow_redirects=False.
ok, reason = check_outbound_url(
_new_base,
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
ep.base_url = _new_base
else:
ep.is_enabled = not ep.is_enabled
+15 -1
View File
@@ -38,11 +38,25 @@ async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict
elif action == "add":
import uuid as _uuid
from src.url_safety import check_outbound_url
name = args.get("name", "")
base_url = args.get("base_url", "")
api_key = args.get("api_key", "")
if not base_url:
return {"error": "base_url is required", "exit_code": 1}
# SSRF hardening: validate the URL before storing it. Every entry
# point that accepts an external base_url must run this guard.
# The HTTP route (POST /api/model-endpoints) gets the same guard
# in #6316; this tool bypasses that route entirely, so it needs
# its own validation. A prompt-injection payload smuggled into a
# skill, note, fetched page, or email could instruct the model to
# register a cloud-metadata or internal-network endpoint.
ok, reason = check_outbound_url(
base_url,
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
return {"error": f"Rejected endpoint URL: {reason}", "exit_code": 1}
eid = str(_uuid.uuid4())[:8]
from datetime import datetime
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
@@ -75,7 +89,7 @@ async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_endpoints error: {e}")
return {"error": str(e), "exit_code": 1}
return {"error": "An internal error occurred while managing endpoints", "exit_code": 1}
finally:
db.close()
+10 -4
View File
@@ -425,9 +425,13 @@ class ToolApprovalStore:
# approval exists to admit an ungranted one. Seal its launch
# boundary now, in the request's workspace, the same way an
# ungranted filesystem action gets its root below (#6635).
# With no workspace bound, bash/python run in the agent
# workspace (agent_cwd), so the launch is sealed there.
roots = request_authority.resource_roots
if not roots and workspace:
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
if not roots:
from src.tool_execution import default_agent_workspace
roots = (FilesystemRoot.seal(workspace or default_agent_workspace(),
owner=_normalized_owner(owner)),)
if len(roots) == 1:
from dataclasses import replace
process_authority = replace(
@@ -455,8 +459,10 @@ class ToolApprovalStore:
if tool_name in NATIVE_FILESYSTEM_TOOLS and backend_operation is not None and isinstance(backend_operation.resource, NativeBackendResource):
try:
roots = request_authority.resource_roots if request_authority is not None else ()
if not roots and workspace and (request_authority is None or not request_authority.inherited):
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
if not roots and (request_authority is None or not request_authority.inherited):
from src.tool_execution import default_agent_workspace
roots = (FilesystemRoot.seal(workspace or default_agent_workspace(),
owner=_normalized_owner(owner)),)
resource_operation = resolve_filesystem_operation(
ExactOperation.normalize(tool_name, content), roots=roots, workspace=workspace or "",
request_id=request_authority.request_id if request_authority is not None else "")
+6
View File
@@ -1221,6 +1221,12 @@ def agent_cwd() -> str:
if vetted is None:
raise RuntimeError("active workspace is not a safe real directory")
return vetted
return default_agent_workspace()
def default_agent_workspace() -> str:
"""The agent workspace under the data dir, where subprocesses run when no
workspace is bound."""
resolved = os.path.realpath(_AGENT_WORKDIR)
if resolved not in _agent_readable_data_subdirs() or vet_workspace(resolved) != resolved:
raise RuntimeError("agent workspace is not a safe real directory")
+7 -3
View File
@@ -4,12 +4,16 @@ The public certificates embedded in `tests/test_ca_trust.py` exercise the Octobe
Sources: the [incident root list](https://emudhra.we-have-a-problem.com/roots.json) and the public Mozilla root certificates distributed in Debian's `ca-certificates` package.
`incident.json` contains the four published roots, sixteen [compromised intermediate certificates and cross-signs](https://emudhra.we-have-a-problem.com/impacted-intermediates.json), and the [twelve published server chains](https://emudhra.we-have-a-problem.com/chains.zip), retrieved on 2026-10-21. Certificates are deduplicated by SHA-256 of DER; each chain lists its leaf first. The additional cross-signed `emSign Root TLS CA - G3` issuer comes from those chains and has fingerprint `3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd`. Like the intermediates, it must not be accepted as a custom trust anchor. All data is public certificate material, with no private keys.
`incident.json` contains the four published roots, sixteen [compromised intermediate certificates and cross-signs](https://emudhra.we-have-a-problem.com/impacted-intermediates.json), and the [fourteen published server chains](https://emudhra.we-have-a-problem.com/chains.zip), retrieved on 2026-10-21 with chains refreshed on 2026-10-23. The refresh adds the two DV G3B hosts without Certificate Transparency entries and preserves the original twelve chains. Certificates are deduplicated by SHA-256 of DER; each chain lists its leaf first. The additional cross-signed `emSign Root TLS CA - G3` issuer comes from those chains and has fingerprint `3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd`. Like the intermediates, it must not be accepted as a custom trust anchor. All data is public certificate material, with no private keys.
Downloaded source SHA-256 values:
- `roots.json`: `119f32cd81a6b5a4e3c4c2df5fe07daa90dc43cafa123a62f0394f1895d92380`
- `impacted-intermediates.json`: `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`
- `chains.zip`: `8a2edc81b6c51e3ad27d45ca1e441ec84c961e2dee0fc547f2a02c0f74315f95`
- `chains.zip`: `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-21T12:00:00Z` (`1792584000`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All twelve chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores two paths, 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 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
View File
@@ -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-----
+12 -1
View File
@@ -1,5 +1,5 @@
{
"verification_time": 1792584000,
"verification_time": 1792756800,
"roots": {
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
@@ -26,6 +26,7 @@
},
"certificates": {
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1\nMDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFy\npDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpV\nbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxg\nyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNd\nAEKwaJ0mVzScy2+6\n-----END CERTIFICATE-----\n",
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcigAwIBAgIUG01Z/jOotSvB49K4K27kFjpdnWMwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMjU3MzFaFw0yNzA1MDUxMzAyMzFa\nMDQxMjAwBgNVBAMMKWR2LWczYi1ub2N0LWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI805yWr+RxanILifET+/\nGwHsXo7dp8ghA3OgwR0Mg11Bl6iYyOzldy4iU7kM+3qfDNsUrzxvkUkJZQpYieuC\n+aOBrDCBqTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATA0BgNVHREELTArgilkdi1nM2Itbm9jdC1lbXVkaHJhLndlLWhh\ndmUtYS1wcm9ibGVtLmNvbTAdBgNVHQ4EFgQUOlRrLQGIczrmr4H+Oak06aUmJ4Yw\nHwYDVR0jBBgwFoAU4h4v5xwZOQ5VS2LBICRXNSSKJRQwCgYIKoZIzj0EAwIDZwAw\nZAIwf4ekmRgXAxXP2AjpVY2f1bO42GC7Nnc80t4OpaWJeVdXpT+X3dOsLRwlIUm0\nYbMwAjBihTx7oHfaYB3KRnFuhKqlHYmUcOe9Hjxqs6bubZohhrqMQzPocyasDhJL\nUx3HsgQ=\n-----END CERTIFICATE-----\n",
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0": "-----BEGIN CERTIFICATE-----\nMIICPzCCAcagAwIBAgIUH0gWfBRnOiSjcduti/cukjDGAjIwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDJaFw0yNzA1MDMwMjI3NDJa\nMDMxMTAvBgNVBAMMKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3EfjVE+6fUm9gr7UoUv6N\nYPttIdILR9OMZnAdTj+RWT1Z4fp7dpC5mat1gbIo4J6EDelcOQQz5/oGeB3gFJKw\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFKzQPp6gTBOWb0TBxZbkL5zsQn3oMB8G\nA1UdIwQYMBaAFJvYZVGv5+csN60I7mkZ2b83hK4KMAoGCCqGSM49BAMCA2cAMGQC\nMARKrRRBEWY2Ix1d80DGUvC7bWENP1Q9pf6BOGF4+84UiwV5+nAuht/+hdpAanWB\nsQIwJF2dW8tpPN6lTm1kDerzG6yJAJOz4drotaTthMF2+efuv5W2FL7Dr8qZSd1z\n201o\n-----END CERTIFICATE-----\n",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "-----BEGIN CERTIFICATE-----\nMIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw\nCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy\nYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290\nIENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD\nVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg\nMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m\nWO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw\nLKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj\nQjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB\nBQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB\nFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi\nBggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290\nQ0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t\nP1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB\nATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg\nTroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0\n1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV\n-----END CERTIFICATE-----\n",
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5": "-----BEGIN CERTIFICATE-----\nMIICODCCAb+gAwIBAgIUbWvtwSNK/Qro223vAqyz323+VBAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM2WhcNMjcwNTAzMDEwNDM2\nWjAvMS0wKwYDVQQDDCRkdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAScdBQpNUOOCcag6YEVFccNRSFe\nUIl02iH53vDfoWPJFS005WzqF3CPDclB/kygP2gWF4pg/r5gPYdnI3/6Vdzro4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGR2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUBj2yGO/TzDOE/u/6QeUvcW8y1iMwHwYDVR0jBBgw\nFoAUrn4QXDwplsGoRycFWfHgPdTlCvMwCgYIKoZIzj0EAwIDZwAwZAIwBQOOQqG9\nccdzM/7XIlXLtota1esALaLmgEN/ZweosBIvk4xabHcERZInWVIwO45NAjBos8Ue\nNwDe1X3ahdK2lkxqBEoBMtUHJN18TnJKs2nK1gmXpMbmY5oKnAQQgCEZPA4=\n-----END CERTIFICATE-----\n",
@@ -34,6 +35,7 @@
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUz\nOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11\nZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49\nAgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+\nbSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3Vm\nxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9\ni1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAU\nBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAq\nMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysG\nAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc\n9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/f\nAal1GNxsOutLnr0Y\n-----END CERTIFICATE-----\n",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO\nL4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2\nPM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE\nwqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM\nQkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ\n7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl\nbN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V\nGUyRCEIDhF5TBUuKF4E3kf1h\n-----END CERTIFICATE-----\n",
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0\nMFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRo\ncmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEG\nBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9\nO8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3Qb\nX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUN\nq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggr\nBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEW\nHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgG\nBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw\nLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1z\naWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4Yl\naHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8E\nBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg\n8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBg\neuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=\n-----END CERTIFICATE-----\n",
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db": "-----BEGIN CERTIFICATE-----\nMIICSTCCAdCgAwIBAgIUO7oRaIOhMWD0jFgvxusAGNi5CI0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMzE0NDhaFw0yNzA1MDUxMzE5NDha\nMDgxNjA0BgNVBAMMLWR2LWczYi1hYWEtbm9jdC1lbXVkaHJhLndlLWhhdmUtYS1w\ncm9ibGVtLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ0yLMf28vw2E6pO\nc74doinU47pwTYD6xZ60nkrGkqI05DxVKXblos+V5S0+bgDjT2qw1F39EW9EUj8b\nNQmmqv6jgbAwga0wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0l\nBAwwCgYIKwYBBQUHAwEwOAYDVR0RBDEwL4ItZHYtZzNiLWFhYS1ub2N0LWVtdWRo\ncmEud2UtaGF2ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBTruPPaZ1PiEFfpqdLo\n8Ra5Ah5a2jAfBgNVHSMEGDAWgBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjO\nPQQDAgNnADBkAjBUTUTymJylZiZRV/RqMayZdUPJKRDIcn9+sUPpG66AjVgx8M+h\nDGzxxgeroOSxzSwCMBM/r1y7g371RI10nDU16T0WEAO/Udqyyuc8mJ2LCQ+Gxv3c\njU2NXGQLsvosv6T0ww==\n-----END CERTIFICATE-----\n",
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIRANj4aF5PK9nYJAKoWhwetzEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQl26haZNOX2gsQUunqVG5Ie+/qnJ2daKDF6tz+73Wmm7XW/AH0K/vKbrQL\nzWTAdn42/9Bdl6gTxaoMbJUw55z18IZkaPE4BkIKpchRhAseJSVA55DktlTDhS7W\nSvHoZX2jggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUPtwg2YlGmId7hz/YgUeyXBXYRwYwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAJNFuAFW9Gnw/8+kCO4KIlZdnM1Sh2u+8cK0EYAHhXpb5FlGvYkcARigo5oS7VoM\n7FOYlTueV6yICZKNAYawZPJPhNCGK+rp3cQBEz6d0n8OZEYZov9lb3ll8ORgSDuf\nFVIW6Gagg/UnOamLBTOR8eD5V73SCme/B2cHUDw+m5E8cc+0Ujzz3Cn3ex5JrhXk\n2T3s8PoVzEmhTgGboFfcWO1EYhPPrYwdxPYRQIXAXUXhLEOVNL9ASbZJLFI8E1IM\nm7NwxVN2s38jWpaCUaaF0tg7hfiy7oe13rzJfHsdqldJVTTUTPWVx8TC4ju0VLet\nbAlwhwFT6AP8uIVMGUHNw24=\n-----END CERTIFICATE-----\n",
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcegAwIBAgIULuWnWW6kFV6QZxp2ls8r18NCjXkwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb7+5g2YHNYwH2eQERrZc\nTmVuf/RmvzsGGMSwXmZke6vn35Ut5HME7UOOU3Muy+BSfjfpo6fjL5KOs3Ndz67d\nOKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBQ7oBzq3jpTuvntbIkzMV12Ps3O0TAf\nBgNVHSMEGDAWgBQ06uoM68kK2vnQxeLtm3jJh54bbDAKBggqhkjOPQQDAgNoADBl\nAjEA/Y7AZ4M2MYbN5Od9tREaUfFjZKoOjczfIKPjKNMjtGxTdL9WUXvAT4QDgktn\n/IJdAjAbORTkAXD5hggyG6K7p0MBz3lFZJb2l/LndZlrttJWBLP8QGrc44hrZZtK\nIq0d1dE=\n-----END CERTIFICATE-----\n",
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAOlCI73pL5FOWrmsq/2sNxAwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwy\nKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEv\nTPhkbQ2jggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQICMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJuMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQALRoOSvkzRa+tN5Rje9W5GiXd/qsicd9N6Gnh0SWC7VyWH+HycIqAnFCMYZznj\nCeGusomFYHE42SHWr85JDIbgzy6y3dO4i9n9w0Bx7yrXWBbDzFS8VgA3+EY4Cg7y\nmtoC3RPOJVtaXXPZ+68X804g1Mj+3vCSHj2lNPVgco0OzSIOJ/KMg1R9MLBkiKzb\nbERAGZHejY0HBhYd76IINZN8z6bBNah2UdFYxsOisLynPQUkol6gRLum024/FzVl\nCCDfnUusrkr+mAfPAvD/Vb8bhpA7jYtepuU+2mIdShj5lLlvAwZphn+ypWycuo2x\n0k9mldz2dqI9nhCiY+TTIOOR\n-----END CERTIFICATE-----\n",
@@ -64,11 +66,20 @@
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
],
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com": [
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
],
"dv-g3b-emudhra.we-have-a-problem.com": [
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7",
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"dv-g3b-noct-emudhra.we-have-a-problem.com": [
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70",
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"dv-g3c-aaa-emudhra.we-have-a-problem.com": [
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52",
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72"
+4 -2
View File
@@ -132,7 +132,7 @@ class CATrustTests(unittest.TestCase):
custom = directory / "custom.pem"
fingerprint = next(iter(INCIDENT["intermediates"]))
custom.write_text(clean.read_text() + INCIDENT["certificates"][fingerprint])
self.assertEqual(len(INCIDENT["chains"]), 12)
self.assertEqual(len(INCIDENT["chains"]), 14)
custom_accepted = []
for hostname, chain in INCIDENT["chains"].items():
leaf, intermediates = directory / "leaf.pem", directory / "chain.pem"
@@ -156,9 +156,11 @@ class CATrustTests(unittest.TestCase):
self.assertIn("unable to get local issuer certificate", negative.stderr)
if verify(custom).returncode == 0:
custom_accepted.append(hostname)
self.assertEqual(custom_accepted, [
self.assertCountEqual(custom_accepted, [
"dv-g3b-aaa-emudhra.we-have-a-problem.com",
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com",
"dv-g3b-emudhra.we-have-a-problem.com",
"dv-g3b-noct-emudhra.we-have-a-problem.com",
])
checker.check_bundle(clean)
for bundle in (affected, custom):
+111
View File
@@ -0,0 +1,111 @@
"""Agent chat history uses the same scope as existing chat and history routes."""
import pytest
from fastapi import APIRouter, HTTPException
from starlette.requests import Request
from routes import codex_routes
def _endpoint(router, method: str, path: str):
for route in router.routes:
if route.path == path and method in route.methods:
return route.endpoint
raise AssertionError(f"{method} {path} route not found")
def _request(scopes: list[str]) -> Request:
request = Request({
"type": "http",
"method": "GET",
"path": "/api/codex/chat/session-test",
"headers": [],
"state": {},
})
request.state.current_user = "api"
request.state.api_token = True
request.state.api_token_owner = "alice"
request.state.api_token_scopes = scopes
return request
def _history_router(calls: list[tuple]):
router = APIRouter()
@router.get("/api/history/{session_id}")
async def history(
request: Request,
session_id: str,
limit: int | None = None,
offset: int | None = None,
):
calls.append((request, session_id, limit, offset))
return {
"name": "Model debugging",
"model": "org/model",
"history": [{"role": "assistant", "content": "second"}],
"total": 3,
"offset": offset,
"limit": limit,
"endpoint_url": "http://private-endpoint",
}
return router
@pytest.mark.asyncio
async def test_history_read_uses_existing_paged_handler_without_exposing_endpoint_url():
calls = []
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
request = _request(["chat"])
result = await read_chat(request, "session-test", offset=1, limit=1)
assert calls == [(request, "session-test", 1, 1)]
assert result == {
"session_id": "session-test",
"name": "Model debugging",
"model": "org/model",
"messages": [{"role": "assistant", "content": "second"}],
"total": 3,
"offset": 1,
"limit": 1,
}
@pytest.mark.asyncio
async def test_token_without_chat_scope_cannot_read_history_or_reach_handler():
calls = []
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
with pytest.raises(HTTPException) as exc_info:
await read_chat(_request(["todos:read"]), "session-test")
assert exc_info.value.status_code == 403
assert calls == []
@pytest.mark.asyncio
async def test_history_read_uses_existing_page_limit():
calls = []
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
await read_chat(_request(["chat"]), "session-test", limit=1000)
assert calls[0][2:] == (100, 0)
def test_capabilities_report_history_permission_from_chat_scope():
router = codex_routes.setup_codex_routes(history_router=_history_router([]))
capabilities = _endpoint(router, "GET", "/api/codex/capabilities")
without_chat = capabilities(_request(["todos:read"]))["tools"]["chat"]
reader = capabilities(_request(["chat"]))["tools"]["chat"]
assert without_chat["read"] is False
assert reader["read"] is True
assert reader["available"] is True
assert reader["actions"] == ["read"]
@@ -79,3 +79,69 @@ async def test_inherited_authority_still_cannot_widen_to_ungranted_bash(tmp_path
assert pending.process_operation is None
_, result = await _dispatch(child, "bash", "printf approved-run", exact)
assert result.get("blocked") is True
@pytest.fixture
def agent_workspace():
import os
from src.constants import AGENT_WORKSPACE_DIR
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
return os.path.realpath(AGENT_WORKSPACE_DIR)
def test_approval_without_a_workspace_seals_a_launch_in_the_agent_workspace(agent_workspace):
# Agent mode with no folder picked is the default. bash already runs in the
# agent workspace there (agent_cwd), so that is where its approval seals.
authority = _authority("", "get_workspace")
assert authority.resource_roots == ()
pending, _ = _approve(authority, "bash", "pwd")
bound = pending.process_operation
assert bound is not None
assert bound.launch is not None
assert bound.launch.scope.root.path == agent_workspace
async def test_approved_bash_without_a_workspace_runs_in_the_agent_workspace(agent_workspace):
authority = _authority("", "get_workspace")
_, exact = _approve(authority, "bash", "pwd")
description, result = await _dispatch(authority, "bash", "pwd", exact)
assert result.get("failure_kind") != "resource_identity_denied", result
assert result.get("exit_code") == 0, result
containment = result.get("containment") or {}
assert containment.get("workspace", agent_workspace) == agent_workspace, containment
async def test_inherited_authority_without_a_workspace_still_cannot_widen(agent_workspace):
from dataclasses import replace
child = replace(_authority("", "get_workspace"), inherited=True)
pending, exact = _approve(child, "bash", "pwd")
assert pending.process_operation is None
_, result = await _dispatch(child, "bash", "pwd", exact)
assert result.get("blocked") is True
async def test_approved_write_without_a_workspace_lands_in_the_agent_workspace(agent_workspace):
# write_file had the same gap: with no workspace the approval sealed no
# root, so "approve" ended in "no sealed resource identity".
import os
target = "approved-write-6635.txt"
content = f"{target}\nhello"
authority = _authority("", "get_workspace")
pending, exact = _approve(authority, "write_file", content)
assert pending.resource_operation is not None
_, result = await _dispatch(authority, "write_file", content, exact)
assert result.get("failure_kind") != "resource_identity_denied", result
written = os.path.join(agent_workspace, target)
try:
with open(written) as fh:
assert fh.read().strip() == "hello"
finally:
if os.path.exists(written):
os.remove(written)
+257
View File
@@ -0,0 +1,257 @@
"""Regression tests for SSRF hardening on the manage_endpoints agent tool.
Verifies that the do_manage_endpoints tool function rejects user-supplied URLs
that resolve to link-local (cloud metadata), non-HTTP schemes, or (when
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses -- applying
the shared outbound-URL policy in check_outbound_url(). The HTTP route
(POST /api/model-endpoints) receives the same guard in #6316.
The agent tool path bypasses the HTTP route entirely, so it needs its own
validation. A prompt-injection payload smuggled into a skill, note, fetched
page, or email could instruct the model to register a cloud-metadata or
internal-network endpoint via this tool.
"""
import ipaddress
import json
import os
import sys
import types
import pytest
import src.url_safety as _url_safety
_real_check_outbound_url = _url_safety.check_outbound_url
def _fake_resolver(host):
"""Resolver that returns deterministic IPs without real DNS."""
_HOST_MAP = {
"169.254.169.254": ["169.254.169.254"],
"metadata.google.internal": ["169.254.169.254"],
"localhost": ["127.0.0.1"],
"127.0.0.1": ["127.0.0.1"],
"0.0.0.0": ["0.0.0.0"],
"example.com": ["93.184.216.34"],
"192.168.1.1": ["192.168.1.1"],
"10.0.0.1": ["10.0.0.1"],
"api.openai.com": ["104.18.6.192"],
}
if host in _HOST_MAP:
return _HOST_MAP[host]
try:
ipaddress.ip_address(host)
return [host]
except ValueError:
pass
raise OSError(f"Name or service not known: {host}")
def _check_with_fake_resolver(url, *, block_private=False, resolver=None):
"""Call the real check_outbound_url with our fake resolver."""
return _real_check_outbound_url(url, block_private=block_private, resolver=_fake_resolver)
def _make_content(base_url, *, name="", api_key=""):
"""Build a JSON content string for the manage_endpoints 'add' action."""
return json.dumps({
"action": "add",
"name": name,
"base_url": base_url,
"api_key": api_key,
})
class _FakeQuery:
def __init__(self, model):
self._model = model
def filter(self, *args):
return self
def first(self):
return None
def all(self):
return []
class _FakeDB:
def __init__(self):
self.added = []
self.committed = False
def query(self, model):
return _FakeQuery(model)
def add(self, obj):
self.added.append(obj)
def commit(self):
self.committed = True
def close(self):
pass
class _FakeModelEndpoint:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
def _setup_mocks(monkeypatch, db):
"""Set up faked core.database and patched check_outbound_url."""
fake_core_db = types.ModuleType("core.database")
fake_core_db.SessionLocal = lambda: db
fake_core_db.ModelEndpoint = _FakeModelEndpoint
monkeypatch.setitem(sys.modules, "core.database", fake_core_db)
monkeypatch.setattr(_url_safety, "check_outbound_url", _check_with_fake_resolver)
if "src.agent_tools.admin_tools" in sys.modules:
monkeypatch.delitem(sys.modules, "src.agent_tools.admin_tools", raising=False)
def _get_do_manage_endpoints():
"""Import and return do_manage_endpoints from the reloaded admin_tools."""
from src.agent_tools.admin_tools import do_manage_endpoints
return do_manage_endpoints
@pytest.mark.asyncio
async def test_cloud_metadata_blocked(monkeypatch):
"""Cloud metadata IP (169.254.169.254) must be rejected."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://169.254.169.254/latest/meta-data/")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_non_http_scheme_blocked(monkeypatch):
"""Non-HTTP schemes (file://, gopher://) must be rejected."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("file:///etc/passwd")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_loopback_accepted_by_default(monkeypatch):
"""Loopback addresses are accepted by default (local-first design).
The agent tool uses check_outbound_url directly, which allows
loopback by default -- this is correct for local model endpoints
(Ollama, llama.cpp, etc.). Loopback is only blocked in strict mode.
"""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://localhost:11434/v1")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 0
assert "Added endpoint" in result["response"]
assert len(db.added) == 1
@pytest.mark.asyncio
async def test_strict_mode_blocks_private(monkeypatch):
"""MODELENDPOINT_BLOCK_PRIVATE_IPS=true blocks private IPs."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
monkeypatch.setenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "true")
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://192.168.1.1:8080/v1")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_safe_url_accepted(monkeypatch):
"""A safe public URL should be accepted and stored."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("https://api.openai.com/v1", name="OpenAI")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 0
assert "Added endpoint" in result["response"]
assert len(db.added) == 1
assert db.committed is True
@pytest.mark.asyncio
async def test_empty_url_rejected(monkeypatch):
"""An empty base_url must be rejected before SSRF check."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "base_url is required" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_gopher_scheme_blocked(monkeypatch):
"""gopher:// scheme must be rejected."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("gopher://evil:6379/secret")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_error_not_leaked(monkeypatch):
"""Error messages must not leak raw exception text."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://169.254.169.254/latest/meta-data/")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert "Traceback" not in result["error"]
@pytest.mark.asyncio
async def test_ipv6_loopback_accepted_by_default(monkeypatch):
"""::1 must be treated as loopback (accepted local-first), not reserved.
Proves this branch carries the dfd1f7e7 url_safety fix after rebasing
onto current dev: the pre-fix guard checked is_reserved before is_loopback
and rejected ::1 even in default local-first mode.
"""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://[::1]:11434/v1")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 0
assert "Added endpoint" in result["response"]
assert len(db.added) == 1
+296
View File
@@ -0,0 +1,296 @@
"""Regression tests for SSRF hardening on model endpoint routes.
Verifies that POST /model-endpoints/test, POST /model-endpoints, and
PATCH /model-endpoints/{ep_id} reject user-supplied URLs that resolve to
link-local (cloud metadata), non-HTTP schemes, or (when
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses — matching
the existing SSRF guard pattern in embedding_routes.py.
A stub resolver is injected so the loopback tests never touch real DNS.
"""
import os
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
# ── Resolver stub (same pattern as tests/test_url_safety.py) ────────────────
def _resolver(mapping):
"""Create a stub resolver for deterministic DNS in tests."""
def resolve(host):
if host in mapping:
return mapping[host]
raise OSError(f"unresolvable: {host}")
return resolve
LOCALHOST_V4 = _resolver({"localhost": ["127.0.0.1"]})
LOCALHOST_V6 = _resolver({"localhost": ["::1"]})
@pytest.fixture
def client(monkeypatch):
"""Build a TestClient with auth disabled and minimal DB stubs."""
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
# Stub heavy deps that model_routes imports at module level.
# Use monkeypatch so entries are removed after the test.
for mod in [
"src.tls_overrides",
"src.llm_core",
"src.settings",
"src.constants",
"core.log_safety",
]:
if mod not in sys.modules:
monkeypatch.setitem(sys.modules, mod, MagicMock())
# Provide real endpoint_resolver so the route logic works
if "src.endpoint_resolver" not in sys.modules or isinstance(
sys.modules["src.endpoint_resolver"], MagicMock
):
er = MagicMock()
er.normalize_base = lambda url: url
er.resolve_url = lambda url: url
er.build_chat_url = lambda base, **kw: base
er.build_models_url = lambda base, **kw: base + "/v1/models"
er.build_headers = lambda base, **kw: {}
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", er)
# Clear cached model_routes module so it picks up our stubs
monkeypatch.delitem(sys.modules, "routes.model_routes", raising=False)
from fastapi.testclient import TestClient
import routes.model_routes as mr
from fastapi import FastAPI
app = FastAPI()
router = mr.setup_model_routes(model_discovery=None)
app.include_router(router)
app.state.auth_enabled = False
return TestClient(app, raise_server_exceptions=False)
class TestModelEndpointSSRF:
"""SSRF protection for POST /model-endpoints/test."""
def test_cloud_metadata_blocked(self, client):
"""Cloud metadata IP (169.254.169.254) must be rejected."""
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_non_http_scheme_blocked(self, client):
"""Non-HTTP schemes (file://, ftp://) must be rejected."""
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "file:///etc/passwd"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_loopback_accepted_by_default(self, client):
"""Local-first: loopback is accepted when block_private=False."""
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://localhost:11434/v1"},
)
# Guard passes; probe fails (no server) but that is not SSRF.
assert "Rejected endpoint URL" not in str(resp.json())
def test_loopback_rejected_in_strict_mode(self, client):
"""Strict mode: loopback is rejected when block_private=True."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://localhost:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
def test_strict_mode_blocks_direct_loopback_ip(self, client):
"""Strict mode rejects 127.0.0.1 passed directly (no DNS needed)."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://127.0.0.1:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
def test_ipv6_loopback_literal_accepted_by_default(self, client):
"""::1 is loopback, not a disallowed 'reserved' address (dfd1f7e7)."""
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://[::1]:11434/v1"},
)
assert "Rejected endpoint URL" not in str(resp.json())
def test_ipv6_loopback_localhost_accepted_by_default(self, client):
"""Dual-stack hosts whose resolver answers ::1 first must work."""
with patch("src.url_safety._default_resolver", LOCALHOST_V6):
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://localhost:11434/v1"},
)
assert "Rejected endpoint URL" not in str(resp.json())
def test_ipv6_loopback_rejected_in_strict_mode(self, client):
"""Strict mode still rejects ::1."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://[::1]:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
class TestModelEndpointCreateSSRF:
"""SSRF protection for POST /model-endpoints."""
def test_cloud_metadata_blocked(self, client):
"""Cloud metadata IP must be rejected on endpoint creation."""
resp = client.post(
"/api/model-endpoints",
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_non_http_scheme_blocked(self, client):
"""Non-HTTP schemes must be rejected on endpoint creation."""
resp = client.post(
"/api/model-endpoints",
data={"base_url": "gopher://evil:6379/secret"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_loopback_accepted_by_default(self, client):
"""Local-first: loopback is accepted when block_private=False."""
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints",
data={"base_url": "http://localhost:11434/v1"},
)
# Guard passes; route may 500 on DB/probe but never 400 SSRF.
assert resp.status_code != 400
body = resp.text if resp.status_code == 500 else str(resp.json())
assert "Rejected endpoint URL" not in body
def test_loopback_rejected_in_strict_mode(self, client):
"""Strict mode: loopback is rejected when block_private=True."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints",
data={"base_url": "http://localhost:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
class TestModelEndpointPatchSSRF:
"""SSRF protection for PATCH /api/model-endpoints/{ep_id} (base_url).
The update path rewrites the stored base_url, which later fetches probe,
list models and serve chat through — it must pass the same guard as the
POST routes instead of bypassing it.
"""
def _install_fake_endpoint(self, monkeypatch, base_url="http://localhost:9999/v1"):
import routes.model_routes as mr
ep = SimpleNamespace(
id="ep1",
name="EP",
base_url=base_url,
api_key=None,
is_enabled=True,
owner=None,
hidden_models=None,
cached_models=None,
pinned_models=None,
model_type="llm",
supports_tools=None,
endpoint_kind="auto",
model_refresh_mode="auto",
model_refresh_interval=None,
model_refresh_timeout=None,
created_at=None,
updated_at=None,
)
class _FakeDb:
def query(self, _model):
return self
def filter(self, *_args, **_kwargs):
return self
def first(self):
return ep
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(mr, "SessionLocal", lambda: _FakeDb())
monkeypatch.setattr(mr, "require_admin", lambda request: None)
monkeypatch.setattr(mr, "_chatgpt_endpoint_visible", lambda ep_, request: True)
return ep
def test_patch_cloud_metadata_blocked(self, client, monkeypatch):
"""PATCH must reject the cloud metadata URL, not store it."""
ep = self._install_fake_endpoint(monkeypatch)
resp = client.patch(
"/api/model-endpoints/ep1",
json={"base_url": "http://169.254.169.254/latest/meta-data"},
)
assert resp.status_code == 400
assert "Rejected endpoint URL" in resp.json()["detail"]
assert ep.base_url == "http://localhost:9999/v1"
def test_patch_non_http_scheme_blocked(self, client, monkeypatch):
"""PATCH must reject non-HTTP schemes."""
ep = self._install_fake_endpoint(monkeypatch)
resp = client.patch(
"/api/model-endpoints/ep1",
json={"base_url": "file:///etc/passwd"},
)
assert resp.status_code == 400
assert "Rejected endpoint URL" in resp.json()["detail"]
assert ep.base_url == "http://localhost:9999/v1"
def test_patch_valid_url_accepted(self, client, monkeypatch):
"""Control: a legitimate local endpoint URL still updates fine."""
ep = self._install_fake_endpoint(monkeypatch)
resp = client.patch(
"/api/model-endpoints/ep1",
json={"base_url": "http://127.0.0.1:11434/v1"},
)
assert resp.status_code == 200
assert ep.base_url == "http://127.0.0.1:11434/v1"
+3
View File
@@ -1281,6 +1281,7 @@ def _patch_create_deps(monkeypatch, db, settings=None):
monkeypatch.setattr(model_routes, "_save_settings", lambda s: settings.update(s))
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda u: u)
monkeypatch.setattr(auth_helpers, "get_current_user", lambda req: None)
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
return settings
@@ -2000,6 +2001,7 @@ def test_explicit_proxy_test_fetches_models_with_long_timeout(monkeypatch):
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
calls = []
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
@@ -2038,6 +2040,7 @@ def test_explicit_proxy_add_fetches_and_caches_models_with_long_timeout(monkeypa
monkeypatch.setattr(model_routes, "_save_settings", lambda settings: None)
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: None)
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
calls = []
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
+79
View File
@@ -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()
+106
View File
@@ -0,0 +1,106 @@
"""A network failure must not be mistaken for rejection of an incident chain."""
import asyncio
import importlib.util
import io
import json
from pathlib import Path
import sys
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
ROOT = Path(__file__).resolve().parents[1]
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
@pytest.fixture
def validator():
repair = ModuleType("repair_trust")
repair.check_bundle = lambda path: None
spec = importlib.util.spec_from_file_location(
"searxng_validation", ROOT / "docker/searxng/validate_image.py"
)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"repair_trust": repair}):
spec.loader.exec_module(module)
module.__file__ = str(ROOT / "tests/fixtures/ca-trust/validate_image.py")
return module
class RequestFailure(Exception):
def __init__(self, code):
super().__init__(f"curl error {code}")
self.code = code
@pytest.mark.parametrize("code", [60, 6, 28, 35, None])
def test_live_probe_requires_certificate_errors_for_every_host(validator, code):
hosts = []
async def get(url, **kwargs):
if url == "https://pypi.org/simple/certifi/":
return SimpleNamespace(status_code=200)
hosts.append(url.removeprefix("https://").removesuffix("/"))
assert kwargs["allow_redirects"] is False
if code is not None:
raise RequestFailure(code)
return SimpleNamespace(status_code=200)
client = SimpleNamespace(get=get, aclose=AsyncMock())
factory = ModuleType("searx.network.client")
def new_client(**kwargs):
assert kwargs["verify"] is True
return client
factory.new_client = new_client
errors = ModuleType("curl_cffi.requests.exceptions")
errors.RequestException = RequestFailure
modules = {"searx.network.client": factory, "curl_cffi.requests.exceptions": errors}
with patch.dict(sys.modules, modules), patch.object(sys, "path", list(sys.path)):
if code == 60:
asyncio.run(validator.check_https())
assert set(hosts) == set(INCIDENT["chains"])
else:
expected = "unexpectedly accepted" if code is None else "inconclusive"
with pytest.raises(RuntimeError, match=expected):
asyncio.run(validator.check_https())
client.aclose.assert_awaited_once()
def test_offline_probe_rejects_openssl_execution_failure(validator):
result = SimpleNamespace(returncode=1, stderr="openssl: unknown option", stdout="")
with patch.dict(validator.os.environ, {"SSL_CERT_FILE": "/unused.pem", "SSL_CERT_DIR": "/unused"}), \
patch.object(validator.subprocess, "run", return_value=result):
with pytest.raises(RuntimeError, match="not a certificate trust rejection"):
validator.check_chains()
@pytest.mark.parametrize("results", [[{"title": "ordinary result"}], []])
def test_running_probe_checks_existing_service_without_repair_or_second_boot(validator, tmp_path, results):
certificate = tmp_path / "01234567.0"
certificate.write_text("certificate double")
(tmp_path / "unrelated").write_text("ignored")
health = io.BytesIO(b"OK")
health.status = 200
search = io.BytesIO(json.dumps({"results": results}).encode())
with patch.dict(validator.os.environ, {"SSL_CERT_DIR": str(tmp_path), "GRANIAN_PORT": "8080"}), \
patch.object(validator.urllib.request, "build_opener") as opener, \
patch.object(validator, "check_chains") as chains, \
patch.object(validator, "check_bundle") as bundle, \
patch.object(validator, "check_https", new_callable=AsyncMock) as https, \
patch.object(validator.subprocess, "Popen") as boot:
opener.return_value.open.side_effect = [health, search]
if results:
validator.check_running(live=True)
else:
with pytest.raises(RuntimeError, match="no results"):
validator.check_running(live=True)
assert opener.return_value.open.call_args_list[0].args == ("http://127.0.0.1:8080/healthz",)
chains.assert_called_once_with()
bundle.assert_called_once_with(certificate)
https.assert_awaited_once()
boot.assert_not_called()
+148
View File
@@ -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"]