Compare commits

..
Author SHA1 Message Date
dependabot[bot] 66ffd8897d chore(deps): bump the actions group across 1 directory with 12 updates
Bumps the actions group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-python](https://github.com/actions/setup-python) | `7.0.0` | `7.1.0` |
| [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `10.2.0` | `10.3.1` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.7` | `4.39.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.7` | `4.39.1` |
| [hadolint/hadolint-action](https://github.com/hadolint/hadolint-action) | `3.4.0` | `3.5.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.3.0` | `4.4.1` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.3.0` | `7.4.0` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.37.7` | `4.39.1` |
| [actions/deploy-pages](https://github.com/actions/deploy-pages) | `5.0.0` | `5.0.1` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` |



Updates `actions/setup-python` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)

Updates `actions/setup-node` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

Updates `astral-sh/setup-uv` from 10.2.0 to 10.3.1
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/c18668ad3cf93ea998bef934396af7bb5c839dc7...18d369b23da25a81e9458561248e1aafff6a70fa)

Updates `github/codeql-action/init` from 4.37.7 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.37.7 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `hadolint/hadolint-action` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/hadolint/hadolint-action/releases)
- [Commits](https://github.com/hadolint/hadolint-action/compare/2a66e89f53d0771bb131a7fa31f3136336094aa6...06be81baf89a55ffd0e24b8f04a4185738dd3387)

Updates `docker/setup-buildx-action` from 4.3.0 to 4.4.1
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e...f87e5991a6d7451dcb8d9637bfbc97413f497069)

Updates `docker/build-push-action` from 7.3.0 to 7.4.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a...c3c9e263c25d99ce0380d002d59b67737d91b0dc)

Updates `github/codeql-action/upload-sarif` from 4.37.7 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `actions/deploy-pages` from 5.0.0 to 5.0.1
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/cd2ce8fcbc39b97be8ca5fce6e763baed58fa128...368f82528645a54fb793d4d04e342629a3f51346)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: hadolint/hadolint-action
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/deploy-pages
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-24 15:33:24 +00:00
RaresKeYandLéo ed78beee1b fix(security): refresh client trust and adopt corrected upstream SearXNG (#6791)
Select the official immutable SearXNG release with corrected system and certifi stores in all Compose deployments. Retain native offline, actual-entrypoint and Compose/live regression checks while removing the custom trust-repair image and registry publication path. Reuse the canonical CA inventory checker.

Include released Python, certifi, grpcio, uv and Docker CLI trust updates and strict fourteen-chain validation. Keep unreleased CPython verifier and native-helper fixes separate from claims about shipped artifacts.

Part of #6784 and #6790.

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-10-24 17:26:21 +02: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
44 changed files with 1533 additions and 80 deletions
+56
View File
@@ -0,0 +1,56 @@
#!/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/scripts/check_ca_trust.py" \
"$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:?}
+6 -6
View File
@@ -76,7 +76,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.11"
# Byte-compile sources — catches syntax errors without installing deps.
@@ -91,7 +91,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "22.23.4"
- run: npm ci --ignore-scripts --no-audit --no-fund
@@ -151,7 +151,7 @@ jobs:
echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
python-version: "3.11"
@@ -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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
- uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1
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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
persist-credentials: false
- name: Lint Dockerfile
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: false
@@ -107,12 +107,12 @@ jobs:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
driver: docker
- name: Build image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: trivy-results.sarif
category: trivy-image
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.12'
+1 -1
View File
@@ -47,4 +47,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- 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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
+79
View File
@@ -0,0 +1,79 @@
name: ci / SearXNG trust
on:
pull_request:
paths:
- 'docker/searxng/**'
- 'scripts/check_ca_trust.py'
- '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]
paths:
- 'docker/searxng/**'
- 'scripts/check_ca_trust.py'
- '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:
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
- name: Select the immutable upstream image from Compose
id: image
run: |
docker compose --env-file /dev/null -f docker-compose.yml config --format json |
python3 -c 'import json, re, sys; image = json.load(sys.stdin)["services"]["searxng"]["image"]; re.fullmatch(r"docker\.io/searxng/searxng:[0-9]{4}\.[^@\s]+@sha256:[0-9a-f]{64}", image) or sys.exit("Invalid SearXNG pin"); print("image=" + image)' >> "$GITHUB_OUTPUT"
- name: Pull the upstream image without registry credentials
env:
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
run: docker pull "$SEARXNG_TEST_IMAGE"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Check unmodified upstream trust and actual entrypoint offline
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: docker/searxng/Dockerfile
target: validation
build-args: SEARXNG_IMAGE=${{ steps.image.outputs.image }}
network: none
platforms: ${{ matrix.platform }}
no-cache-filters: validation
- name: Validate the selected upstream runtime through Compose and live HTTPS
env:
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
run: bash .github/scripts/validate-searxng-image.sh "$SEARXNG_TEST_IMAGE"
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.12'
+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())
+2 -2
View File
@@ -145,8 +145,8 @@ services:
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
# Update all default/GPU pins together after native startup and trust checks.
image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8
entrypoint:
- /bin/sh
- -c
+2 -2
View File
@@ -148,8 +148,8 @@ services:
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
# Update all default/GPU pins together after native startup and trust checks.
image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8
entrypoint:
- /bin/sh
- -c
+2 -2
View File
@@ -126,8 +126,8 @@ services:
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
# Update all default/GPU pins together after native startup and trust checks.
image: docker.io/searxng/searxng:2026.10.24-ef0ec03da@sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8
entrypoint:
- /bin/sh
- -c
+9
View File
@@ -0,0 +1,9 @@
# Validation only. Deploy the unmodified upstream image selected by Compose.
ARG SEARXNG_IMAGE
FROM ${SEARXNG_IMAGE} AS validation
COPY scripts/check_ca_trust.py 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
FROM validation AS live-validation
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --live
+10
View File
@@ -0,0 +1,10 @@
**
!scripts/
!scripts/check_ca_trust.py
!docker/
!docker/searxng/
!docker/searxng/**
!tests/
!tests/fixtures/
!tests/fixtures/ca-trust/
!tests/fixtures/ca-trust/incident.json
+162
View File
@@ -0,0 +1,162 @@
"""Verify incident distrust and startup in the unmodified upstream 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 check_ca_trust import check_bundle
def check_chains():
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]]
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 = [(selection, False) for selection in 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}")
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__":
live = "--live" in sys.argv
if "--running" in sys.argv:
check_running(live=live)
else:
check_chains()
check_boot(live=live)
+39
View File
@@ -0,0 +1,39 @@
# Upstream SearXNG trust validation
SearXNG owns independent system and Python certificate stores. Updating Odysseus's Python environment does not update this sidecar. The base and both standalone GPU Compose files now select the official `docker.io/searxng/searxng:2026.10.24-ef0ec03da` image at immutable multiarch digest `sha256:7c7cbe509429e82755104b2a95f52085a98633c95d53f44e4e905fa3a59f08c8`. Its system and certifi stores remove the affected eMudhra trust. The [upstream certifi update](https://github.com/searxng/searxng/pull/6881) is included in this release.
Deploy the upstream image directly. The validation Dockerfile adds public fixtures and diagnostics only; no repair layer, custom runtime image or project registry publisher is required. `scripts/check_ca_trust.py` owns the known-certificate inventory for both the application and SearXNG checks.
## Validate a pin update
Run from the repository root on a disposable Docker host. The helper uses a fresh configuration volume, omits published ports and does not load the operator's `.env`. It retains the base Compose settings wrapper, healthcheck and capabilities. Outbound network access and working search engines are required for the live checks.
```sh
image=$(docker compose --env-file /dev/null -f docker-compose.yml config --format json |
python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["searxng"]["image"])')
# Offline build-step checks, including actual entrypoint and loopback health.
# Pulling the selected base still requires registry access.
docker build --network=none --target validation \
--build-arg "SEARXNG_IMAGE=$image" -f docker/searxng/Dockerfile .
# Exercise the exact unmodified deployment image after an anonymous pull.
docker pull "$image"
bash .github/scripts/validate-searxng-image.sh "$image"
```
The offline checks require all fourteen incident chains to fail certificate verification through the system PEM bundle, hashed CA directory and certifi. The affected positive-control roots must accept those same chains. Each fixture's DER fingerprint is verified first, and a fixed verification timestamp prevents expiry from hiding a broken mitigation. Checks run again after the real upstream root entrypoint regenerates trust.
The live checks use SearXNG's actual curl client factory. Ordinary verified HTTPS must succeed, all fourteen incident hosts must fail with curl certificate error 60, and a JSON search must return results. DNS failures, timeouts and generic handshake failures are inconclusive and fail validation. The checks inspect the already-running service without changing its trust stores.
The `ci / SearXNG trust` workflow runs these offline and Compose/live checks on native amd64 and arm64 runners. It selects the same immutable image as base Compose, pulls without registry credentials, and has read-only repository permissions. It neither publishes a replacement image nor requires package-write credentials. Update all three deployment pins together; the alignment regression guards the standalone GPU files.
## Rollout and scope
Changing a Compose pin does not replace running containers. After updating the checkout, run `docker compose pull searxng` and `docker compose up -d --no-deps searxng`, then inspect health, retained settings and actual Odysseus search integration. Use the appropriate standalone GPU Compose file when that is the deployment being managed.
Operator-mounted trust, custom engines, persisted packages and independently managed clients need separate effective-client checks. An amd64 result does not establish arm64 behavior. The fixture-based check detects known certificate fingerprints; it does not model every native revocation mechanism or arbitrary reissued certificates sharing a compromised key. See [outbound TLS trust](tls-trust.md) for the application, bootstrap, Chromium and other runtime boundaries.
## Public fixture provenance
The public CA inventory comes from the incident's [roots](https://emudhra.we-have-a-problem.com/roots.json) and [intermediates](https://emudhra.we-have-a-problem.com/impacted-intermediates.json). The additional cross-signed issuer and fourteen chain fixtures come from [chains.zip](https://emudhra.we-have-a-problem.com/chains.zip), SHA-256 `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`. Fixtures contain public certificates only. See [the fixture notes](../tests/fixtures/ca-trust/README.md).
+13 -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,12 @@ 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 [upstream-image validation and rollout instructions](searxng-trust-image.md); updating these application packages does not repair an existing sidecar. Host daemons, native browser policy, optional compiled clients and independently managed integrations require their own effective-client checks.
CPython's [intermediate-key verifier block](https://github.com/python/cpython/pull/160042) and its [3.14 backport](https://github.com/python/cpython/pull/160569) merged on October 24. They are distinct from Python 3.14.9's bundled-pip update. No released 3.14 interpreter containing that verifier change was identified when this image pin was selected; retain the corrected effective trust stores and track the subsequent security release. hf-xet's [trust fix](https://github.com/huggingface/xet-core/pull/1038) is also merged but not included in the latest published 1.7.0 release at that check. Agent-browser 0.39.2 and primp 2.0.1 are not established incident fixes for their independent native trust paths. A version bump alone does not establish that the active client uses corrected trust.
@@ -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.
+34 -16
View File
@@ -6,15 +6,15 @@
"": {
"name": "odysseus",
"devDependencies": {
"@antithesishq/bombadil": "^0.7.9",
"@playwright/test": "^1.64.1",
"@antithesishq/bombadil": "^0.7.0",
"@playwright/test": "^1.62.1",
"acorn": "8.19.0"
}
},
"node_modules/@antithesishq/bombadil": {
"version": "0.7.9",
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.9.tgz",
"integrity": "sha512-UkqScZka+C1392bU3cxivUo5tYM9VpFFe7fSRptfHEa0lH/i2T8RqC8nFCqSWeeDT6vpsSfjTXvxCXh+aFswWg==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.0.tgz",
"integrity": "sha512-alJmnphJ/iUoL5mCsnV3DwtajGy/sEQ3NJJCiMhgjqXshSq2BUtAs0vqdXEiiSkB8HbsOX5CLrAcaogYdwfAJg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -22,13 +22,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -50,26 +50,44 @@
"node": ">=0.4.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+2 -2
View File
@@ -12,8 +12,8 @@
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.7.9",
"@playwright/test": "^1.64.1",
"@antithesishq/bombadil": "^0.7.0",
"@playwright/test": "^1.62.1",
"acorn": "8.19.0"
}
}
+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")
+5 -3
View File
@@ -4,12 +4,14 @@ 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 upstream SearXNG image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
+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"]
+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():
checker = ModuleType("check_ca_trust")
checker.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, {"check_ca_trust": checker}):
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()
+15 -10
View File
@@ -10,17 +10,22 @@ startup. This guards that the pin stays in place.
import re
from pathlib import Path
COMPOSE = Path(__file__).resolve().parent.parent / "docker-compose.yml"
import yaml
ROOT = Path(__file__).resolve().parent.parent
COMPOSE = ROOT / "docker-compose.yml"
def test_searxng_image_is_pinned_not_latest():
text = COMPOSE.read_text(encoding="utf-8")
m = re.search(r"image:\s*\S*searxng/searxng:(\S+)", text)
assert m, "searxng image line not found in docker-compose.yml"
tag = m.group(1)
assert tag != "latest", (
"SearXNG must be pinned, not ':latest' — odysseus startup depends on its "
"healthcheck, so a broken upstream latest tag blocks the app (issue #1414)"
)
# A real version tag (date-based, e.g. 2026.5.31-7159b8aed), not a moving ref.
assert re.match(r"\d{4}\.\d", tag), f"expected a versioned tag, got {tag!r}"
image = yaml.safe_load(text)["services"]["searxng"]["image"]
assert re.fullmatch(
r"docker\.io/searxng/searxng:\d{4}\.\d[^\s@]*@sha256:[0-9a-f]{64}", image
), f"SearXNG must use a versioned immutable upstream image: {image!r}"
def test_all_standalone_compose_files_select_the_same_searxng_image():
expected = yaml.safe_load(COMPOSE.read_text())["services"]["searxng"]["image"]
for name in ("docker-compose.gpu-amd.yml", "docker-compose.gpu-nvidia.yml"):
actual = yaml.safe_load((ROOT / name).read_text())["services"]["searxng"]["image"]
assert actual == expected, f"{name} selects a different SearXNG image"
+73
View File
@@ -0,0 +1,73 @@
"""Native checks must validate the selected runtime without registry credentials."""
import json
import os
from pathlib import Path
import subprocess
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[1]
def test_ci_checks_the_pinned_upstream_image_on_both_native_architectures():
workflow = yaml.safe_load((ROOT / ".github/workflows/searxng-image.yml").read_text())
events = workflow.get("on", workflow.get(True))
assert "pull_request_target" not in events
assert workflow["permissions"] == {"contents": "read"}
assert set(workflow["jobs"]) == {"validate"}
job = workflow["jobs"]["validate"]
matrix = job["strategy"]["matrix"]["include"]
assert {(row["arch"], row["runner"]) for row in matrix} == {
("amd64", "ubuntu-latest"), ("arm64", "ubuntu-24.04-arm")
}
steps = job["steps"]
assert not any("login-action" in step.get("uses", "") for step in steps)
assert all(step.get("with", {}).get("persist-credentials") is False
for step in steps if "actions/checkout@" in step.get("uses", ""))
offline = next(step for step in steps if "docker/build-push-action@" in step.get("uses", ""))
assert offline["with"]["network"] == "none"
assert offline["with"]["build-args"] == "SEARXNG_IMAGE=${{ steps.image.outputs.image }}"
live = next(step for step in steps if "validate-searxng-image.sh" in step.get("run", ""))
assert live["env"]["SEARXNG_TEST_IMAGE"] == "${{ steps.image.outputs.image }}"
@pytest.mark.parametrize("failure,expected", [("none", 0), ("exec", 7), ("up", 1), ("existing-volume", 1)])
def test_compose_probe_owns_only_its_temporary_resources(tmp_path, failure, expected):
"""Exercise the real helper with a Docker double, including failure cleanup."""
binary = tmp_path / "docker"
calls = tmp_path / "calls.jsonl"
binary.write_text('''#!/usr/bin/env python3
import json, os, sys
args = sys.argv[1:]
with open(os.environ["DOCKER_CALLS"], "a") as log:
log.write(json.dumps({"args": args, "volume": os.environ["SEARXNG_VALIDATION_VOLUME"], "secret": os.environ["SEARXNG_SECRET"]}) + "\\n")
failure = os.environ["DOCKER_FAILURE"]
if args[:2] == ["volume", "inspect"]:
sys.exit(0 if failure == "existing-volume" else 1)
if "exec" in args and failure == "exec":
sys.exit(7)
if "up" in args and failure == "up":
sys.exit(9)
''')
binary.chmod(0o755)
result = subprocess.run(["bash", str(ROOT / ".github/scripts/validate-searxng-image.sh"), "localhost/searxng:validated"], env={**os.environ, "PATH": str(tmp_path) + ":" + os.environ["PATH"], "DOCKER_CALLS": str(calls), "DOCKER_FAILURE": failure, "SEARXNG_SECRET": "operator-secret-must-not-propagate"}, capture_output=True, text=True)
assert result.returncode == expected, result.stderr
entries = [json.loads(line) for line in calls.read_text().splitlines()]
assert all(entry["secret"] == "searxng-ci-validation-only" for entry in entries)
commands = [entry["args"] for entry in entries]
if failure == "existing-volume":
assert len(commands) == 1
return
up = next(command for command in commands if "up" in command)
assert up[up.index("--env-file") + 1] == "/dev/null"
assert "--no-deps" in up and "--no-build" in up and "--wait" in up
assert up[up.index("--pull") + 1] == "never"
down = next(command for command in commands if "down" in command)
assert "--volumes" not in down
assert commands[-1] == ["volume", "rm", entries[-1]["volume"]]
if failure != "up":
execute = next(command for command in commands if "exec" in command)
assert execute[-2:] == ["--running", "--live"]