mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-24 07:42:21 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d76b9a9b | ||
|
|
f8e043e8ca | ||
|
|
98a16242c6 | ||
|
|
0dbf457c00 | ||
|
|
b4ae7ebcc4 | ||
|
|
01566b158e | ||
|
|
a98013dd33 | ||
|
|
77a02c30ff | ||
|
|
cd9f1f734f | ||
|
|
5d0a219ede | ||
|
|
c4fdaf59b3 | ||
|
|
df1c2c8b19 | ||
|
|
8877273e19 | ||
|
|
377070ab79 | ||
|
|
f06ec5d91f | ||
|
|
057ae92c17 | ||
|
|
75df766358 | ||
|
|
c7d1555eea | ||
|
|
550ac015eb | ||
|
|
8eaf742b43 | ||
|
|
6cf676dfe2 |
@@ -0,0 +1,118 @@
|
||||
"""Describe a validated sidecar release without executing image or repository code."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
IMAGE = "ghcr.io/odysseus-dev/odysseus-searxng"
|
||||
REPOSITORY = "odysseus-dev/odysseus"
|
||||
SOURCE_FILES = (
|
||||
"docker/searxng/Dockerfile",
|
||||
"docker/searxng/repair_trust.py",
|
||||
"docker/searxng/blocked-certificates.json",
|
||||
"docker/searxng/validate_image.py",
|
||||
"tests/fixtures/ca-trust/incident.json",
|
||||
)
|
||||
|
||||
|
||||
def revision(value):
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", value):
|
||||
raise ValueError("invalid source revision")
|
||||
return value
|
||||
|
||||
|
||||
def digest(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value):
|
||||
raise ValueError("invalid image digest")
|
||||
return value
|
||||
|
||||
|
||||
def source_snapshot(root, source_revision, repository):
|
||||
revision(source_revision)
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
|
||||
raise ValueError("invalid source repository")
|
||||
dockerfile = (root / SOURCE_FILES[0]).read_text()
|
||||
match = re.search(r"^FROM (\S+) AS base$", dockerfile, re.M)
|
||||
if not match or not re.fullmatch(r"docker\.io/searxng/searxng:[^\s@]+@sha256:[0-9a-f]{64}", match[1]):
|
||||
raise ValueError("SearXNG base must have an immutable upstream digest")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"repository": repository,
|
||||
"source_revision": source_revision,
|
||||
"upstream_image": match[1],
|
||||
"source_inputs": {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in SOURCE_FILES},
|
||||
}
|
||||
|
||||
|
||||
def release_descriptor(amd64, arm64, manifest, source_revision, run_id, tag, arch_digests):
|
||||
revision(source_revision)
|
||||
if amd64 != arm64:
|
||||
raise ValueError("native architecture builds used different source inputs")
|
||||
if amd64.get("repository") != REPOSITORY or amd64.get("source_revision") != source_revision:
|
||||
raise ValueError("release source does not match the canonical workflow revision")
|
||||
if amd64.get("schema_version") != 1 or set(amd64.get("source_inputs", {})) != set(SOURCE_FILES):
|
||||
raise ValueError("incomplete source inventory")
|
||||
if any(not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value) for value in amd64["source_inputs"].values()):
|
||||
raise ValueError("invalid source checksum")
|
||||
if not str(run_id).isdigit() or not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}", tag):
|
||||
raise ValueError("invalid workflow identity or image tag")
|
||||
if manifest.get("mediaType") not in (
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
):
|
||||
raise ValueError("release is not a multiarch image index")
|
||||
children = manifest.get("manifests", [])
|
||||
platforms = sorted(f"{entry.get('platform', {}).get('os')}/{entry.get('platform', {}).get('architecture')}" for entry in children)
|
||||
if platforms != ["linux/amd64", "linux/arm64"]:
|
||||
raise ValueError("release must contain exactly the two validated Linux architectures")
|
||||
if len({digest(entry.get("digest")) for entry in children}) != 2:
|
||||
raise ValueError("architecture digests must be distinct")
|
||||
expected = {arch: digest(value) for arch, value in arch_digests.items()}
|
||||
actual = {entry["platform"]["architecture"]: entry["digest"] for entry in children}
|
||||
if actual != expected:
|
||||
raise ValueError("index does not reference the exact validated architecture images")
|
||||
return {
|
||||
**amd64,
|
||||
"image": f"{IMAGE}@{digest(manifest.get('digest'))}",
|
||||
"tag": tag,
|
||||
"platforms": platforms,
|
||||
"architecture_digests": actual,
|
||||
"workflow_run": f"https://github.com/{REPOSITORY}/actions/runs/{run_id}",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
inputs = sub.add_parser("inputs")
|
||||
inputs.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
|
||||
inputs.add_argument("--revision", required=True)
|
||||
inputs.add_argument("--repository", required=True)
|
||||
inputs.add_argument("--output", type=Path, required=True)
|
||||
release = sub.add_parser("release")
|
||||
release.add_argument("--amd64", type=Path, required=True)
|
||||
release.add_argument("--arm64", type=Path, required=True)
|
||||
release.add_argument("--manifest", type=Path, required=True)
|
||||
release.add_argument("--amd64-digest", required=True)
|
||||
release.add_argument("--arm64-digest", required=True)
|
||||
release.add_argument("--revision", required=True)
|
||||
release.add_argument("--run-id", required=True)
|
||||
release.add_argument("--tag", required=True)
|
||||
release.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if args.command == "inputs":
|
||||
value = source_snapshot(args.root, args.revision, args.repository)
|
||||
else:
|
||||
value = release_descriptor(
|
||||
json.loads(args.amd64.read_text()), json.loads(args.arm64.read_text()),
|
||||
json.loads(args.manifest.read_text()), args.revision, args.run_id, args.tag,
|
||||
{"amd64": args.amd64_digest, "arm64": args.arm64_digest},
|
||||
)
|
||||
args.output.write_text(json.dumps(value, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exercise an already-built image through the normal Compose startup path.
|
||||
set -euo pipefail
|
||||
|
||||
searxng_repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
searxng_image=${1:?usage: validate-searxng-image.sh IMAGE}
|
||||
[[ "$searxng_image" =~ ^[a-zA-Z0-9_./:@+-]+$ ]] || {
|
||||
echo "Invalid image reference" >&2
|
||||
exit 2
|
||||
}
|
||||
searxng_checks=$(mktemp -d "${TMPDIR:-/tmp}/searxng-ci-XXXXXXXX")
|
||||
searxng_project=$(basename "$searxng_checks" | tr '[:upper:]' '[:lower:]')
|
||||
export SEARXNG_VALIDATION_IMAGE="$searxng_image"
|
||||
export SEARXNG_VALIDATION_CHECKS="$searxng_checks"
|
||||
export SEARXNG_VALIDATION_VOLUME="${searxng_project}-settings"
|
||||
# Never forward an operator's existing secret or load their .env for a CI probe.
|
||||
export SEARXNG_SECRET=searxng-ci-validation-only
|
||||
searxng_volume_created=0
|
||||
searxng_started=0
|
||||
searxng_compose=(docker compose --env-file /dev/null --project-name "$searxng_project"
|
||||
-f "$searxng_repo/docker-compose.yml"
|
||||
-f "$searxng_repo/.github/searxng-validation.compose.yml")
|
||||
|
||||
searxng_cleanup() {
|
||||
local searxng_exit=$?
|
||||
if [ "$searxng_started" = 1 ]; then
|
||||
"${searxng_compose[@]}" down --remove-orphans >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "$searxng_volume_created" = 1 ]; then
|
||||
docker volume rm "$SEARXNG_VALIDATION_VOLUME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf -- "$searxng_checks"
|
||||
return "$searxng_exit"
|
||||
}
|
||||
trap searxng_cleanup EXIT
|
||||
|
||||
install -m 0644 "$searxng_repo/docker/searxng/repair_trust.py" \
|
||||
"$searxng_repo/docker/searxng/blocked-certificates.json" \
|
||||
"$searxng_repo/docker/searxng/validate_image.py" "$searxng_checks/"
|
||||
install -m 0644 "$searxng_repo/tests/fixtures/ca-trust/incident.json" "$searxng_checks/incident.json"
|
||||
install -m 0644 "$searxng_repo/config/searxng/settings.yml" "$searxng_checks/settings.yml"
|
||||
install -m 0644 "$searxng_repo/scripts/migrate_searxng_settings.py" "$searxng_checks/migrate_searxng_settings.py"
|
||||
if docker volume inspect "$SEARXNG_VALIDATION_VOLUME" >/dev/null 2>&1; then
|
||||
echo "Refusing to reuse an existing validation volume" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker volume create --label "odysseus.searxng-validation=$searxng_project" \
|
||||
"$SEARXNG_VALIDATION_VOLUME" >/dev/null
|
||||
searxng_volume_created=1
|
||||
searxng_started=1
|
||||
if ! "${searxng_compose[@]}" up --detach --no-deps --no-build --pull never \
|
||||
--wait --wait-timeout 150 searxng; then
|
||||
"${searxng_compose[@]}" logs --no-color searxng >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
"${searxng_compose[@]}" exec -T searxng /usr/local/searxng/.venv/bin/python \
|
||||
/opt/odysseus-checks/validate_image.py --running --live
|
||||
@@ -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:?}
|
||||
+20
-11
@@ -93,7 +93,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22.23.4"
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- name: Check frontend architecture
|
||||
run: node scripts/check_frontend.mjs
|
||||
@@ -155,20 +155,35 @@ jobs:
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Establish system trust before dependency installers or their hooks run.
|
||||
# Keep one apt pass for FFmpeg and bubblewrap; containment is checked later.
|
||||
- name: Install FFmpeg and bubblewrap and validate system CA trust
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates
|
||||
python scripts/check_ca_trust.py --system
|
||||
command -v ffmpeg
|
||||
ffmpeg -version | head -n 1
|
||||
|
||||
# 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
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
version: "0.13.5"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: requirements.txt
|
||||
prune-cache: false
|
||||
- run: uv pip install --system -r requirements.txt
|
||||
- run: |
|
||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
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
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22.23.4"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
@@ -184,15 +199,9 @@ jobs:
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- run: mkdir -p data # sqlite DB lives at ./data/app.db
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
# One apt pass for FFmpeg (media integration tests) and bubblewrap
|
||||
# (containment, verified in the next step).
|
||||
- name: Install FFmpeg and bubblewrap
|
||||
- name: Validate Python and system CA trust
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap
|
||||
command -v ffmpeg
|
||||
ffmpeg -version | head -n 1
|
||||
run: python scripts/check_ca_trust.py --system --python
|
||||
|
||||
- name: Establish functional bubblewrap containment
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
|
||||
@@ -70,5 +70,9 @@ jobs:
|
||||
- name: Run pip-audit on requirements
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends ca-certificates
|
||||
python scripts/check_ca_trust.py --system
|
||||
python -m pip install --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
pip install pip-audit==2.10.0
|
||||
pip-audit -r requirements.txt -r requirements-optional.txt --strict
|
||||
|
||||
@@ -29,7 +29,6 @@ permissions:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -52,6 +51,9 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Normalize image name
|
||||
shell: bash
|
||||
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Log in to GHCR
|
||||
@@ -65,6 +67,7 @@ jobs:
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
@@ -93,6 +96,9 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Normalize image name
|
||||
shell: bash
|
||||
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
- name: Read APP_VERSION + short sha
|
||||
id: ver
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
name: ci / SearXNG image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docker/searxng/**'
|
||||
- 'tests/fixtures/ca-trust/**'
|
||||
- 'tests/test_searxng*'
|
||||
- 'docker-compose*.yml'
|
||||
- 'config/searxng/**'
|
||||
- 'scripts/migrate_searxng_settings.py'
|
||||
- '.github/scripts/*searxng*'
|
||||
- '.github/searxng-validation.compose.yml'
|
||||
- '.github/workflows/searxng-image.yml'
|
||||
push:
|
||||
branches: [dev, main, release/searxng-ca-trust]
|
||||
paths:
|
||||
- 'docker/searxng/**'
|
||||
- 'tests/fixtures/ca-trust/**'
|
||||
- 'docker-compose*.yml'
|
||||
- 'config/searxng/**'
|
||||
- 'scripts/migrate_searxng_settings.py'
|
||||
- '.github/scripts/*searxng*'
|
||||
- '.github/searxng-validation.compose.yml'
|
||||
- '.github/workflows/searxng-image.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: Publish after native validation (canonical trusted branches only)
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: searxng-image-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: SearXNG validate (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Reproduce affected base
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/searxng/Dockerfile
|
||||
target: baseline-validation
|
||||
network: none
|
||||
platforms: ${{ matrix.platform }}
|
||||
no-cache-filters: baseline-validation
|
||||
cache-from: type=gha,scope=searxng-${{ matrix.arch }}
|
||||
- name: Validate repaired stores and upstream startup
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/searxng/Dockerfile
|
||||
target: validation
|
||||
network: none
|
||||
platforms: ${{ matrix.platform }}
|
||||
no-cache-filters: validation
|
||||
cache-from: type=gha,scope=searxng-${{ matrix.arch }}
|
||||
- name: Build runtime without registry credentials
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/searxng/Dockerfile
|
||||
target: runtime
|
||||
network: none
|
||||
platforms: ${{ matrix.platform }}
|
||||
load: true
|
||||
tags: localhost/odysseus-searxng:ci-${{ matrix.arch }}
|
||||
provenance: false
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
cache-from: type=gha,scope=searxng-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=searxng-${{ matrix.arch }}
|
||||
- name: Validate actual runtime through Compose and live HTTPS
|
||||
env:
|
||||
SEARXNG_TEST_IMAGE: localhost/odysseus-searxng:ci-${{ matrix.arch }}
|
||||
run: bash .github/scripts/validate-searxng-image.sh "$SEARXNG_TEST_IMAGE"
|
||||
- name: Export validated runtime and input checksums
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
docker save --output "$RUNNER_TEMP/runtime-${ARCH}.tar" "localhost/odysseus-searxng:ci-${ARCH}"
|
||||
python3 .github/scripts/searxng_release.py inputs --revision "$GITHUB_SHA" \
|
||||
--repository "$GITHUB_REPOSITORY" --output "$RUNNER_TEMP/inputs-${ARCH}.json"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: searxng-runtime-${{ matrix.arch }}
|
||||
path: |
|
||||
${{ runner.temp }}/runtime-${{ matrix.arch }}.tar
|
||||
${{ runner.temp }}/inputs-${{ matrix.arch }}.json
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: Publish validated SearXNG architectures and index
|
||||
needs: validate
|
||||
if: >-
|
||||
github.repository == 'odysseus-dev/odysseus' &&
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish)) &&
|
||||
contains(fromJSON('["refs/heads/dev","refs/heads/main","refs/heads/release/searxng-ca-trust"]'), github.ref)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
image: ${{ steps.release.outputs.image }}
|
||||
env:
|
||||
SEARXNG_IMAGE: ghcr.io/odysseus-dev/odysseus-searxng
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ${{ runner.temp }}/searxng-artifacts
|
||||
pattern: searxng-runtime-*
|
||||
merge-multiple: true
|
||||
- name: Load and inspect the validated artifacts before registry login
|
||||
run: |
|
||||
for arch in amd64 arm64; do
|
||||
docker load --input "$RUNNER_TEMP/searxng-artifacts/runtime-${arch}.tar"
|
||||
image="localhost/odysseus-searxng:ci-${arch}"
|
||||
test "$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = "linux/${arch}"
|
||||
test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$GITHUB_SHA"
|
||||
done
|
||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish the exact validated artifacts
|
||||
id: release
|
||||
run: |
|
||||
tag="sha-${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
refs=()
|
||||
for arch in amd64 arm64; do
|
||||
remote="${SEARXNG_IMAGE}:${tag}-${arch}"
|
||||
docker tag "localhost/odysseus-searxng:ci-${arch}" "$remote"
|
||||
docker push "$remote"
|
||||
digests=$(docker image inspect --format '{{json .RepoDigests}}' "$remote")
|
||||
ref=$(jq -er --arg prefix "${SEARXNG_IMAGE}@" '.[] | select(startswith($prefix))' <<< "$digests")
|
||||
[[ "$ref" =~ ^ghcr.io/odysseus-dev/odysseus-searxng@sha256:[0-9a-f]{64}$ ]]
|
||||
refs+=("$ref")
|
||||
done
|
||||
docker buildx imagetools create --tag "${SEARXNG_IMAGE}:${tag}" "${refs[@]}"
|
||||
docker buildx imagetools inspect "${SEARXNG_IMAGE}:${tag}" --format '{{json .Manifest}}' > "$RUNNER_TEMP/searxng-manifest.json"
|
||||
python3 .github/scripts/searxng_release.py release \
|
||||
--amd64 "$RUNNER_TEMP/searxng-artifacts/inputs-amd64.json" \
|
||||
--arm64 "$RUNNER_TEMP/searxng-artifacts/inputs-arm64.json" \
|
||||
--manifest "$RUNNER_TEMP/searxng-manifest.json" --revision "$GITHUB_SHA" \
|
||||
--amd64-digest "${refs[0]#*@}" --arm64-digest "${refs[1]#*@}" \
|
||||
--run-id "$GITHUB_RUN_ID" --tag "$tag" --output "$RUNNER_TEMP/searxng-release.json"
|
||||
image=$(jq -er .image "$RUNNER_TEMP/searxng-release.json")
|
||||
echo "image=$image" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: searxng-release-candidate
|
||||
path: ${{ runner.temp }}/searxng-release.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
verify-published:
|
||||
name: Verify public SearXNG pull (${{ matrix.arch }})
|
||||
needs: publish
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Pull the published index without registry credentials
|
||||
env:
|
||||
SEARXNG_TEST_IMAGE: ${{ needs.publish.outputs.image }}
|
||||
run: |
|
||||
if ! docker pull "$SEARXNG_TEST_IMAGE"; then
|
||||
echo '::error::Anonymous GHCR pull failed. A newly created package defaults to private: make odysseus-searxng public, then rerun the failed verification jobs.'
|
||||
exit 1
|
||||
fi
|
||||
- name: Validate the pulled runtime through Compose
|
||||
env:
|
||||
SEARXNG_TEST_IMAGE: ${{ needs.publish.outputs.image }}
|
||||
run: bash .github/scripts/validate-searxng-image.sh "$SEARXNG_TEST_IMAGE"
|
||||
|
||||
release:
|
||||
name: Record verified Compose image reference
|
||||
needs: [publish, verify-published]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: searxng-release-candidate
|
||||
path: ${{ runner.temp }}/searxng-release
|
||||
- name: Record the verified image
|
||||
env:
|
||||
SEARXNG_VERIFIED_IMAGE: ${{ needs.publish.outputs.image }}
|
||||
run: |
|
||||
{
|
||||
echo '## Verified SearXNG image'
|
||||
echo
|
||||
echo "\`$SEARXNG_VERIFIED_IMAGE\`"
|
||||
echo
|
||||
echo 'Both native architectures passed Compose startup, fourteen incident-chain/HTTPS checks and live search before and after publication.'
|
||||
echo 'Pin this reference in the base and standalone GPU Compose files and commit the release descriptor.'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: searxng-release
|
||||
path: ${{ runner.temp }}/searxng-release/searxng-release.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -76,5 +76,9 @@ jobs:
|
||||
- name: Run zizmor
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends ca-certificates
|
||||
python scripts/check_ca_trust.py --system
|
||||
python -m pip install --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
pip install zizmor==1.25.2
|
||||
zizmor --offline --min-severity=low .github/workflows/
|
||||
|
||||
+30
-4
@@ -3,13 +3,18 @@
|
||||
# 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
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
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
|
||||
RUN python /usr/local/bin/check-ca-trust.py --system
|
||||
COPY requirements-bootstrap.txt /tmp/requirements-bootstrap.txt
|
||||
RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --no-deps -r /tmp/requirements-bootstrap.txt \
|
||||
&& python /usr/local/bin/check-ca-trust.py --python
|
||||
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,
|
||||
@@ -28,6 +33,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
cmake \
|
||||
curl \
|
||||
ca-certificates \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
@@ -48,6 +54,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Refresh both Python trust bundles before installing application dependencies.
|
||||
# System roots also serve web fetches, webhooks, curl, and Node's OpenSSL mode.
|
||||
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
||||
RUN python /usr/local/bin/check-ca-trust.py --system
|
||||
COPY requirements-bootstrap.txt /tmp/requirements-bootstrap.txt
|
||||
RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --no-deps -r /tmp/requirements-bootstrap.txt \
|
||||
&& python /usr/local/bin/check-ca-trust.py --python
|
||||
ENV NODE_OPTIONS=--use-openssl-ca \
|
||||
GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# Chromium has a separate root store. Distrust the compromised intermediate
|
||||
# public keys explicitly, including cross-signs, without waiting for CRL updates.
|
||||
COPY docker/chromium-emudhra.json /etc/chromium/policies/managed/odysseus-emudhra.json
|
||||
|
||||
# Private browser automation wrapper used by the native `private_browser` tool.
|
||||
# Chromium is installed above, so agent-browser can drive the existing browser
|
||||
# binary without paying `npx` startup/install overhead on each tool call.
|
||||
@@ -72,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 ;; \
|
||||
@@ -108,6 +129,11 @@ COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
|
||||
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.
|
||||
# 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
|
||||
COPY . .
|
||||
# Require the redistribution notices in the image build context.
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ if (-not $pyExe) {
|
||||
Write-Host ("Using Python: " + $pyExe)
|
||||
|
||||
Write-Step "Installing build dependencies"
|
||||
& $pyExe -m pip install --upgrade pip --quiet
|
||||
& $pyExe -m pip install --quiet --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
if ($LASTEXITCODE -ne 0) { Fail "TLS build dependency bootstrap failed." }
|
||||
& $pyExe -m pip install -r requirements.txt pyinstaller pystray Pillow
|
||||
if ($LASTEXITCODE -ne 0) { Fail "Dependency install failed." }
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Chromium incident distrust policy
|
||||
|
||||
`chromium-emudhra.json` is installed in the Docker image at `/etc/chromium/policies/managed/odysseus-emudhra.json`. Chromium 132 and later support `CADistrustedCertificates`: a list of base64-encoded DER X.509 certificates whose public keys must not be accepted for TLS server authentication. Matching is by SubjectPublicKeyInfo (SPKI), so one certificate per compromised key also blocks its cross-signs. This policy adds no trust, disables no certificate checks, and does not require a revocation-list download. Native browsers and other browser distributions require separately managed installation of the appropriate policy.
|
||||
|
||||
Policy semantics: [Chromium's version-155 definition](https://github.com/chromium/chromium/blob/155.0.8059.79/components/policy/resources/templates/policy_definitions/CertificateManagement/CADistrustedCertificates.yaml). Linux installation paths: [Chromium policy documentation](https://www.chromium.org/administrators/configuring-policy-for-extensions/#linux).
|
||||
|
||||
Public certificate source, retrieved 2026-10-21: [incident intermediate inventory](https://emudhra.we-have-a-problem.com/impacted-intermediates.json). Its downloaded JSON has SHA-256 `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`. Each selected certificate's DER fingerprint and public-key fingerprint were checked against that inventory before inclusion. The eight unique keys below are covered, including the code-signing intermediate keys listed in the incident. The policy contains only public certificates; no private key is included.
|
||||
|
||||
| Intermediate | SHA-256 of DER SPKI |
|
||||
| --- | --- |
|
||||
| EM OV TLS CA - G3B | `43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b` |
|
||||
| EM DV TLS CA - G3C | `49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f` |
|
||||
| EM EV TLS CA - G3C | `5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0` |
|
||||
| EM OV TLS CA - G3C | `63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8` |
|
||||
| EM OV CS CA - G3C | `92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30` |
|
||||
| EM EV TLS CA - G3B | `e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41` |
|
||||
| EM DV TLS CA - G3B | `ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105` |
|
||||
| EM OV CS CA - G3B | `ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6` |
|
||||
|
||||
`tests/test_chromium_ca_policy.py` verifies that the policy distrusts exactly these eight keys using valid intermediate CA certificates and adds no trust settings. Changes to the encoded material need a fresh source/fingerprint check and behavioral validation: all twelve published incident HTTPS chains must be rejected by the actual Chromium binary while ordinary HTTPS still succeeds. Confirm loading and any policy errors at `chrome://policy`. Do not treat a passing PEM-store check as a browser-policy check.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"CADistrustedCertificates": [
|
||||
"MIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAwMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEEAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe8Dz3bQjauvy9uw==",
|
||||
"MIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1MDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxgyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNdAEKwaJ0mVzScy2+6",
|
||||
"MIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUzOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11ZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49AgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+bSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3VmxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9i1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAqMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysGAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/fAal1GNxsOutLnr0Y",
|
||||
"MIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3NDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44cNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR794rSb9vGqxZzelQth",
|
||||
"MIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0MFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEGBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9O8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3QbX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUNq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggrBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgGBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBgeuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=",
|
||||
"MIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYwMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDAlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6MN/P+ByGeiRMghzB",
|
||||
"MIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1NDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/JhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYBBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5jcmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPOIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8gt2TlfJbHii+KNvK",
|
||||
"MIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgyN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2rzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsGAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYGZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03YEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9HTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM="
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Keep the application version unchanged while correcting its independent trust.
|
||||
FROM docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 AS base
|
||||
|
||||
FROM base AS baseline-validation
|
||||
COPY docker/searxng/repair_trust.py docker/searxng/blocked-certificates.json docker/searxng/validate_image.py /opt/odysseus-trust/
|
||||
COPY tests/fixtures/ca-trust/incident.json /opt/odysseus-trust/incident.json
|
||||
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --baseline
|
||||
|
||||
FROM base AS repaired
|
||||
COPY docker/searxng/repair_trust.py docker/searxng/blocked-certificates.json /opt/odysseus-trust/
|
||||
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/repair_trust.py
|
||||
|
||||
# Optional offline validation runs inside the actual SearXNG filesystem.
|
||||
FROM repaired AS validation
|
||||
COPY tests/fixtures/ca-trust/incident.json /opt/odysseus-trust/incident.json
|
||||
COPY docker/searxng/validate_image.py /opt/odysseus-trust/validate_image.py
|
||||
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py
|
||||
|
||||
FROM validation AS live-validation
|
||||
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --live
|
||||
|
||||
FROM repaired AS runtime
|
||||
@@ -0,0 +1,8 @@
|
||||
**
|
||||
!docker/
|
||||
!docker/searxng/
|
||||
!docker/searxng/**
|
||||
!tests/
|
||||
!tests/fixtures/
|
||||
!tests/fixtures/ca-trust/
|
||||
!tests/fixtures/ca-trust/incident.json
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (cross-signed)",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Opt in to the locally built trust-corrected image without publishing a tag.
|
||||
# Run from the repository root with the base Compose file first.
|
||||
services:
|
||||
searxng:
|
||||
image: localhost/odysseus-searxng:2026.9.25-ca-trust
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/searxng/Dockerfile
|
||||
target: runtime
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Remove known incident certificates without downloading packages or roots.
|
||||
|
||||
Inventory provenance: https://emudhra.we-have-a-problem.com/roots.json and
|
||||
https://emudhra.we-have-a-problem.com/impacted-intermediates.json; the additional
|
||||
cross-signed issuer is from the incident's public chains.zip.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ssl
|
||||
import subprocess
|
||||
|
||||
|
||||
BLOCKED = json.loads(Path(__file__).with_name("blocked-certificates.json").read_text())
|
||||
PEM = re.compile(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", re.S)
|
||||
|
||||
|
||||
def certificates(text):
|
||||
blocks = PEM.findall(text)
|
||||
if not blocks or "-----BEGIN" in PEM.sub("", text):
|
||||
raise ValueError("empty or malformed certificate input")
|
||||
for block in blocks:
|
||||
# Validate the X.509 object as well as its base64 encoding.
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.load_verify_locations(cadata=block)
|
||||
yield block, hashlib.sha256(ssl.PEM_cert_to_DER_cert(block)).hexdigest()
|
||||
|
||||
|
||||
def filter_bundle(path, *, allow_empty=False):
|
||||
original = list(certificates(path.read_text()))
|
||||
retained = [block for block, digest in original if digest not in BLOCKED]
|
||||
removed = len(original) - len(retained)
|
||||
if not retained and not allow_empty:
|
||||
raise ValueError(f"refusing to empty trust bundle: {path}")
|
||||
if removed:
|
||||
if retained:
|
||||
path.write_text("\n".join(retained) + "\n")
|
||||
else:
|
||||
path.unlink()
|
||||
return removed
|
||||
|
||||
|
||||
def check_bundle(path):
|
||||
blocks = list(certificates(path.read_text()))
|
||||
if any(digest in BLOCKED for _, digest in blocks):
|
||||
raise ValueError(f"incident certificate remains: {path}")
|
||||
return len(blocks)
|
||||
|
||||
|
||||
def main():
|
||||
import certifi
|
||||
|
||||
sources = list(Path("/usr/share/ca-certificates").rglob("*.crt"))
|
||||
sources += list(Path("/usr/local/share/ca-certificates").rglob("*.crt"))
|
||||
if not sources:
|
||||
raise ValueError("no system CA sources found")
|
||||
removed = sum(filter_bundle(path, allow_empty=True) for path in sources)
|
||||
removed += filter_bundle(Path(certifi.where()))
|
||||
# The upstream entrypoint also regenerates trust at root startup. Removing
|
||||
# source certificates ensures that it cannot restore the incident roots.
|
||||
subprocess.run(["update-ca-certificates", "--fresh"], check=True)
|
||||
paths = [Path("/etc/ssl/certs/ca-certificates.crt"), Path(certifi.where())]
|
||||
paths += [p for p in Path("/etc/ssl/certs").iterdir()
|
||||
if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", p.name)]
|
||||
if len(paths) <= 2:
|
||||
raise ValueError("no hashed system CA entries generated")
|
||||
for path in paths:
|
||||
check_bundle(path)
|
||||
print(f"Removed {removed} incident certificates; checked {len(paths)} trust files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Offline incident-chain checks and loopback boot smoke for the derived image."""
|
||||
|
||||
import hashlib
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import certifi
|
||||
|
||||
from repair_trust import check_bundle
|
||||
|
||||
|
||||
def check_chains(baseline=False):
|
||||
incident = json.loads(Path(__file__).with_name("incident.json").read_text())
|
||||
system = Path(os.environ["SSL_CERT_FILE"])
|
||||
capath = os.environ["SSL_CERT_DIR"]
|
||||
stores = [["-CAfile", str(system), "-no-CApath"],
|
||||
["-CAfile", certifi.where(), "-no-CApath"],
|
||||
["-no-CAfile", "-CApath", capath]]
|
||||
if not baseline:
|
||||
check_bundle(system)
|
||||
check_bundle(Path(certifi.where()))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
for fingerprint, pem in incident["certificates"].items():
|
||||
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
|
||||
raise ValueError(f"incident fixture fingerprint mismatch: {fingerprint}")
|
||||
(directory / fingerprint).write_text(pem)
|
||||
positive = directory / "affected.pem"
|
||||
positive.write_text("\n".join(incident["certificates"][fp] for fp in incident["roots"]))
|
||||
for hostname, chain in incident["chains"].items():
|
||||
untrusted = directory / "untrusted.pem"
|
||||
untrusted.write_text("\n".join(incident["certificates"][fp] for fp in chain[1:]))
|
||||
base = ["openssl", "verify", "-no-CAstore", "-attime", str(incident["verification_time"]),
|
||||
"-purpose", "sslserver", "-verify_hostname", hostname,
|
||||
"-x509_strict", "-partial_chain", "-trusted_first", "-untrusted", str(untrusted)]
|
||||
expectations = [(s, baseline and (index != 1 or "-aaa-" not in hostname))
|
||||
for index, s in enumerate(stores)]
|
||||
for selection, expected in expectations + [
|
||||
(["-CAfile", str(positive), "-no-CApath"], True)]:
|
||||
result = subprocess.run(base + selection + [str(directory / chain[0])],
|
||||
capture_output=True, text=True)
|
||||
if (result.returncode == 0) != expected:
|
||||
raise RuntimeError(f"unexpected chain result: {hostname}: {selection}: {result.stderr}")
|
||||
# Accept issuer lookup, self-signed and explicitly untrusted
|
||||
# certificate errors, rather than tool or fixture failures.
|
||||
if not expected and not re.search(r"error (?:2|18|19|20|21|27) at \d+ depth lookup", result.stderr):
|
||||
raise RuntimeError(f"not a certificate trust rejection: {hostname}: {result.stderr}")
|
||||
if baseline:
|
||||
total = len(incident["chains"])
|
||||
certifi_accepted = sum("-aaa-" not in host for host in incident["chains"])
|
||||
print(f"Affected base reproduced: system PEM and directory accept {total}/{total}; certifi accepts {certifi_accepted}/{total}")
|
||||
else:
|
||||
print(f"{len(incident['chains'])}/{len(incident['chains'])} incident chains rejected by system PEM, hashed directory and certifi; affected controls accepted")
|
||||
|
||||
|
||||
def check_boot(live=False):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
settings = Path(directory) / "settings.yml"
|
||||
settings.write_text("use_default_settings: true\nserver:\n secret_key: offline-build-validation-only\n limiter: false\nsearch:\n formats: [html, json]\n")
|
||||
cache = Path(directory) / "cache"
|
||||
cache.mkdir()
|
||||
env = dict(os.environ, SEARXNG_SETTINGS_PATH=str(settings),
|
||||
__SEARXNG_CONFIG_PATH=directory, __SEARXNG_DATA_PATH=str(cache),
|
||||
GRANIAN_HOST="127.0.0.1", GRANIAN_PORT="18888")
|
||||
# Do not expose a port or issue an outbound search during offline builds.
|
||||
with tempfile.TemporaryFile() as log:
|
||||
process = subprocess.Popen(["/usr/local/searxng/entrypoint.sh"],
|
||||
cwd="/usr/local/searxng", env=env, stdout=log,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
healthy = False
|
||||
for _ in range(60):
|
||||
if process.poll() is not None:
|
||||
break
|
||||
try:
|
||||
with opener.open("http://127.0.0.1:18888/healthz", timeout=1) as response:
|
||||
healthy = response.status == 200
|
||||
except OSError:
|
||||
pass
|
||||
if healthy:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
if not healthy:
|
||||
log.seek(0)
|
||||
raise RuntimeError("SearXNG failed boot smoke: " + log.read().decode(errors="replace")[-6000:])
|
||||
print("SearXNG loopback healthcheck passed")
|
||||
# Its root entrypoint regenerates system trust. Repeat the
|
||||
# behavioral checks after that real startup path completes.
|
||||
check_running(live=live, port=18888)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
|
||||
|
||||
def check_running(live=False, port=None):
|
||||
"""Verify an existing service without repairing it or starting another one."""
|
||||
port = int(os.environ.get("GRANIAN_PORT", "8080")) if port is None else port
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with opener.open(f"http://127.0.0.1:{port}/healthz", timeout=5) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError("SearXNG running-service healthcheck failed")
|
||||
check_chains()
|
||||
for path in Path(os.environ["SSL_CERT_DIR"]).iterdir():
|
||||
if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", path.name):
|
||||
check_bundle(path)
|
||||
if live:
|
||||
asyncio.run(check_https())
|
||||
with opener.open(f"http://127.0.0.1:{port}/search?q=python+documentation&format=json", timeout=90) as search:
|
||||
payload = json.load(search)
|
||||
results = payload.get("results", [])
|
||||
if not results:
|
||||
raise RuntimeError("live search returned no results")
|
||||
print(f"SearXNG live JSON search passed: {len(results)} results")
|
||||
|
||||
|
||||
async def check_https():
|
||||
# Exercise the actual pinned SearXNG client factory with its default CA
|
||||
# selection, rather than a different HTTP library's trust configuration.
|
||||
sys.path.insert(0, "/usr/local/searxng")
|
||||
from curl_cffi.requests.exceptions import RequestException
|
||||
from searx.network.client import new_client
|
||||
|
||||
client = new_client(enable_http=False, verify=True, enable_http2=True,
|
||||
enable_http3=False, max_connections=2, proxies={},
|
||||
local_address=None, max_redirects=3)
|
||||
try:
|
||||
response = await client.get("https://pypi.org/simple/certifi/", timeout=30)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"ordinary HTTPS failed: {response.status_code}")
|
||||
print("SearXNG actual curl client ordinary HTTPS passed with verification enabled")
|
||||
incident = json.loads(Path(__file__).with_name("incident.json").read_text())
|
||||
for hostname in incident["chains"]:
|
||||
try:
|
||||
await client.get(f"https://{hostname}/", timeout=30, allow_redirects=False)
|
||||
except RequestException as exc:
|
||||
# libcurl code 60 is peer certificate verification failure.
|
||||
# DNS failures, timeouts and handshake EOF do not prove distrust.
|
||||
if exc.code != 60:
|
||||
raise RuntimeError(f"inconclusive incident TLS failure: {hostname}: {exc}") from exc
|
||||
else:
|
||||
raise RuntimeError(f"incident HTTPS unexpectedly accepted: {hostname}")
|
||||
print(f"SearXNG actual curl client rejects {len(incident['chains'])}/{len(incident['chains'])} incident hosts with certificate errors after startup")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
baseline = "--baseline" in sys.argv
|
||||
live = "--live" in sys.argv
|
||||
if "--running" in sys.argv:
|
||||
if baseline:
|
||||
raise ValueError("--running cannot validate an affected baseline")
|
||||
check_running(live=live)
|
||||
else:
|
||||
check_chains(baseline=baseline)
|
||||
if not baseline:
|
||||
check_boot(live=live)
|
||||
@@ -0,0 +1,50 @@
|
||||
# SearXNG image trust correction
|
||||
|
||||
SearXNG has independent system and Python certificate stores. Updating Odysseus's Python environment does not update this sidecar. The pinned SearXNG image includes certificate authorities listed in the public eMudhra incident inventory. `docker/searxng/Dockerfile` derives a corrected image from the same immutable application base, without downloading dependencies or changing the SearXNG version.
|
||||
|
||||
The build removes the 21 known incident CA certificates by SHA-256 fingerprint from system CA source files and certifi, regenerates the system PEM bundle and hashed CA directory, and checks the generated stores. Only certificates present in the base are removed: the inspected amd64 image loses two system roots and one certifi root. Unaffected certificates remain. The image's existing certifi package version metadata is unchanged; this is an image-owned trust-content correction rather than a package upgrade.
|
||||
|
||||
The runtime is Void Linux with its package manager removed. Do not substitute Alpine, Debian or XBPS package-install commands. Removing affected source certificates is necessary because the upstream root entrypoint runs `update-ca-certificates` again at startup.
|
||||
|
||||
## Build and validate
|
||||
|
||||
Run these commands from the repository root on a Docker host. The build context allowlist excludes credentials and unrelated application files. The repair requires no build-step network; pulling the pinned base still needs registry access.
|
||||
|
||||
```sh
|
||||
# Reproduce the affected upstream behavior with offline public chain fixtures.
|
||||
docker build --network=none --target baseline-validation -f docker/searxng/Dockerfile .
|
||||
|
||||
# Check corrected trust and actual upstream entrypoint/loopback health.
|
||||
docker build --network=none --target validation -f docker/searxng/Dockerfile .
|
||||
|
||||
# Also exercise verified HTTPS through SearXNG's own curl client and a real search.
|
||||
# Requires outbound networking and working upstream engines.
|
||||
docker build --target live-validation -f docker/searxng/Dockerfile .
|
||||
|
||||
# Build the runtime image and select it through the opt-in Compose overlay.
|
||||
docker compose -f docker-compose.yml -f docker/searxng/compose.yml build searxng
|
||||
docker compose -f docker-compose.yml -f docker/searxng/compose.yml up -d --no-deps searxng
|
||||
docker compose -f docker-compose.yml -f docker/searxng/compose.yml ps searxng
|
||||
```
|
||||
|
||||
The overlay retains the base service's settings migration, persistent configuration, healthcheck, ports and capability limits. All Compose commands managing this local image must include the overlay; using only the base file selects its original image. Validation fixtures and validation scripts are excluded from the runtime stage.
|
||||
|
||||
The offline checks use fourteen public incident chains, including two without Certificate Transparency entries, with a frozen verification time, hostname verification and affected positive controls. They independently exercise the system PEM, system hashed directory and certifi bundle. The unpatched baseline must accept 14/14, 14/14 and 7/14 respectively; the corrected image must reject all fourteen in every store. The startup check rechecks system trust after the upstream entrypoint regenerates it. Live validation requires an ordinary verified HTTPS request, certificate-verification failures for all fourteen incident hosts through SearXNG's own curl client after startup, and nonempty SearXNG JSON search results; network and engine availability can cause that optional target to fail.
|
||||
|
||||
## Scope and rollout
|
||||
|
||||
The `ci / SearXNG image` workflow builds on native amd64 and arm64 runners. Each architecture reproduces the affected upstream stores, checks the repaired image with build-step networking disabled, then starts the actual runtime through the base Compose service's settings wrapper, capabilities and healthcheck. The CI override removes published ports and uses a fresh temporary configuration volume; it does not replace the service's startup path. Live checks run against that existing service without repairing its trust stores. Pull requests perform these checks without registry credentials or publication.
|
||||
|
||||
On the canonical repository's `dev`, `main` or initial `release/searxng-ca-trust` branch, successful native checks allow a separate job to publish the exact exported images to `ghcr.io/odysseus-dev/odysseus-searxng`. Explicit workflow runs on those branches can also request publication. The publisher verifies source revisions, source-input checksums and architecture digests before recording the immutable multiarch reference. Tags identify the commit and workflow attempt; no moving `latest` or `dev` tag is changed. Fresh native jobs then pull anonymously and repeat Compose/live validation. Only after both pass does the workflow produce the `searxng-release` artifact with `searxng-release.json` for the coordinated Compose pin update.
|
||||
|
||||
For the first publication, a repository administrator must make the new GHCR package public before anonymous verification can succeed. Rerun the failed verification jobs after changing package visibility. The initial release branch permits this bootstrap before the workflow reaches the default branch; it does not merge or deploy the application. Future publication can use the normal trusted-branch push or explicit workflow trigger.
|
||||
|
||||
Validate each target architecture on a native builder or an explicitly configured emulation builder. An amd64 success does not establish arm64 support. This patch preserves the upstream default identity and entrypoint; it does not alter host trust, existing running containers or operator-mounted/custom trust stores. Operators must inspect those overrides separately. Removing AAA Certificate Services can also affect unrelated legacy certificate chains anchored solely there; test configured engines and any private integrations before rollout.
|
||||
|
||||
After architecture validation and independent review, publish an approved multi-architecture runtime image, record its immutable digest, and update the base and standalone GPU Compose pins together. The opt-in overlay permits local validation before a published reference exists. Recreate affected services after selecting the replacement. Keep the broader incident remediation tracker open for deployment and other runtime work.
|
||||
|
||||
## Public fixture provenance
|
||||
|
||||
Fingerprint inventory: <https://emudhra.we-have-a-problem.com/roots.json> and <https://emudhra.we-have-a-problem.com/impacted-intermediates.json>. The cross-signed issuer and chain fixtures come from <https://emudhra.we-have-a-problem.com/chains.zip> (archive SHA-256 `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`). Fixtures contain public certificates only. Each fixture certificate's fingerprint is verified before chain checks.
|
||||
|
||||
The same incident follow-up updates Odysseus's application bootstrap and independent bundled clients; see [outbound TLS trust](tls-trust.md). Those package updates do not change the derived SearXNG application/package versions. The base-image alignment regression requires its immutable upstream base to match the deployment pins until a verified derived release is selected, with the local overlay tag matching the application date.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Outbound HTTPS trust
|
||||
|
||||
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.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:
|
||||
|
||||
```sh
|
||||
python -m pip install --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
The pinned bootstrap protects package integrity even when the old installer's certificate bundle is affected. It is not an operating-system trust-store update. Native Windows/macOS trust and command-line OpenSSL PEM stores may differ; follow the platform vendor's remediation for every store in use. Restart Odysseus and its subprocesses after updating trust.
|
||||
|
||||
On Linux/OpenSSL deployments, inspect both the effective CA file and hashed CA directory, plus the two Python bundles:
|
||||
|
||||
```sh
|
||||
python scripts/check_ca_trust.py --system --python --overrides --verbose
|
||||
```
|
||||
|
||||
Explicit PEM bundles can also be passed as positional arguments. The check rejects the four published root fingerprints, all sixteen published intermediate certificates (including their cross-signs), the cross-signed G3 issuer in the published test chains, and malformed/empty bundles. Checking intermediates matters because adding one as a custom trust anchor can restore an affected chain even after its roots have been removed.
|
||||
|
||||
`--overrides` checks every nonempty `LLM_CA_BUNDLE`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `PIP_CERT`, `NODE_EXTRA_CA_CERTS` and `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH` setting in the checker's process environment, including configured fallback settings. Requests/pip hashed-directory overrides are inspected too; `--system` covers `SSL_CERT_DIR`. Run with the application's environment and filesystem access. The script does not load `.env` or application configuration files; pass any CA paths configured only there explicitly, for example `python scripts/check_ca_trust.py --system --python /path/to/private-ca.pem`. Missing or invalid configured files fail the check. Selecting only `--overrides` with no configured paths also fails instead of reporting that stores were validated.
|
||||
|
||||
`--verbose` reports the Python executable/prefix, user home, effective UID/GID where available, and resolved CA paths, including bundles selected from user-installed packages. Compare these with the running application's identity and interpreter. It does not dump environment variables or credentials.
|
||||
|
||||
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 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.
|
||||
|
||||
To replace only Odysseus in an already running Compose stack, build from the corrected checkout with a distinct local image tag. Use the same Compose files and project name as the existing deployment for every command below:
|
||||
|
||||
```sh
|
||||
export ODYSSEUS_IMAGE="odysseus-local:ca-$(git rev-parse --short=12 HEAD)"
|
||||
docker compose build --pull --no-cache odysseus
|
||||
docker compose up -d --no-deps --pull never --no-build odysseus
|
||||
docker image inspect --format '{{.Id}}' "$ODYSSEUS_IMAGE"
|
||||
docker inspect --format '{{.Image}}' "$(docker compose ps -q odysseus)"
|
||||
```
|
||||
|
||||
The two image IDs must match. Keep the selected `ODYSSEUS_IMAGE` in the deployment configuration for subsequent recreations. Compose can prefer the registry image when both `image` and `build` are present; the explicit pull/build flags keep this replacement on the local build. `--no-deps` leaves the existing sidecars running and is intended for an existing stack. For registry deployments, select a verified corrected immutable tag or digest. A merge to `dev` publishes `:dev`; it does not update `:latest`, which is published from `main`.
|
||||
|
||||
Check the standard image as its configured app account, using the image's Python interpreter and the same user-bin PATH prefix as the entrypoint:
|
||||
|
||||
```sh
|
||||
docker compose exec -T --user 0 odysseus sh -ec '
|
||||
gosu_bin=$(command -v gosu)
|
||||
app_user=$(getent passwd "${PUID:-1000}" | cut -d: -f1)
|
||||
test -n "$app_user"
|
||||
exec "$gosu_bin" "$app_user" env PATH="/app/.local/bin:$PATH" \
|
||||
/usr/local/bin/python /app/scripts/check_ca_trust.py \
|
||||
--system --python --grpc --overrides --verbose
|
||||
'
|
||||
```
|
||||
|
||||
`gosu` selects the account's HOME and drops privileges before Python runs, so the check includes its persisted user packages. The entrypoint itself is not rerun. Plain `docker compose exec ... python` uses the image's default root user and can miss `/app/.local` packages. A custom service user, entrypoint, virtual environment or launch command requires matching that deployment's actual user, HOME and Python interpreter instead of copying the standard command unchanged. Docker exec inherits container-creation environment, not variables exported later by the app or entrypoint: reproduce any additional Python/TLS settings with exec's `--env` options, and pass CA paths configured only in application files explicitly. Confirm the verbose identity and selected paths before accepting the result.
|
||||
|
||||
For containers started non-root or without the SETUID/SETGID capabilities needed by `gosu`, use `docker compose exec --user UID:GID --env HOME=/actual/app/home` with the deployment's actual numeric IDs, home, PATH and interpreter instead. Do not add capabilities merely to run this check.
|
||||
|
||||
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 fourteen [published test chains](https://emudhra.we-have-a-problem.com/chains.zip), including the AAA cross-signs and two chains without Certificate Transparency entries, must fail certificate verification through the effective stores. Confirm that an ordinary known-good HTTPS request still succeeds. In Chromium, confirm the managed policy is loaded at `chrome://policy` and test the incident endpoints through the actual browser path with certificate verification enabled. The PEM checker cannot inspect Chromium's policy or its active verifier. Package versions and a successful image build alone do not prove that a running deployment selects the corrected store.
|
||||
|
||||
## Follow-up releases
|
||||
|
||||
The maintained bootstrap selects certifi 2026.10.23 with its verified wheel hash; pip remains 26.2.2. The certifi update removes the C1/C3 roots in addition to the earlier incident changes. CI uses uv 0.13.5, which adds verifier-level rejection of the compromised intermediate keys and updated managed CPython builds. Existing virtual environments do not update automatically. See the [certifi change](https://github.com/certifi/python-certifi/commit/b60466c9f2882d9cf088943c650b0faac06c080c), [uv release](https://github.com/astral-sh/uv/releases/tag/0.13.5), and [Python security releases](https://blog.python.org/2026/10/python-3151-3149-31317/). The pinned bootstrap still replaces the selected versions exactly; preserving newer installed versions and private-mirror support remain separate maintenance.
|
||||
|
||||
SearXNG remains an independent trust owner. Use the [derived-image validation and rollout instructions](searxng-trust-image.md); updating these application packages does not repair an existing sidecar. Host daemons, native browser policy, optional compiled clients and independently managed integrations require their own effective-client checks.
|
||||
@@ -100,6 +100,14 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory
|
||||
- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`.
|
||||
- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`.
|
||||
|
||||
## Chat history
|
||||
|
||||
- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope.
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID
|
||||
```
|
||||
|
||||
## Email draft + send
|
||||
|
||||
- Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small Odysseus scoped API helper for Codex terminal sessions."""
|
||||
"""Small Odysseus scoped API helper for Claude Code terminal sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,7 +44,7 @@ def _config() -> tuple[str, str] | None:
|
||||
if not token:
|
||||
missing.append("ODYSSEUS_API_TOKEN")
|
||||
if missing:
|
||||
print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr)
|
||||
print(f"missing {', '.join(missing)}; create a Claude Agent token in Odysseus Settings", file=sys.stderr)
|
||||
return None
|
||||
return base_url, token
|
||||
|
||||
|
||||
@@ -100,6 +100,14 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex
|
||||
- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`.
|
||||
- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`.
|
||||
|
||||
## Chat history
|
||||
|
||||
- `GET /api/codex/chat/{session_id}?offset=0&limit=100` — read a paginated conversation transcript owned by the token owner. Requires the existing `chat` scope.
|
||||
|
||||
```bash
|
||||
python3 integrations/codex/scripts/odysseus_api.py GET /api/codex/chat/SESSION_ID
|
||||
```
|
||||
|
||||
## Email draft + send
|
||||
|
||||
- Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
|
||||
|
||||
+2
-1
@@ -131,7 +131,8 @@ if (-not (Test-Path $venvPy)) {
|
||||
|
||||
# 3. Install / update dependencies
|
||||
Write-Step "Installing dependencies (first run can take a few minutes)"
|
||||
& $venvPy -m pip install --upgrade pip --quiet
|
||||
& $venvPy -m pip install --quiet --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
if ($LASTEXITCODE -ne 0) { Fail "TLS dependency bootstrap failed." }
|
||||
& $venvPy -m pip install -r requirements.txt
|
||||
if ($LASTEXITCODE -ne 0) { Fail "Dependency install failed. Scroll up for the pip error." }
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Integrity-pinned bootstrap for the October 2026 eMudhra CA incident.
|
||||
# Install with --require-hashes --only-binary=:all: --no-deps before other pip work.
|
||||
# pip vendors its own certifi; updating the application bundle cannot update it.
|
||||
# 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/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl \
|
||||
--hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1
|
||||
@@ -3,6 +3,8 @@ uvicorn
|
||||
python-multipart
|
||||
python-dotenv
|
||||
httpx
|
||||
# HTTPS clients using certifi must distrust the compromised eMudhra CA paths.
|
||||
certifi>=2026.10.23
|
||||
httpcore>=1.0,<2.0
|
||||
pydantic>=2.13.4
|
||||
pydantic-settings>=2.14.1
|
||||
@@ -21,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,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)
|
||||
|
||||
@@ -25,13 +25,16 @@ import json
|
||||
import re
|
||||
import html
|
||||
import io
|
||||
import tempfile
|
||||
import zipfile
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
from html.parser import HTMLParser as _HTMLParser
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from email.message import EmailMessage
|
||||
from email import policy as email_policy
|
||||
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
@@ -636,10 +639,15 @@ def _message_id_chain(*values: str) -> list[str]:
|
||||
|
||||
|
||||
def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
||||
m = re.search(rb"\bUID\s+(\d+)\b", meta_b)
|
||||
m = re.search(rb"\bUID\s+(\d+)\b", meta_b, re.IGNORECASE)
|
||||
return m.group(1).decode() if m else ""
|
||||
|
||||
|
||||
def _size_from_fetch_meta(meta_b: bytes) -> int | None:
|
||||
m = re.search(rb"\bRFC822\.SIZE\s+(\d+)\b", meta_b, re.IGNORECASE)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
|
||||
"""Parse RFC List-Unsubscribe entries into safe reviewable actions.
|
||||
|
||||
@@ -4185,6 +4193,414 @@ def setup_email_routes():
|
||||
headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
|
||||
)
|
||||
|
||||
@router.post("/messages-download-bulk")
|
||||
async def download_bulk_messages(request: Request, owner: str = Depends(require_owner)):
|
||||
"""Export selected original email messages as .eml files in a ZIP."""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
messages = payload.get("messages") if isinstance(payload, dict) else None
|
||||
if not isinstance(messages, list) or not messages:
|
||||
raise HTTPException(status_code=400, detail="No emails selected")
|
||||
if len(messages) > 250:
|
||||
raise HTTPException(status_code=400, detail="Too many emails selected")
|
||||
|
||||
category = str(payload.get("category") or "selected").strip().lower()
|
||||
category = re.sub(r"[^a-z0-9_-]+", "-", category).strip("-") or "selected"
|
||||
selected_dates = sorted({
|
||||
str(row.get("date") or "").strip()
|
||||
for row in messages if isinstance(row, dict)
|
||||
and re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(row.get("date") or "").strip())
|
||||
})
|
||||
date_span = ""
|
||||
if selected_dates:
|
||||
date_span = selected_dates[0] if len(selected_dates) == 1 else f"{selected_dates[0]} - {selected_dates[-1]}"
|
||||
export_folder = f"emails-{category}" + (f" ({date_span})" if date_span else "")
|
||||
|
||||
# 512 MiB safety cap prevents unbounded memory growth when exporting
|
||||
# many large messages.
|
||||
max_total_bytes = 512 * 1024 * 1024
|
||||
max_message_bytes = 64 * 1024 * 1024
|
||||
spool_threshold_bytes = 32 * 1024 * 1024
|
||||
used_names: set[str] = set()
|
||||
exported = 0
|
||||
skipped = 0
|
||||
total_bytes = 0
|
||||
validated_accounts: set[str] = set()
|
||||
|
||||
def _email_filename(row: dict, uid: str) -> str:
|
||||
date = str(row.get("date") or "").strip()
|
||||
date = date if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) else ""
|
||||
subject = str(row.get("subject") or "email").strip()
|
||||
subject = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]+', "_", subject)
|
||||
subject = re.sub(r"\s+", " ", subject).strip(" .")[:100] or "email"
|
||||
safe_uid = re.sub(r"[^A-Za-z0-9_-]+", "_", uid).strip("_") or "message"
|
||||
prefix = f"{date} - " if date else ""
|
||||
return _safe_attachment_zip_name(f"{prefix}{subject} - {safe_uid}.eml", "email.eml")
|
||||
|
||||
def _fixture_message_bytes(fixture: dict, uid: str, folder: str) -> bytes:
|
||||
msg = EmailMessage()
|
||||
sender_name = str(fixture.get("from_name") or "").strip()
|
||||
sender_address = str(fixture.get("from_address") or "").strip()
|
||||
sender = email.utils.formataddr((sender_name, sender_address)) if sender_address else sender_name
|
||||
for key, value in (
|
||||
("From", sender),
|
||||
("To", fixture.get("to") or owner),
|
||||
("Subject", fixture.get("subject") or "(no subject)"),
|
||||
("Message-ID", fixture.get("message_id") or f"<fixture-{uid}@mail.local>"),
|
||||
):
|
||||
clean_value = re.sub(r"[\r\n]+", " ", str(value or "")).strip()
|
||||
if clean_value:
|
||||
msg[key] = clean_value
|
||||
raw_date = str(fixture.get("date") or "").strip()
|
||||
if raw_date:
|
||||
try:
|
||||
parsed_date = datetime.fromisoformat(raw_date.replace("Z", "+00:00"))
|
||||
if parsed_date.tzinfo is None:
|
||||
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
try:
|
||||
parsed_date = email.utils.parsedate_to_datetime(raw_date)
|
||||
except Exception:
|
||||
parsed_date = None
|
||||
if parsed_date is not None:
|
||||
msg["Date"] = email.utils.format_datetime(parsed_date)
|
||||
msg.set_content(str(fixture.get("body") or ""))
|
||||
for attachment in fixture.get("attachments") or []:
|
||||
try:
|
||||
index = int(attachment.get("index"))
|
||||
source = _fixture_attachment_source(uid, index, owner, folder)
|
||||
if not source:
|
||||
continue
|
||||
_row, raw_attachment = source
|
||||
content_type = str(raw_attachment.get("content_type") or "application/octet-stream")
|
||||
maintype, _, subtype = content_type.partition("/")
|
||||
if not subtype:
|
||||
maintype, subtype = "application", "octet-stream"
|
||||
msg.add_attachment(
|
||||
str(raw_attachment.get("content") or "").encode("utf-8"),
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
filename=str(raw_attachment.get("filename") or attachment.get("filename") or f"attachment-{index}"),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Skipping malformed fixture email attachment uid=%s", uid, exc_info=True)
|
||||
return msg.as_bytes(policy=email_policy.SMTP)
|
||||
|
||||
# Optimization: single message exports return raw .eml directly without ZIP compression.
|
||||
if len(messages) == 1:
|
||||
row = messages[0]
|
||||
if not isinstance(row, dict):
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
uid = str(row.get("uid") or "").strip()
|
||||
if not uid:
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
folder = str(row.get("folder") or "INBOX").strip() or "INBOX"
|
||||
account_id = str(row.get("account_id") or row.get("account") or "").strip() or None
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
|
||||
filename = _email_filename(row, uid)
|
||||
message_bytes: bytes | None = None
|
||||
|
||||
fixture = _fixture_email_read(uid, folder, owner)
|
||||
if fixture is not None and not fixture.get("error"):
|
||||
message_bytes = _fixture_message_bytes(fixture, uid, folder)
|
||||
elif re.fullmatch(r"\d+", uid):
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder), readonly=True)
|
||||
uid_b = uid.encode()
|
||||
st_sz, sz_data = None, None
|
||||
try:
|
||||
st_sz, sz_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822.SIZE)")
|
||||
except Exception as sz_err:
|
||||
logger.warning("Single message size preflight query error for uid=%s: %s", uid, sz_err)
|
||||
|
||||
if st_sz != "OK":
|
||||
logger.warning("Single message size preflight status=%s for uid=%s", st_sz, uid)
|
||||
raise HTTPException(status_code=502, detail="Unable to verify email size safety")
|
||||
|
||||
if not sz_data:
|
||||
# Message does not exist in mailbox (missing mail)
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
|
||||
sz: int | None = None
|
||||
for item in sz_data:
|
||||
meta = item[0] if isinstance(item, tuple) else item
|
||||
if isinstance(meta, bytes):
|
||||
u = _uid_from_fetch_meta(meta)
|
||||
if u == uid:
|
||||
sz = _size_from_fetch_meta(meta)
|
||||
break
|
||||
|
||||
if sz is None or sz < 0:
|
||||
logger.warning("Single message size preflight returned invalid/malformed size for uid=%s", uid)
|
||||
raise HTTPException(status_code=502, detail="Invalid email size metadata from server")
|
||||
|
||||
if sz > max_message_bytes or sz > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
|
||||
# Preflight size safety established; perform RFC822 fetch.
|
||||
st, msg_data = _imap_uid_fetch(conn, uid_b, "(UID RFC822)")
|
||||
if st == "OK" and msg_data:
|
||||
for meta, raw in _group_uid_fetch_records(msg_data):
|
||||
if _uid_from_fetch_meta(meta) == uid and raw:
|
||||
message_bytes = bytes(raw)
|
||||
break
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Single email export failed for uid=%s folder=%s: %s", uid, folder, exc)
|
||||
|
||||
if not message_bytes:
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
|
||||
if len(message_bytes) > max_message_bytes or len(message_bytes) > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(message_bytes),
|
||||
media_type="message/rfc822",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=\"email.eml\"; filename*=UTF-8''{quote(filename, safe='')}",
|
||||
"X-Exported-Emails": "1",
|
||||
"X-Skipped-Emails": "0",
|
||||
},
|
||||
)
|
||||
|
||||
seen_items: set[tuple[str | None, str, str]] = set()
|
||||
remote_groups: dict[tuple[str | None, str], dict[str, tuple[str, dict]]] = {}
|
||||
spooled_file = tempfile.SpooledTemporaryFile(max_size=spool_threshold_bytes, mode="w+b")
|
||||
response_started = False
|
||||
try:
|
||||
with zipfile.ZipFile(spooled_file, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for row in messages:
|
||||
if not isinstance(row, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
uid = str(row.get("uid") or "").strip()
|
||||
if not uid:
|
||||
skipped += 1
|
||||
continue
|
||||
folder = str(row.get("folder") or "INBOX").strip() or "INBOX"
|
||||
account_id = str(row.get("account_id") or row.get("account") or "").strip() or None
|
||||
if account_id and account_id not in validated_accounts:
|
||||
_assert_owns_account(account_id, owner)
|
||||
validated_accounts.add(account_id)
|
||||
|
||||
item_key = (account_id, folder, uid)
|
||||
if item_key in seen_items:
|
||||
skipped += 1
|
||||
continue
|
||||
seen_items.add(item_key)
|
||||
|
||||
fixture = _fixture_email_read(uid, folder, owner)
|
||||
if fixture is not None and not fixture.get("error"):
|
||||
filename = _email_filename(row, uid)
|
||||
unique_name = filename
|
||||
suffix = 2
|
||||
while unique_name.lower() in used_names:
|
||||
unique_name = f"{Path(filename).stem}-{suffix}.eml"
|
||||
suffix += 1
|
||||
used_names.add(unique_name.lower())
|
||||
message_bytes = _fixture_message_bytes(fixture, uid, folder)
|
||||
if len(message_bytes) > max_message_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
total_bytes += len(message_bytes)
|
||||
if total_bytes > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
try:
|
||||
zf.writestr(f"{export_folder}/{unique_name}", message_bytes)
|
||||
except Exception as zip_err:
|
||||
logger.error("ZIP entry write failed for fixture: %s", zip_err)
|
||||
raise HTTPException(status_code=500, detail="Failed to create export archive")
|
||||
exported += 1
|
||||
continue
|
||||
if not re.fullmatch(r"\d+", uid):
|
||||
skipped += 1
|
||||
continue
|
||||
group = remote_groups.setdefault((account_id, folder), {})
|
||||
group.setdefault(uid, (_email_filename(row, uid), row))
|
||||
|
||||
preflight_error: HTTPException | None = None
|
||||
|
||||
# Fetch original RFC822 data in batches and store it byte-for-byte.
|
||||
# Note on IMAP memory-safety: Python's standard imaplib buffers socket responses into
|
||||
# memory before returning literals to application code. Preflight verification ensures
|
||||
# we do not intentionally fetch messages exceeding limits, but an untrusted or non-compliant
|
||||
# IMAP server sending unexpectedly large literals cannot be bounded at the transport level
|
||||
# without custom socket streaming. Post-fetch checks guard application-level storage.
|
||||
for (account_id, folder), rows_by_uid in remote_groups.items():
|
||||
rows = list(rows_by_uid.items())
|
||||
remaining_rows = len(rows)
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder), readonly=True)
|
||||
|
||||
# Size preflight query to catch oversized exports early and bound batch sizes.
|
||||
uid_set_all = ",".join(uid for uid, _ in rows).encode()
|
||||
st_sz, sz_data = None, None
|
||||
try:
|
||||
st_sz, sz_data = _imap_uid_fetch(conn, uid_set_all, "(UID RFC822.SIZE)")
|
||||
except Exception as sz_err:
|
||||
logger.warning("IMAP size preflight query failed for folder=%s: %s", folder, sz_err)
|
||||
|
||||
if st_sz != "OK":
|
||||
logger.warning("IMAP size preflight status=%s for folder=%s account=%s", st_sz, folder, account_id)
|
||||
preflight_error = HTTPException(status_code=502, detail="Unable to verify email size safety")
|
||||
skipped += remaining_rows
|
||||
remaining_rows = 0
|
||||
continue
|
||||
|
||||
if not sz_data:
|
||||
# All requested UIDs in this mailbox are missing on server
|
||||
skipped += remaining_rows
|
||||
remaining_rows = 0
|
||||
continue
|
||||
|
||||
reported_sizes: dict[str, int | None] = {}
|
||||
for item in sz_data:
|
||||
meta = item[0] if isinstance(item, tuple) else item
|
||||
if isinstance(meta, bytes):
|
||||
u = _uid_from_fetch_meta(meta)
|
||||
if u:
|
||||
reported_sizes[u] = _size_from_fetch_meta(meta)
|
||||
|
||||
# Categorize requested UIDs
|
||||
malformed_uids = [
|
||||
uid for uid, _ in rows
|
||||
if uid in reported_sizes and (reported_sizes[uid] is None or reported_sizes[uid] < 0)
|
||||
]
|
||||
if malformed_uids:
|
||||
logger.warning("IMAP size preflight returned invalid/malformed size for uids=%s in folder=%s", malformed_uids, folder)
|
||||
preflight_error = HTTPException(status_code=502, detail="Invalid email size metadata from server")
|
||||
skipped += remaining_rows
|
||||
remaining_rows = 0
|
||||
continue
|
||||
|
||||
# Identify missing mail (requested UIDs not returned in preflight)
|
||||
missing_uids = {uid for uid, _ in rows if uid not in reported_sizes}
|
||||
if missing_uids:
|
||||
skipped += len(missing_uids)
|
||||
remaining_rows -= len(missing_uids)
|
||||
rows = [item for item in rows if item[0] not in missing_uids]
|
||||
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
size_by_uid: dict[str, int] = {
|
||||
uid: reported_sizes[uid] for uid, _ in rows
|
||||
}
|
||||
|
||||
for u, s in size_by_uid.items():
|
||||
if s > max_message_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
if total_bytes + sum(size_by_uid.values()) > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
|
||||
# Form batches with at most 20 messages and capped at 10 MiB payload.
|
||||
batches: list[list[tuple[str, tuple[str, dict]]]] = []
|
||||
cur_batch: list[tuple[str, tuple[str, dict]]] = []
|
||||
cur_batch_bytes = 0
|
||||
max_batch_bytes = 10 * 1024 * 1024
|
||||
for item in rows:
|
||||
uid_val = item[0]
|
||||
sz = size_by_uid[uid_val]
|
||||
if cur_batch and (
|
||||
len(cur_batch) >= 20 or
|
||||
(cur_batch_bytes + sz > max_batch_bytes)
|
||||
):
|
||||
batches.append(cur_batch)
|
||||
cur_batch = []
|
||||
cur_batch_bytes = 0
|
||||
cur_batch.append(item)
|
||||
cur_batch_bytes += sz
|
||||
if cur_batch:
|
||||
batches.append(cur_batch)
|
||||
|
||||
for batch in batches:
|
||||
wanted = {uid: filename for uid, (filename, _row) in batch}
|
||||
uid_set = ",".join(wanted).encode()
|
||||
status, msg_data = _imap_uid_fetch(conn, uid_set, "(UID RFC822)")
|
||||
if status != "OK" or not msg_data:
|
||||
skipped += len(batch)
|
||||
remaining_rows -= len(batch)
|
||||
continue
|
||||
found: set[str] = set()
|
||||
for meta, raw_message in _group_uid_fetch_records(msg_data):
|
||||
uid = _uid_from_fetch_meta(meta)
|
||||
if not uid or uid not in wanted or not raw_message:
|
||||
continue
|
||||
if uid in found:
|
||||
# P2: Reject duplicate IMAP response records for an already-processed UID
|
||||
logger.debug("Ignoring duplicate IMAP FETCH record for uid=%s in folder=%s", uid, folder)
|
||||
continue
|
||||
filename = wanted[uid]
|
||||
unique_name = filename
|
||||
suffix = 2
|
||||
while unique_name.lower() in used_names:
|
||||
unique_name = f"{Path(filename).stem}-{suffix}.eml"
|
||||
suffix += 1
|
||||
used_names.add(unique_name.lower())
|
||||
message_bytes = bytes(raw_message)
|
||||
if len(message_bytes) > max_message_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
total_bytes += len(message_bytes)
|
||||
if total_bytes > max_total_bytes:
|
||||
raise HTTPException(status_code=413, detail="Export too large")
|
||||
try:
|
||||
zf.writestr(f"{export_folder}/{unique_name}", message_bytes)
|
||||
except Exception as zip_err:
|
||||
logger.error("ZIP entry write failed for remote message: %s", zip_err)
|
||||
raise HTTPException(status_code=500, detail="Failed to create export archive")
|
||||
found.add(uid)
|
||||
exported += 1
|
||||
remaining_rows -= 1
|
||||
missing = len(batch) - len(found)
|
||||
skipped += len(batch) - len(found)
|
||||
remaining_rows -= missing
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
skipped += remaining_rows
|
||||
logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, remaining_rows, exc)
|
||||
|
||||
if preflight_error is not None:
|
||||
raise preflight_error
|
||||
|
||||
if exported <= 0:
|
||||
raise HTTPException(status_code=404, detail="No selected emails could be exported")
|
||||
|
||||
spooled_file.seek(0)
|
||||
zip_name = _safe_attachment_zip_name(f"{export_folder}.zip", "selected-emails.zip")
|
||||
logger.info("Bulk email export complete owner=%s selected=%s exported=%s skipped=%s", owner, len(messages), exported, skipped)
|
||||
|
||||
def _iter_spooled(f, chunk_size=64 * 1024):
|
||||
try:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
response_started = True
|
||||
return StreamingResponse(
|
||||
_iter_spooled(spooled_file),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{zip_name}"',
|
||||
"X-Exported-Emails": str(exported),
|
||||
"X-Skipped-Emails": str(skipped),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if not response_started:
|
||||
spooled_file.close()
|
||||
|
||||
@router.get("/inline-image/{uid}")
|
||||
async def inline_image(
|
||||
uid: str,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reject the eMudhra incident's known CA certificates in PEM trust stores.
|
||||
|
||||
This checks certificate contents, including OpenSSL's lazily loaded CA directory.
|
||||
It does not model native OS certificate revocation or arbitrary custom clients.
|
||||
Incident fingerprints: https://emudhra.we-have-a-problem.com/roots.json
|
||||
and https://emudhra.we-have-a-problem.com/impacted-intermediates.json
|
||||
Cross-signed issuer: https://emudhra.we-have-a-problem.com/chains.zip
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
|
||||
BLOCKED_CERTIFICATES = {
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (cross-signed)",
|
||||
# Intermediates can themselves be operator-supplied trust anchors. Include
|
||||
# both published signatures for each key, without parsing SPKI in stdlib.
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)",
|
||||
}
|
||||
OVERRIDE_ENV_VARS = (
|
||||
"LLM_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
|
||||
"PIP_CERT", "NODE_EXTRA_CA_CERTS", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH",
|
||||
)
|
||||
|
||||
|
||||
def check_bundle(path: Path) -> None:
|
||||
"""Raise on an unreadable, empty, malformed, or affected CA bundle."""
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.load_verify_locations(cafile=str(path))
|
||||
# A hashed OpenSSL directory can include valid non-CA certificates (for
|
||||
# example Ubuntu's snakeoil server certificate). get_ca_certs() omits them;
|
||||
# use the total X.509 count to distinguish these from an empty store.
|
||||
if not context.cert_store_stats()["x509"]:
|
||||
raise ValueError("no certificates")
|
||||
certificates = context.get_ca_certs(binary_form=True)
|
||||
blocked = {
|
||||
BLOCKED_CERTIFICATES[fingerprint]
|
||||
for certificate in certificates
|
||||
if (fingerprint := hashlib.sha256(certificate).hexdigest()) in BLOCKED_CERTIFICATES
|
||||
}
|
||||
if blocked:
|
||||
raise ValueError("affected CA certificates: " + ", ".join(sorted(blocked)))
|
||||
|
||||
|
||||
def ca_directory_paths(directory: Path) -> list[Path]:
|
||||
"""Return the hashed certificate files consumed by OpenSSL's CApath."""
|
||||
return [
|
||||
path for path in directory.iterdir()
|
||||
if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", path.name) and path.is_file()
|
||||
]
|
||||
|
||||
|
||||
def system_paths() -> list[Path]:
|
||||
"""Inspect both the effective OpenSSL CA file and hashed directory entries."""
|
||||
defaults = ssl.get_default_verify_paths()
|
||||
paths = [Path(defaults.cafile)] if defaults.cafile else []
|
||||
# get_default_verify_paths() can report None for a colon-separated
|
||||
# SSL_CERT_DIR even though OpenSSL consumes each directory in that list.
|
||||
capath = os.environ.get(defaults.openssl_capath_env, defaults.capath)
|
||||
if capath:
|
||||
for directory in filter(None, capath.split(os.pathsep)):
|
||||
paths.extend(ca_directory_paths(Path(directory)))
|
||||
if not paths:
|
||||
raise ValueError("no effective OpenSSL PEM CA store found")
|
||||
return paths
|
||||
|
||||
|
||||
def override_paths() -> list[Path]:
|
||||
"""Inspect every configured override, including client fallback settings."""
|
||||
paths = []
|
||||
for name in OVERRIDE_ENV_VARS:
|
||||
value = os.environ.get(name, "")
|
||||
if name == "LLM_CA_BUNDLE":
|
||||
value = value.strip() # Match src/tls_overrides.py.
|
||||
if not value:
|
||||
continue
|
||||
path = Path(value)
|
||||
# Requests (also used by pip) accepts either a file or a hashed directory.
|
||||
if name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT") and path.is_dir():
|
||||
entries = ca_directory_paths(path)
|
||||
if not entries:
|
||||
raise ValueError(f"{name}: no hashed certificates in {path}")
|
||||
paths.extend(entries)
|
||||
else:
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def grpc_roots_path() -> Path:
|
||||
"""Select gRPC's override or its independent Python-wheel default bundle."""
|
||||
override = os.environ.get("GRPC_DEFAULT_SSL_ROOTS_FILE_PATH")
|
||||
if override:
|
||||
# gRPC can fall back to its bundled roots when this file is unreadable.
|
||||
# Check the configured file strictly instead of blessing that fallback.
|
||||
return Path(override)
|
||||
spec = importlib.util.find_spec("grpc")
|
||||
if spec is None or spec.origin is None:
|
||||
raise ImportError("grpcio is not installed; cannot check its default roots")
|
||||
return Path(spec.origin).parent / "_cython" / "_credentials" / "roots.pem"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("bundles", nargs="*", type=Path)
|
||||
parser.add_argument("--system", action="store_true", help="check effective OpenSSL PEM stores")
|
||||
parser.add_argument("--python", action="store_true", help="check certifi and pip's separate vendored bundle")
|
||||
parser.add_argument("--grpc", action="store_true", help="check gRPC's selected default CA file")
|
||||
parser.add_argument("--overrides", action="store_true", help="check configured LLM/HTTPX/Requests/pip/Node/gRPC CA overrides")
|
||||
parser.add_argument("--verbose", action="store_true", help="show the runtime identity, Python executable and selected CA paths")
|
||||
args = parser.parse_args(argv)
|
||||
if not (args.bundles or args.system or args.python or args.grpc or args.overrides):
|
||||
parser.error("select --system, --python, --grpc, --overrides, or explicit PEM bundle paths")
|
||||
if args.verbose:
|
||||
print(f"Python executable: {sys.executable}")
|
||||
print(f"Python prefix: {sys.prefix}")
|
||||
print(f"User home: {Path.home()}")
|
||||
if hasattr(os, "geteuid"):
|
||||
print(f"Effective user: uid={os.geteuid()} gid={os.getegid()}")
|
||||
try:
|
||||
paths = list(args.bundles)
|
||||
if args.system:
|
||||
paths.extend(system_paths())
|
||||
if args.python:
|
||||
for name in ("certifi", "pip._vendor.certifi"):
|
||||
paths.append(Path(importlib.import_module(name).where()))
|
||||
if args.grpc:
|
||||
paths.append(grpc_roots_path())
|
||||
if args.overrides:
|
||||
paths.extend(override_paths())
|
||||
paths = list(dict.fromkeys(path.resolve() for path in paths))
|
||||
if not paths:
|
||||
raise ValueError("no CA bundle files selected; also select --system, --python, or an explicit path")
|
||||
errors = []
|
||||
for path in paths:
|
||||
if args.verbose:
|
||||
print(f"CA bundle: {path}")
|
||||
try:
|
||||
check_bundle(path)
|
||||
except (OSError, ValueError, ssl.SSLError) as exc:
|
||||
errors.append(f"{path}: {exc}")
|
||||
if errors:
|
||||
raise ValueError("\n".join(errors))
|
||||
except (ImportError, OSError, ValueError, ssl.SSLError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
print(f"Validated {len(paths)} CA bundle file(s): no known eMudhra incident CA certificates")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
@@ -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 "")
|
||||
|
||||
@@ -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")
|
||||
|
||||
+2
-2
@@ -140,11 +140,11 @@ if [ ! -x "$VENV_PY" ] || ! "$VENV_PY" -m pip --version >/dev/null 2>&1; then
|
||||
echo "▶ Creating Python environment…"
|
||||
"$PY" -m venv venv
|
||||
fi
|
||||
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)"
|
||||
REQ_HASH="$("$VENV_PY" -c 'import hashlib, pathlib; print(hashlib.sha256(b"".join(pathlib.Path(p).read_bytes() for p in ("requirements.txt", "requirements-bootstrap.txt"))).hexdigest())')"
|
||||
REQ_HASH_FILE="venv/.requirements_hash"
|
||||
if [ ! -f "$REQ_HASH_FILE" ] || [ "$REQ_HASH" != "$(cat "$REQ_HASH_FILE" 2>/dev/null)" ]; then
|
||||
echo "▶ Installing Python packages (first run downloads a few — can take a few minutes)…"
|
||||
"$VENV_PY" -m pip install --quiet --upgrade pip
|
||||
"$VENV_PY" -m pip install --quiet --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
# Not --quiet: this is the slow step, so show progress (and any real errors).
|
||||
"$VENV_PY" -m pip install -r requirements.txt
|
||||
echo "$REQ_HASH" > "$REQ_HASH_FILE"
|
||||
|
||||
@@ -2690,14 +2690,11 @@ body.theme-frosted .modal {
|
||||
max-height: 95dvh !important;
|
||||
}
|
||||
|
||||
/* Notes and Documents must respect the same viewport and safe-area
|
||||
top as other mobile full-height sheets. Keep these authoritative rules
|
||||
consistent with 05-documents.css and 14-email.css. */
|
||||
body.doc-view .doc-editor-pane,
|
||||
body.notes-view .notes-pane {
|
||||
inset: var(--sheet-top) 0 0 !important;
|
||||
height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
inset: auto 0 0 !important;
|
||||
height: 95dvh !important;
|
||||
max-height: 95dvh !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
|
||||
(function(){
|
||||
var ua = navigator.userAgent || '';
|
||||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
||||
var vp = document.querySelector('meta[name="viewport"]');
|
||||
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
|
||||
})();
|
||||
(function(){
|
||||
try {
|
||||
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
||||
|
||||
@@ -1123,7 +1123,175 @@ function _filenameFromContentDisposition(header) {
|
||||
return bare ? bare[1].trim() : '';
|
||||
}
|
||||
|
||||
export async function _exportSelectedAttachments() {
|
||||
async function _printSelectedEmails(messages) {
|
||||
if (messages.length > 20) throw new Error('Print / save PDF supports up to 20 emails at a time');
|
||||
if (typeof window.print !== 'function') throw new Error('Browser printing is unavailable');
|
||||
|
||||
const loaded = new Array(messages.length);
|
||||
let nextIndex = 0;
|
||||
const readWorker = async () => {
|
||||
while (nextIndex < messages.length) {
|
||||
const index = nextIndex++;
|
||||
const message = messages[index];
|
||||
try {
|
||||
const account = message.account_id ? `&account_id=${encodeURIComponent(message.account_id)}` : '';
|
||||
const url = `${API_BASE}/api/email/read/${encodeURIComponent(message.uid)}?folder=${encodeURIComponent(message.folder || 'INBOX')}${account}&mark_seen=false&full=true`;
|
||||
const response = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data?.error) throw new Error(data.error);
|
||||
loaded[index] = { ok: true, message, data };
|
||||
} catch (err) {
|
||||
loaded[index] = { ok: false, message, error: err?.message || 'Failed to load' };
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(4, messages.length) }, readWorker));
|
||||
|
||||
const successful = loaded.filter(item => item && item.ok);
|
||||
const omitted = loaded.filter(item => !item || !item.ok);
|
||||
|
||||
if (successful.length === 0) {
|
||||
throw new Error('None of the selected emails could be loaded for printing');
|
||||
}
|
||||
|
||||
// Build sanitized HTML for the print frame -- scripts and event handlers
|
||||
// cannot execute inside the sandboxed iframe.
|
||||
const articles = [];
|
||||
if (omitted.length > 0) {
|
||||
const count = omitted.length;
|
||||
articles.push(
|
||||
`<div class="email-export-pdf-omitted-notice">Note: ${count} selected email${count === 1 ? ' was' : 's were'} unavailable and omitted from this print.</div>`
|
||||
);
|
||||
}
|
||||
|
||||
for (const { message, data } of successful) {
|
||||
const subject = String(data.subject || message.subject || '(no subject)');
|
||||
const from = data.from_name && data.from_address
|
||||
? `${data.from_name} <${data.from_address}>`
|
||||
: data.from_name || data.from_address;
|
||||
|
||||
const safeBody = new DOMParser().parseFromString(_safeRenderEmailBody(data), 'text/html');
|
||||
|
||||
// Replace interactive image placeholder components with clean print-safe notices
|
||||
safeBody.querySelectorAll('.email-inline-image-placeholder').forEach(ph => {
|
||||
const title = ph.querySelector('.email-inline-image-title')?.textContent?.trim();
|
||||
const ariaLabel = ph.getAttribute('aria-label')?.trim();
|
||||
const label = title || ariaLabel || '';
|
||||
const isGeneric = !label || /^inline image$|^remote image$|^image unavailable$|^image$/i.test(label);
|
||||
const span = safeBody.createElement('span');
|
||||
span.className = 'email-export-image-notice';
|
||||
span.textContent = isGeneric ? '[Image omitted]' : `[Image: ${label}]`;
|
||||
ph.replaceWith(span);
|
||||
});
|
||||
|
||||
// Replace any remaining raw <img> tags
|
||||
safeBody.querySelectorAll('img').forEach(img => {
|
||||
const alt = (img.getAttribute('alt') || img.getAttribute('title') || '').trim();
|
||||
const span = safeBody.createElement('span');
|
||||
span.className = 'email-export-image-notice';
|
||||
span.textContent = alt ? `[Image: ${alt}]` : '[Image omitted]';
|
||||
img.replaceWith(span);
|
||||
});
|
||||
|
||||
// Strip interactive controls (buttons, inputs, actions) from the print document
|
||||
safeBody.querySelectorAll('button, input, select, textarea, .email-inline-image-actions, .email-inline-image-btn').forEach(el => el.remove());
|
||||
|
||||
safeBody.querySelectorAll('details').forEach(detail => { detail.open = true; });
|
||||
const bodyHtml = safeBody.body.innerHTML;
|
||||
|
||||
const fieldParts = [];
|
||||
for (const [lbl, val] of [['From', from], ['To', data.to], ['Cc', data.cc], ['Date', data.date_display || data.date || message.date]]) {
|
||||
if (!val) continue;
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
fieldParts.push(`<div class="email-export-pdf-field"><span class="email-export-pdf-label">${esc(lbl)}</span><span>${esc(val)}</span></div>`);
|
||||
}
|
||||
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
articles.push(
|
||||
`<article class="email-export-pdf-message">` +
|
||||
`<h1>${esc(subject)}</h1>` +
|
||||
`<div class="email-export-pdf-fields">${fieldParts.join('')}</div>` +
|
||||
`<div class="email-export-pdf-body">${bodyHtml}</div>` +
|
||||
`</article>`
|
||||
);
|
||||
}
|
||||
|
||||
const printCss = [
|
||||
'html,body{background:#fff;color:#171717;font:12px/1.55 Arial,Helvetica,sans-serif;margin:0;padding:0}',
|
||||
'.email-export-pdf{max-width:794px;margin:0 auto;padding:38px;box-sizing:border-box}',
|
||||
'.email-export-pdf-message{page-break-after:always;break-after:page}',
|
||||
'.email-export-pdf-message:last-child{page-break-after:auto;break-after:auto}',
|
||||
'.email-export-pdf-message h1{margin:0 0 18px;font-size:20px;line-height:1.3;overflow-wrap:anywhere}',
|
||||
'.email-export-pdf-fields{margin:0 0 20px;padding:0 0 14px;border-bottom:1px solid #d8d8d8}',
|
||||
'.email-export-pdf-field{display:flex;gap:10px;margin:3px 0;overflow-wrap:anywhere}',
|
||||
'.email-export-pdf-label{width:54px;flex:0 0 54px;color:#666}',
|
||||
'.email-export-pdf-body{overflow-wrap:anywhere;word-break:break-word}',
|
||||
'.email-export-pdf-body img{max-width:100%}',
|
||||
'.email-export-pdf-body a{color:#135f9b;text-decoration:underline}',
|
||||
'.email-export-pdf-body blockquote{margin-left:12px;padding-left:10px;border-left:2px solid #ccc;color:#555}',
|
||||
'.email-export-pdf-body pre{white-space:pre-wrap}',
|
||||
'.email-inline-image-placeholder,.email-inline-image-actions,.email-inline-image-btn{display:none !important}',
|
||||
'.email-export-image-notice{display:inline-block;color:#666;font-style:italic;font-size:11px;margin:4px 0}',
|
||||
'.email-export-pdf-omitted-notice{margin:0 0 16px;padding:8px 12px;background:#f8f9fa;border:1px solid #e2e8f0;border-radius:4px;font-size:11px;color:#64748b}',
|
||||
'@page{margin:10mm}',
|
||||
].join('\n');
|
||||
|
||||
const safeBaseName = successful.length === 1
|
||||
? String(successful[0]?.data?.subject || successful[0]?.message?.subject || 'email').replace(/[^\p{L}\p{N} ._-]+/gu, '_').trim().slice(0, 100) || 'email'
|
||||
: 'selected-emails';
|
||||
|
||||
// Use a sandboxed same-origin iframe following the established document.js
|
||||
// Print / save PDF pattern (SAN-157). The browser opens a print dialog;
|
||||
// saving, cancellation and pagination belong to the browser.
|
||||
document.getElementById('email-browser-print-frame')?.remove();
|
||||
const frame = document.createElement('iframe');
|
||||
frame.id = 'email-browser-print-frame';
|
||||
frame.title = 'Email print view';
|
||||
frame.setAttribute('sandbox', 'allow-same-origin allow-modals');
|
||||
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:600px;border:0;';
|
||||
document.body.appendChild(frame);
|
||||
|
||||
let cleanedUp = false;
|
||||
const cleanupFrame = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
try {
|
||||
frame.remove();
|
||||
} catch (_) {}
|
||||
window.removeEventListener('afterprint', cleanupFrame);
|
||||
};
|
||||
|
||||
try {
|
||||
const printDoc = frame.contentDocument;
|
||||
printDoc.open();
|
||||
printDoc.write('<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>');
|
||||
printDoc.close();
|
||||
printDoc.title = safeBaseName;
|
||||
const printStyle = printDoc.createElement('style');
|
||||
printStyle.textContent = printCss;
|
||||
printDoc.head.appendChild(printStyle);
|
||||
const container = printDoc.createElement('div');
|
||||
container.className = 'email-export-pdf';
|
||||
container.innerHTML = articles.join('');
|
||||
printDoc.body.appendChild(container);
|
||||
await printDoc.fonts.ready;
|
||||
frame.contentWindow?.addEventListener('afterprint', cleanupFrame, { once: true });
|
||||
window.addEventListener('afterprint', cleanupFrame, { once: true });
|
||||
setTimeout(cleanupFrame, 60000);
|
||||
frame.contentWindow.focus();
|
||||
frame.contentWindow.print();
|
||||
if (omitted.length > 0) {
|
||||
const msg = `Prepared ${successful.length} email${successful.length === 1 ? '' : 's'} for printing; ${omitted.length} unavailable`;
|
||||
showToast(msg);
|
||||
}
|
||||
} catch (error) {
|
||||
cleanupFrame();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function _exportSelectedBundle(kind) {
|
||||
const messages = _selectedEmailExportPayload();
|
||||
if (!messages.length) {
|
||||
showToast('Select emails first');
|
||||
@@ -1151,8 +1319,14 @@ export async function _exportSelectedAttachments() {
|
||||
if (deleteBtn) deleteBtn.disabled = true;
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
if (selectAll) selectAll.disabled = true;
|
||||
if (countEl) countEl.textContent = `Exporting attachments from ${messages.length}…`;
|
||||
const res = await fetch(`${API_BASE}/api/email/attachments-download-bulk`, {
|
||||
const isEmailExport = kind === 'emails';
|
||||
const isPrintExport = kind === 'print';
|
||||
if (countEl) countEl.textContent = `${isPrintExport ? 'Preparing print' : 'Exporting'} ${isPrintExport ? 'for' : isEmailExport ? 'emails from' : 'attachments from'} ${messages.length}…`;
|
||||
if (isPrintExport) {
|
||||
await _printSelectedEmails(messages);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/email/${isEmailExport ? 'messages' : 'attachments'}-download-bulk`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1170,12 +1344,21 @@ export async function _exportSelectedAttachments() {
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'selected-email-attachments.zip';
|
||||
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition'))
|
||||
|| (isEmailExport
|
||||
? (messages.length === 1 ? 'selected-email.eml' : 'selected-emails.zip')
|
||||
: 'selected-email-attachments.zip');
|
||||
_downloadBlob(blob, filename);
|
||||
showToast(`Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
|
||||
const exported = Number(res.headers.get('X-Exported-Emails')) || messages.length;
|
||||
const skipped = Number(res.headers.get('X-Skipped-Emails')) || 0;
|
||||
showToast(skipped
|
||||
? `Exported ${exported} email${exported === 1 ? '' : 's'}; ${skipped} unavailable`
|
||||
: isEmailExport
|
||||
? `Exported ${exported} email${exported === 1 ? '' : 's'} as ${exported === 1 ? 'an .eml file' : '.eml files'}`
|
||||
: `Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to export selected email attachments:', err);
|
||||
showToast(err?.message || 'Failed to export attachments');
|
||||
console.error(`Failed to export selected email ${kind}:`, err);
|
||||
showToast(err?.message || (kind === 'emails' ? 'Failed to export emails' : kind === 'print' ? 'Could not open browser print' : 'Failed to export attachments'));
|
||||
} finally {
|
||||
if (busySpinner) busySpinner.destroy();
|
||||
if (actionsBtn) {
|
||||
@@ -1191,6 +1374,18 @@ export async function _exportSelectedAttachments() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function _exportSelectedEmails() {
|
||||
return _exportSelectedBundle('emails');
|
||||
}
|
||||
|
||||
export async function _exportSelectedEmailPdf() {
|
||||
return _exportSelectedBundle('print');
|
||||
}
|
||||
|
||||
export async function _exportSelectedAttachments() {
|
||||
return _exportSelectedBundle('attachments');
|
||||
}
|
||||
|
||||
// URL-suffix helper — appends &account_id=... when an account is actively selected.
|
||||
// Every email route call in this file goes through here so switching accounts
|
||||
// is a single-variable flip.
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
_animateEmailCardRemoval,
|
||||
_clearDoneResponseTagsLocal,
|
||||
_emailMutationQuery,
|
||||
_exportSelectedEmailPdf,
|
||||
_exportSelectedEmails,
|
||||
_exportSelectedAttachments,
|
||||
_findSiblingEmailCard,
|
||||
_fitEmailDropdown,
|
||||
@@ -585,7 +587,10 @@ export function _showBulkActionsMenu(anchor) {
|
||||
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const _emailIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>';
|
||||
const _pdfIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 15h8M8 18h5"/></svg>';
|
||||
const items = [
|
||||
{ label: 'Export Emails', icon: _exportIco, submenu: true },
|
||||
{ label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
|
||||
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
|
||||
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
|
||||
@@ -594,8 +599,45 @@ export function _showBulkActionsMenu(anchor) {
|
||||
for (const a of items) {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||
it.className += a.submenu ? ' email-bulk-submenu-trigger' : '';
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>${a.submenu ? '<span style="margin-left:auto;opacity:0.55;">›</span>' : ''}`;
|
||||
it.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (a.submenu) {
|
||||
dropdown.replaceChildren();
|
||||
const back = document.createElement('div');
|
||||
back.className = 'dropdown-item-compact email-bulk-submenu-back';
|
||||
back.innerHTML = '<span style="opacity:.65;margin-right:4px;">‹</span><span>Export Emails</span>';
|
||||
back.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
close();
|
||||
_showBulkActionsMenu(anchor);
|
||||
});
|
||||
dropdown.appendChild(back);
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dropdown-divider';
|
||||
dropdown.appendChild(divider);
|
||||
const options = [
|
||||
{ label: 'Email file (.eml)', icon: _emailIco, action: _exportSelectedEmails },
|
||||
{ label: 'Print / save PDF', icon: _pdfIco, action: _exportSelectedEmailPdf },
|
||||
];
|
||||
for (const option of options) {
|
||||
const child = document.createElement('div');
|
||||
child.className = 'dropdown-item-compact';
|
||||
child.innerHTML = `<span class="dropdown-icon">${option.icon}</span><span>${option.label}</span>`;
|
||||
child.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
close();
|
||||
option.action();
|
||||
});
|
||||
dropdown.appendChild(child);
|
||||
}
|
||||
_fitEmailDropdown(dropdown, anchor.getBoundingClientRect());
|
||||
return;
|
||||
}
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(it);
|
||||
}
|
||||
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
||||
|
||||
@@ -379,6 +379,7 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
||||
root.classList.toggle('keyboard-open', keyboardOpen);
|
||||
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
|
||||
else root.style.removeProperty('--keyboard-viewport-height');
|
||||
if (window.scrollY) window.scrollTo(0, 0);
|
||||
};
|
||||
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
|
||||
// the tapped field already had focus (e.g. the composer after a new chat)
|
||||
@@ -391,6 +392,7 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
||||
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
|
||||
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
|
||||
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
|
||||
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
|
||||
|
||||
let _lastVVHeight = window.visualViewport.height;
|
||||
window.visualViewport.addEventListener('resize', function() {
|
||||
|
||||
@@ -267,9 +267,8 @@ export function makeWindowResizable(content, options = {}) {
|
||||
content.style.maxWidth = 'none';
|
||||
content.style.maxHeight = 'none';
|
||||
const left = parseFloat(content.style.left);
|
||||
if (Number.isFinite(left)) {
|
||||
const rightmostLeft = Math.max(padLeft, window.innerWidth - padRight - w);
|
||||
content.style.left = Math.max(padLeft, Math.min(left, rightmostLeft)) + 'px';
|
||||
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
|
||||
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// Tests against the running Odysseus app, not a static mock page.
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, '../..');
|
||||
const port = Number(process.env.ODYSSEUS_LAYOUT_PORT || '17739');
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
const pythonInVenv = path.join(repositoryRoot, '.venv', 'bin', 'python');
|
||||
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(pythonInVenv) ? pythonInVenv : 'python3');
|
||||
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-pr6396-'));
|
||||
const evidenceDir = process.env.ODYSSEUS_LAYOUT_EVIDENCE_DIR || path.join(os.homedir(), 'Pictures', 'Odysseus-6396-followup');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: path.join(__dirname, 'ios-layout'),
|
||||
testMatch: ['**/*.spec.cjs'],
|
||||
outputDir: path.join(dataDirectory, 'playwright-output'),
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['line']],
|
||||
use: {
|
||||
baseURL,
|
||||
headless: true,
|
||||
serviceWorkers: 'block',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'desktop-chromium', use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } } },
|
||||
{ name: 'phone-webkit-portrait', use: { browserName: 'webkit', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true } },
|
||||
{ name: 'phone-webkit-landscape', use: { browserName: 'webkit', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true } },
|
||||
{ name: 'tablet-webkit', use: { browserName: 'webkit', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true } },
|
||||
],
|
||||
metadata: { evidenceDir },
|
||||
webServer: {
|
||||
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
|
||||
cwd: repositoryRoot,
|
||||
url: baseURL,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
...process.env,
|
||||
AUTH_ENABLED: 'false',
|
||||
// Never connect the disposable app to the host's real ChromaDB.
|
||||
CHROMADB_HOST: '127.0.0.1',
|
||||
CHROMADB_PORT: String(port + 1),
|
||||
LLM_HOST: '127.0.0.1',
|
||||
EMBEDDING_URL: `http://127.0.0.1:${port + 1}/v1/embeddings`,
|
||||
DATABASE_URL: `sqlite:///${path.join(dataDirectory, 'app.db')}`,
|
||||
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||
ODYSSEUS_STARTUP_WARMUPS: '0',
|
||||
RESPONSE_COMPRESSION_ENABLED: 'false',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
async function openApp(page) {
|
||||
const response = await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
// The runtime currently produces two #message nodes. Select the
|
||||
// initialized composer while diagnosing whether both are visible.
|
||||
const composers = await page.locator('textarea#message').evaluateAll(nodes =>
|
||||
nodes.map((el, index) => ({
|
||||
index,
|
||||
initialized: el.dataset.startupPreserveBound === '1',
|
||||
visible: !!el.getClientRects().length &&
|
||||
getComputedStyle(el).visibility !== 'hidden',
|
||||
parent: el.parentElement?.className || '',
|
||||
}))
|
||||
);
|
||||
if (composers.length !== 1) {
|
||||
console.log('Composer DOM diagnostic:', JSON.stringify(composers));
|
||||
}
|
||||
const activeComposer = page.locator(
|
||||
'textarea#message[data-startup-preserve-bound="1"]'
|
||||
);
|
||||
await expect(activeComposer).toHaveCount(1);
|
||||
await expect(activeComposer).toBeVisible();
|
||||
expect(
|
||||
composers.filter(composer => composer.visible).length,
|
||||
'Exactly one composer should be visible'
|
||||
).toBe(1);
|
||||
}
|
||||
|
||||
test('renders actual Odysseus UI and captures review screenshots', async ({ page }, testInfo) => {
|
||||
await openApp(page);
|
||||
const out = testInfo.config.metadata.evidenceDir;
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const name = testInfo.project.name;
|
||||
await page.screenshot({ path: path.join(out, `${name}-chat.png`), fullPage: false, animations: 'disabled' });
|
||||
|
||||
// Screenshot the actual Settings UI only if a real user-visible Settings
|
||||
// control is reachable; never fabricate a modal for PR screenshots.
|
||||
const settingsButton = page.locator('#rail-settings:visible, #user-bar-settings:visible').first();
|
||||
if (await settingsButton.count()) {
|
||||
await settingsButton.click();
|
||||
const dialog = page.locator('#settings-modal');
|
||||
if (await dialog.isVisible()) {
|
||||
await page.screenshot({ path: path.join(out, `${name}-settings.png`), fullPage: false, animations: 'disabled' });
|
||||
}
|
||||
}
|
||||
|
||||
// Open the real Notes route via an existing UI action when it is reachable.
|
||||
// These screenshots reflect genuine runtime content, not test-injected panes.
|
||||
await openApp(page);
|
||||
const notesButton = page.locator('#rail-notes:visible, #tool-notes-btn:visible').first();
|
||||
if (await notesButton.count()) {
|
||||
await notesButton.click();
|
||||
const realNotes = page.locator('.notes-pane:visible').first();
|
||||
if (await realNotes.count()) {
|
||||
await page.screenshot({ path: path.join(out, `${name}-notes.png`), fullPage: false, animations: 'disabled' });
|
||||
}
|
||||
}
|
||||
testInfo.annotations.push({ type: 'evidence', description: out });
|
||||
});
|
||||
|
||||
test('viewport meta does not restrict user zoom', async ({ page }) => {
|
||||
await openApp(page);
|
||||
const meta = await page.locator('meta[name="viewport"]').getAttribute('content');
|
||||
expect(meta || '').not.toMatch(/maximum-scale\s*=\s*1(?:\b|\.)/i);
|
||||
expect(meta || '').not.toMatch(/user-scalable\s*=\s*no/i);
|
||||
});
|
||||
|
||||
test('browser-owned window scrolling is not globally reset', async ({ page }) => {
|
||||
await openApp(page);
|
||||
const value = await page.evaluate(async () => {
|
||||
document.documentElement.style.overflowY = 'auto';
|
||||
document.body.style.overflowY = 'auto';
|
||||
document.body.style.height = 'auto';
|
||||
document.body.style.minHeight = '3200px';
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'position:absolute;top:3100px;width:1px;height:10px;pointer-events:none;';
|
||||
document.body.appendChild(spacer);
|
||||
window.scrollTo(0, 250);
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
return window.scrollY;
|
||||
});
|
||||
expect(value).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('mobile Notes and Documents panes honor the safe-area top', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'phone-webkit-portrait', 'Only mobile <=768px');
|
||||
await openApp(page);
|
||||
const results = await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty('--safe-top', '44px');
|
||||
document.documentElement.style.setProperty('--sheet-top', '68px');
|
||||
document.documentElement.style.setProperty('--app-height', '844px');
|
||||
const values = {};
|
||||
for (const [key, bodyClass, paneClass] of [
|
||||
['notes', 'notes-view', 'notes-pane'],
|
||||
['document', 'doc-view', 'doc-editor-pane'],
|
||||
]) {
|
||||
const pane = document.createElement('section');
|
||||
pane.className = paneClass;
|
||||
pane.style.opacity = '0'; // CSS-only fixture, not a screenshot of fake app content
|
||||
document.body.appendChild(pane);
|
||||
document.body.classList.add(bodyClass);
|
||||
const style = window.getComputedStyle(pane);
|
||||
values[key] = {
|
||||
top: style.top,
|
||||
height: style.height,
|
||||
insetTop: pane.getBoundingClientRect().top,
|
||||
};
|
||||
pane.remove();
|
||||
document.body.classList.remove(bodyClass);
|
||||
}
|
||||
return values;
|
||||
});
|
||||
for (const key of ['notes', 'document']) {
|
||||
expect(results[key].top, key).toBe('68px');
|
||||
expect(parseFloat(results[key].height), key).toBeGreaterThanOrEqual(770);
|
||||
}
|
||||
});
|
||||
|
||||
test('restored floating windows clamp away from a left safe area', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'Geometry test uses desktop resizable windows');
|
||||
await openApp(page);
|
||||
const value = await page.evaluate(async () => {
|
||||
document.documentElement.style.setProperty('--safe-left', '58px');
|
||||
document.body.style.setProperty('padding-left', '58px', 'important');
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = 'position:fixed;left:10px;top:80px;width:420px;height:250px;z-index:999999;';
|
||||
document.body.appendChild(probe);
|
||||
const key = 'odysseus-layout-restore-test';
|
||||
localStorage.setItem(key, JSON.stringify({ w: 480, h: 280 }));
|
||||
try {
|
||||
// Body horizontal padding transitions over 160ms. Wait for the
|
||||
// simulated safe-area change to settle before restoring geometry.
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const padAtRestore = parseFloat(
|
||||
getComputedStyle(document.body).paddingLeft
|
||||
);
|
||||
console.log('PADDING AT RESTORE:', padAtRestore);
|
||||
|
||||
if (Math.abs(padAtRestore - 58) > 0.5) {
|
||||
throw new Error(
|
||||
`Safe-area fixture did not settle: ${padAtRestore}px`
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the running server actually serves the patched module.
|
||||
const response = await fetch(
|
||||
'/static/js/windowResize.js',
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Cannot fetch windowResize.js');
|
||||
}
|
||||
const moduleSource = await response.text();
|
||||
if (!moduleSource.includes('const rightmostLeft = Math.max(padLeft')) {
|
||||
throw new Error('Running server is serving unpatched windowResize.js');
|
||||
}
|
||||
|
||||
const { makeWindowResizable } = await import('/static/js/windowResize.js');
|
||||
makeWindowResizable(probe, { storageKey: key, mobileSkip: 768 });
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return {
|
||||
left: parseFloat(probe.style.left),
|
||||
width: parseFloat(probe.style.width),
|
||||
paddingLeft: parseFloat(getComputedStyle(document.body).paddingLeft),
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
} finally {
|
||||
localStorage.removeItem(key);
|
||||
probe.remove();
|
||||
}
|
||||
});
|
||||
console.log('WINDOW RESTORE DIAGNOSTIC:', JSON.stringify(value));
|
||||
expect(value.paddingLeft).toBeGreaterThanOrEqual(58);
|
||||
expect(value.width).toBe(480);
|
||||
expect(value.left).toBeGreaterThanOrEqual(value.paddingLeft);
|
||||
});
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
# CA trust regression fixtures
|
||||
|
||||
The public certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. `localhost-leaf.pem` is a synthetic self-signed Ed25519 server certificate with `CA:FALSE`, generated solely to reproduce hashed non-CA certificates found in Ubuntu's OpenSSL directory; its private key was discarded. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture.
|
||||
|
||||
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 [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`: `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`
|
||||
|
||||
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
|
||||
|
||||
The SearXNG derived-image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
|
||||
|
||||
`emsign-g1.txt` is the unaffected public emSign Root CA - G1 certificate in PEM format, used by the SearXNG regressions to verify preservation of unrelated trust. Its fingerprint is SHA-256 `40f6af0346a99aa1cd1d555a4e9cce62c7f9634603ee406615833dc8c8d00367`.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD
|
||||
VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU
|
||||
ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH
|
||||
MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO
|
||||
MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv
|
||||
Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz
|
||||
f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO
|
||||
8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq
|
||||
d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM
|
||||
tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt
|
||||
Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB
|
||||
o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD
|
||||
AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x
|
||||
PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM
|
||||
wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d
|
||||
GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH
|
||||
6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby
|
||||
RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
|
||||
iN66zB+Afko=
|
||||
-----END CERTIFICATE-----
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"verification_time": 1792756800,
|
||||
"roots": {
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3"
|
||||
},
|
||||
"intermediates": {
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
|
||||
},
|
||||
"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",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1\nNDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J\nhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p\n1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO\nIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8\ngt2TlfJbHii+KNvK\n-----END CERTIFICATE-----\n",
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUXcJ4+VeNEICJ9JbBANtIyfP96EowCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRvdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATLEvRHdoPc0Z9ovgqlYmoKTHPU\nApwd6lAp0ou1G63SzuESyBzIW6Tl64CsKzlV9ggkZjX+L9/tOx97/1wmSTYvo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJG92LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUrytBb+px+b58Df+d5vP8GpTW+/wwHwYDVR0jBBgw\nFoAUNOrqDOvJCtr50MXi7Zt4yYeeG2wwCgYIKoZIzj0EAwIDaAAwZQIwNdoOUOKz\nMLV1OFEoMjBeUqvkQAwsajvVWu0A23qyyvYlosyMp8aFv0gxURW6Yj6DAjEAhe0z\nkOQmmqb0eyhjh7weF4fHtwMtLi4jNFA6M/EfPTfkqE91MN9fSin5uhua5xhk\n-----END CERTIFICATE-----\n",
|
||||
"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",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAJHzR/Glt9OZYrleDuTimdEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8C\nvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh\n3xsqRB0Bo4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECATANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECZDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAr7b6Mx11TIJgVzHt6EH5qGNWeiiqoMrp8E5/k8s+jvupx1n083pZ5XOzUzND\nwEUe0W4C4FIGUHNQ4XDwqOw0yK42xqm2dvCKgsko2UvDlT0haZVtTCeWMQOe5J/J\nziaMfZwsZNfZ3aaI6XPtWDZtwY8AaNfyaeCpbtm5GcGnyNWk4jSNpbtpo6NjfXuP\nCFOm3rTjJ4zytP7Tso6iMm0L01cYFOlTmoPv4515R2r3SPnKDtijoB/D/vgxzojv\nDafgyG9afX8+laO9NbMMz4sqC1vZx3984i/sBNFiugm3Vvdq5OdJRjuGC1QILIs6\ndg/K3i8VQqRpbfaM9kIlMzZ3dQ==\n-----END CERTIFICATE-----\n",
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUPfthO0PYGAYKs9lQ4gp1IOkOr2MwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGV2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPZPwlA3io/AX/pMwu0dNl9WMxrr\nC9j/xMLHgG72cWVzoX62REmvp9zyXQ04kHP9/sW6KnCcm5ZZ5i4YoJbDPASjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZXYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBSvX4QR4ERW5uNUaMq0yYzXCZf0ZjAfBgNVHSMEGDAW\ngBQ+3CDZiUaYh3uHP9iBR7JcFdhHBjAKBggqhkjOPQQDAgNnADBkAjACy/uJR01M\n/O9IEZyRHX1LSU28bmg542K0IX9jDRTrZXUqkDWfiZIL7xfnrRrBpHoCMFbBIaOa\nWwWtFtDg9ZqQvktsWS950u5qfW1sChgWNya/E3IMKFgtoHm2xrZsmfwLSQ==\n-----END CERTIFICATE-----\n",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv\n1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf\nHAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i\nKOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6\nOzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ\nTaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw\nFFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb\nuL5ChFWOQU+ErSDnUq7yr+pjBQ==\n-----END CERTIFICATE-----\n",
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "-----BEGIN CERTIFICATE-----\nMIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx\nOTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11\nZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg\nVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG\nH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao\n4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW\nBBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI\nrvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT\nKCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc=\n-----END CERTIFICATE-----\n",
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUU3mgjls/8ZJ8upmukdlSQR1IF/UwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATK3sfcOxxtM2Vdq9Qbw/GR\nNsHlJcnqINEInXO3fkWhw/FYT80hkqGk0jSN/EKib3YZJsr5GyfkYcpEiA2rwMgm\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFDggfJFz1eeToKmzFBSYkMCvpAu0MB8G\nA1UdIwQYMBaAFOIeL+ccGTkOVUtiwSAkVzUkiiUUMAoGCCqGSM49BAMCA2gAMGUC\nMDqDDOt7K313QIH0rhn/ZKnzFu8+/9W8OlTuFtDfVCTRDfRmqdyfsCfLsEk7xqHq\nuQIxAJqyLERABmU98U46OCuT5bp5h0ytuSEIGM7orTpKci5S1u1B/AOmwv162U7R\nTcVPHA==\n-----END CERTIFICATE-----\n",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIRANal72E7OQ8y75zfNJ93KEEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEaMBgGA1UEAwwRRU0gT1YgQ1MgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAS58OBjRwKmnKaq3UQ8/WLEG5uksgkkaX15dOdpGhJvuWr7PjTC/TvF9IMG\nHyZdwqWENjRzI1EENrKPBPOGI2MGeYDiUD4CUy1lRiNacWWiWaHvSW90G1/e53hy\nCOS4+u+jggEsMIIBKDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU7m9zKY1vv6ezG8OVNPYVVkR4ZrcwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwMgYDVR0gBCsw\nKTAIBgZngQwBBAEwDQYLKwYBBAGDjiEBAAEwDgYMKwYBBAGDjiEBAoFIMEMGA1Ud\nHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FBQUNlcnRpZmlj\nYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0\ncDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBgGbZ+NDkn\n2Fr5dCVs/51+6cT5iEPP1stglX4TI2UaNh9LmPdltxXw3crV8gK5L8i+d+ScGibF\ny0obtBHNTldXDgLnAHIzY3n+pceyKj7O2ae9CYnyuu5RBifeI3XYKowWE+oXnVGF\nOy0CvCJ/JsdP6aYgoCa9hGnAYicYQQL6MOMQZ5PuhcSM8t4giIzrpgrEkUP93GYE\nemDG5Z8BYwKs80lLeQNBnwg/VHJ3iEF/o84RXYwrlrKqJ+33C094o6LNikMJCqOK\n/WYHZSR8Xyhf8GXFAj5pZuI6Bcpug2V/E7i42j8OBhc25aNYDqK+B0qmb90sjNUB\ng3tJeSIb/599\n-----END CERTIFICATE-----\n",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "-----BEGIN CERTIFICATE-----\nMIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG\nEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo\nbm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g\nRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ\nTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s\nb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw\ndjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0\nWXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS\nfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB\nzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq\nhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB\nCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD\n+JbNR6iC8hZVdyR+EhCVBCyj\n-----END CERTIFICATE-----\n",
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52": "-----BEGIN CERTIFICATE-----\nMIICQjCCAcegAwIBAgIUMH3z62Z6agEULBZqrfhpw9/ZpCAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQxWhcNMjcwNTAzMDIyNzQx\nWjAzMTEwLwYDVQQDDChkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMPpHsQqEqIerrj/durO\nEN7gkUVJSVgIPN6+VJ2sEGXDyqXAN1fnNzGuASP/4WEr0RIhht3cYvuUYp2SYofr\nw6OBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBT8CNQz4OLVnIwrQYZBF+++9jTCvzAf\nBgNVHSMEGDAWgBSufhBcPCmWwahHJwVZ8eA91OUK8zAKBggqhkjOPQQDAgNpADBm\nAjEAp3ZLdfBvKcZxgR2p8LZaxWzP6uB7zU/EZ9BA49Ds2P5ByYkRodwK5qcwt4J/\nVL/vAjEAnnxrcrDX7SzoJxuho604GBh6eyBLJLCdmcy+qb+LA3u5oxP/OjAzCA4x\nv1kw6Tm4\n-----END CERTIFICATE-----\n",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "-----BEGIN CERTIFICATE-----\nMIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAw\nMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVk\naHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIB\nBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK\n56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDft\nDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZ\nQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYI\nKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAo\nBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEE\nAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc\n28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe\n8Dz3bQjauvy9uw==\n-----END CERTIFICATE-----\n",
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb6gAwIBAgIUY1R/PHrtXShV0uhLJ5EyjsHvMi0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGR2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFpE24ACBFHQLzpg+7D767thJ3+Q\nNIoJJugUMT/OftEz6G6uDdxlS+9VfXYBDglHjJirjEPFNglOGJoQNQuiz7Gjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZHYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBT4r5v4etYNuG21BqBqs273OkcHiDAfBgNVHSMEGDAW\ngBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjOPQQDAgNpADBmAjEAlxNF+/8l\nAPpgibawACy15yn5bcvTksz2Zw0R9z9nUK9e3MSSk+hXagg7LVb7V4NtAjEA4qyR\nlaFPkaokrnTjqQieq8CgDQ6tIz3FjBvj3D7vkhKXHaml6zD2zT5ih2Y+lD2h\n-----END CERTIFICATE-----\n",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgy\nN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRo\ncmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUOb\nNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jord\nd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2r\nzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsG\nAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYc\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYG\nZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3At\nYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNp\nZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVo\ndHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQE\nAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03\nYEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9\nHTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=\n-----END CERTIFICATE-----\n",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "-----BEGIN CERTIFICATE-----\nMIIDwzCCAqugAwIBAgIQQeMrc6wYXWzg10gLrPhOaTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYx\nGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACID\nYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+\nW/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr\n5eBQo4IBLDCCASgwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD\nVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMA4GA1UdDwEB/wQEAwIBBjASBgNV\nHRMBAf8ECDAGAQH/AgEAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDIGA1UdIAQrMCkw\nCAYGZ4EMAQQBMA0GCysGAQQBg44hAQABMA4GDCsGAQQBg44hAQKBSDBDBgNVHR8E\nPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9BQUFDZXJ0aWZpY2F0\nZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6\nLy9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIh0OzWsdBYhY\n7+yxPGyosRL+uUSN6/Mrd30fBuougs5FVWQ3cRgLhImnUUgXADY9buugWhMmfT3c\nyU5T1MFXZXClCILm5mfg+oqm/ikUWJl3Ayf2GvYf2O++6vzqvkn418nI5Lpdt0O7\nLi524YS/3CZHDA+zZQ7rrHpZXgQ9Hmp8AUQ4FneaGjNanFoYxiZxwCyW5z4u7AoK\n0bmOVqKtcDFoVNz5++G9TYDJdhWOXmKKgzqRo1MK0PjazNT7MGHqknP93SqMVJ1g\nmX/qNWxYteAjlvFanQxBNX/+TRxDjuFsEHO/7BvqSpIIck0UEEfizhN8zNpWLbcl\nEqVgl0g39A==\n-----END CERTIFICATE-----\n",
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUUYu3O8bpN1GPvCIHqWpT/FYUNDowCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASjDLpnVdmvXM6R3UB2o42a\n1GnrYfxRXo7UII36mBl9mIS7p9MkAryhPdxitFHkgTrrbypVtzpydNtJIGGGXUwE\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFEVBY1ecTN9bLMDl4ifdnN2WZL+WMB8G\nA1UdIwQYMBaAFD7cINmJRpiHe4c/2IFHslwV2EcGMAoGCCqGSM49BAMCA2gAMGUC\nMQCyvr9gktgmtsuTuFleFV+YExNNKLf3GZieuUprQcERrzh798oYKavTqZeYf9Kz\nRhECMCVs1B7KPh7/lgHRly14Z8RJA8ApR7e2cRREW5uPKFOSDlZ/RIezxsfEzsQ4\nlAODeA==\n-----END CERTIFICATE-----\n",
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcegAwIBAgIUTMUrGzSU+0BySFdrN3ALZq9Spv8wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEglClDbotLFFeY9SpryT4\nk958tvriY2DDnaPsmjQaecRhB6+L/8hLw6FMt+HT8SLvjIZnFY+AH92cBjedcNST\nAKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBSVtGbn6xUT1iosUeaGA15Nq640aTAf\nBgNVHSMEGDAWgBSmJV6AqU2385Z8G8eR35K87kvrXzAKBggqhkjOPQQDAgNnADBk\nAjAg7b5bIK9X50QEX0U6G8fQh12BnFQ079Iluzd0Y2TJWCjtVXZi3nOciM8RzNKM\ntZECMCBf0JOyMt8RNS93Wj8kYbWkCtoKoUrqx7G3JTfGReKci+DdTvkF8/3F6doi\n9KB22Q==\n-----END CERTIFICATE-----\n",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3\nNDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ\nfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+\nN7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c\nNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79\n4rSb9vGqxZzelQth\n-----END CERTIFICATE-----\n",
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUOcw5N4XGLiB/3GvB05bJuGoZPR0wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRldi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQo7U02MAUO8EEMomXwIl+kSQzm\nb+nvXKIZ/hC4rZ1GpXchv4SzTrV1SB/Qg+pD6OBEXbBBnPmQNkePNQTlujqRo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGV2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUqxe+7qS+MxNyioJZhQsL5MEEw6swHwYDVR0jBBgw\nFoAUpiVegKlNt/OWfBvHkd+SvO5L618wCgYIKoZIzj0EAwIDaAAwZQIwC/CxSIov\nwcar3sV98B8cL+QuR2kkJdLdXsFKzg/HDSJ1MOjqTGm924LB6r9nl0f7AjEAzcwr\nWQOYY9eycqIz+LG2daYS6LOctj+CjoAk/CLugWPxGxp4Aerfau2P1z1h5jAA\n-----END CERTIFICATE-----\n",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "-----BEGIN CERTIFICATE-----\nMIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb\nMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow\nGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj\nYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL\nMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE\nBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM\nGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP\nADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua\nBtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe\n3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4\nYgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR\nrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm\nez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU\noBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v\nQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t\nb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF\nAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q\nGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz\nRt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2\nG9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi\nl2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3\nsmPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==\n-----END CERTIFICATE-----\n",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYw\nMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB\n9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ\n5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDA\nlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6\nMN/P+ByGeiRMghzB\n-----END CERTIFICATE-----\n",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "-----BEGIN CERTIFICATE-----\nMIICKDCCAa2gAwIBAgIQAY/OySfBFvWcmX7cDM0uKzAKBggqhkjOPQQDAzBVMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEfMB0GA1UEAwwWZW1TaWduIFJvb3QgQ1MgQ0EgLSBHMzAeFw0yNDAyMDgwODMz\nMTRaFw00OTAyMDcwODMzMTRaMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVk\naHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBD\nUyBDQSAtIEczMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESl9kM5D0VA/Og76dz5Xz\nw1hnGQ4fSR9993/qBkkvI48pXQVKOdyHCgAZThgWejQ8OpFRiTISjpabn4tfRFOY\nuzhApR/XxGbkg9X+2cEtbyghF9CdoF91W22V/R2EVe4xo0IwQDAdBgNVHQ4EFgQU\naHQD+IXKjVRQVx0W4MjeRQ2rzS0wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wCgYIKoZIzj0EAwMDaQAwZgIxAK9OlACrFod3UgWY1ai+DF9sGFhahU59\nrv4jJyV01QaxGn0HErZ8gjHkngqe+tcFogIxAPTdW0hBmrGjhxBWdv9mZNdVFiJ4\nlrZ3ahvimsLhTQpnDBgiGKv6Ov1Fx/CE6yjIAg==\n-----END CERTIFICATE-----\n",
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe": "-----BEGIN CERTIFICATE-----\nMIICODCCAb6gAwIBAgIUPXjC97k7buQHZ7p5ehDU8QNyrR4wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzdaFw0yNzA1MDMwMTA0Mzda\nMC8xLTArBgNVBAMMJG92LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA1haD+95Albk0UN6YtDCPpqevTv\nYUmEhsIVpb/hnhsbBlH0yLthSE2JLH14ywiQJXttoc/pDWC9eoICW6CJXL2jgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkb3YtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBQUhzc4s202Hn62G0srmgguYdY2MTAfBgNVHSMEGDAW\ngBSb2GVRr+fnLDetCO5pGdm/N4SuCjAKBggqhkjOPQQDAgNoADBlAjEAoJ6d4zzA\nxE349yGHHrwKZpVCC/HBFutcOpOfzQoe8ikbmaaBTdtKai5TEllwt3dBAjBeHGiX\nVheDhJfewbei99cw1MfYB5tPwdfuY5pe1MJz0Tg9zJ4JgxBvF6LTT0OOXZ4=\n-----END CERTIFICATE-----\n",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIQBfB4dS3s56G7hZXTAxZxcTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5j\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQtVugppvuCxkpGl9lgr5UekGeukfkm00MG2kioqut4abamfm0nQm4EQqoD\ns24ZiN/2NlFO4c8H9bpLP/KgZkWufTHVhmrTGqPSRusLMXfumct1ZsUWznSAimaP\nkfDEf8qjggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUpiVegKlNt/OWfBvHkd+SvO5L618wDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAIFfHiaMSjnrdZygRYV4ekRBmG7ybcFk4hGQWMMyMFi0cAEk2+dGfz5bl8IaIbLq\nz+uVJzhOgVEX7WqIfgbWfFsfPYHYjPHBmy7SlZ3BwA86IQ6qF/etpX2/qL9A5yRN\nN3nVowUpaqV6H3aCpkAh3wR2ZNkvAbLDvPz3J0l+sEWjWv+ZISOV4R+hXDTdPXV0\n1lKxnbwqEsdo/sE2QkCDKwFrZh/0uKjWspydJj+jSaDLTrGNx50Dgmn7VEcD1ZI5\nFXjknVUiO25YlmS1dtVZSBbyUKSjlrP/yDwPcZunoAsmCAAtCKsFLviLaymzvsKI\neSSDGxpSySp2VCxompGDk8g=\n-----END CERTIFICATE-----\n"
|
||||
},
|
||||
"chains": {
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"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"
|
||||
],
|
||||
"dv-g3c-emudhra.we-have-a-problem.com": [
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512"
|
||||
],
|
||||
"ev-g3b-emudhra.we-have-a-problem.com": [
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f"
|
||||
],
|
||||
"ev-g3c-emudhra.we-have-a-problem.com": [
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699"
|
||||
],
|
||||
"ov-g3b-emudhra.we-have-a-problem.com": [
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489"
|
||||
],
|
||||
"ov-g3c-emudhra.we-have-a-problem.com": [
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
]
|
||||
},
|
||||
"cross_signed_roots": {
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (emSign ECC Root CA - G3 cross-sign)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Offline regressions for incident roots, unaffected roots, and CA directories."""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("check_ca_trust", ROOT / "scripts/check_ca_trust.py")
|
||||
checker = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(checker)
|
||||
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||
|
||||
|
||||
class CATrustTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Public certificate data is embedded below. Tests create temporary
|
||||
# PEM files from these constants; no fixture contains a private key.
|
||||
cls.temporary = tempfile.TemporaryDirectory()
|
||||
cls.fixtures = Path(cls.temporary.name)
|
||||
for name, pem in PUBLIC_CERTIFICATES.items():
|
||||
(cls.fixtures / name).write_text(pem)
|
||||
for fingerprint, pem in INCIDENT["certificates"].items():
|
||||
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
|
||||
raise ValueError("incident fixture fingerprint mismatch")
|
||||
(cls.fixtures / f"{fingerprint}.pem").write_text(pem)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def test_unaffected_emsign_root_remains_usable(self):
|
||||
checker.check_bundle(self.fixtures / "emsign-g1.pem")
|
||||
|
||||
def test_verbose_python_check_reports_and_rejects_affected_user_bundle(self):
|
||||
# A fresh interpreter must see persisted user packages, independently
|
||||
# of pytest's imported certifi/pip modules and any active test venv.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp)
|
||||
env = dict(os.environ, HOME=str(home), PYTHONUSERBASE=str(home / ".local"))
|
||||
env.pop("PYTHONNOUSERSITE", None)
|
||||
env.pop("PYTHONPATH", None)
|
||||
python = getattr(sys, "_base_executable", sys.executable)
|
||||
site_result = subprocess.run(
|
||||
[python, "-m", "site", "--user-site"], env=env, capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(site_result.returncode, 0, site_result.stderr)
|
||||
user_site = Path(site_result.stdout.strip())
|
||||
for package in ("certifi", "pip", "pip/_vendor", "pip/_vendor/certifi"):
|
||||
directory = user_site / package
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "__init__.py").write_text(
|
||||
"from pathlib import Path\ndef where(): return str(Path(__file__).with_name('cacert.pem'))\n"
|
||||
)
|
||||
application_bundle = user_site / "certifi/cacert.pem"
|
||||
pip_bundle = user_site / "pip/_vendor/certifi/cacert.pem"
|
||||
for bundle in (application_bundle, pip_bundle):
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"])
|
||||
command = [python, str(ROOT / "scripts/check_ca_trust.py"), "--python", "--verbose"]
|
||||
clean = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(clean.returncode, 0, clean.stderr)
|
||||
self.assertIn(str(application_bundle), clean.stdout)
|
||||
self.assertIn(str(pip_bundle), clean.stdout)
|
||||
self.assertIn(str(home), clean.stdout)
|
||||
self.assertIn("Python executable:", clean.stdout)
|
||||
application_bundle.write_text(PUBLIC_CERTIFICATES["emsign-g3.pem"])
|
||||
affected = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(affected.returncode, 1, affected.stderr)
|
||||
self.assertIn(str(application_bundle), affected.stderr)
|
||||
self.assertIn("emSign ECC Root CA - G3", affected.stderr)
|
||||
|
||||
def test_compromised_emsign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "emSign ECC Root CA - G3"):
|
||||
checker.check_bundle(self.fixtures / "emsign-g3.pem")
|
||||
|
||||
def test_aaa_cross_sign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(self.fixtures / "aaa.pem")
|
||||
|
||||
def test_mixed_bundle_does_not_hide_an_affected_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "mixed.pem"
|
||||
bundle.write_bytes((self.fixtures / "emsign-g1.pem").read_bytes() + (self.fixtures / "aaa.pem").read_bytes())
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_all_published_intermediates_and_cross_signs_are_rejected(self):
|
||||
self.assertEqual(len(INCIDENT["intermediates"]), 16)
|
||||
for fingerprint, name in INCIDENT["intermediates"].items():
|
||||
with self.subTest(certificate=name), self.assertRaisesRegex(ValueError, "affected CA certificates"):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_published_roots_and_cross_signed_issuer_are_rejected(self):
|
||||
certificates = INCIDENT["roots"] | INCIDENT["cross_signed_roots"]
|
||||
self.assertEqual(len(certificates), 5)
|
||||
for fingerprint, name in certificates.items():
|
||||
with self.subTest(certificate=name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_clean_roots_do_not_hide_an_added_compromised_intermediate(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "custom.pem"
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"] + INCIDENT["certificates"][fingerprint])
|
||||
with self.assertRaisesRegex(ValueError, "EM DV TLS CA - G3B"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
@unittest.skipUnless(shutil.which("openssl"), "offline chain verification requires OpenSSL")
|
||||
def test_published_chains_and_custom_anchor_with_affected_positive_control(self):
|
||||
# Freeze time inside the public fixtures' validity interval. Otherwise
|
||||
# expiry could make an ineffective trust-store fix appear successful.
|
||||
help_result = subprocess.run(["openssl", "verify", "-help"], capture_output=True, text=True)
|
||||
no_store = ["-no-CAstore"] if "-no-CAstore" in help_result.stdout + help_result.stderr else []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
affected = directory / "affected.pem"
|
||||
affected.write_text("".join(INCIDENT["certificates"][key] for key in INCIDENT["roots"]))
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
custom = directory / "custom.pem"
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
custom.write_text(clean.read_text() + INCIDENT["certificates"][fingerprint])
|
||||
self.assertEqual(len(INCIDENT["chains"]), 14)
|
||||
custom_accepted = []
|
||||
for hostname, chain in INCIDENT["chains"].items():
|
||||
leaf, intermediates = directory / "leaf.pem", directory / "chain.pem"
|
||||
leaf.write_text(INCIDENT["certificates"][chain[0]])
|
||||
intermediates.write_text("".join(INCIDENT["certificates"][key] for key in chain[1:]))
|
||||
|
||||
def verify(bundle):
|
||||
return subprocess.run([
|
||||
"openssl", "verify", "-no-CApath", *no_store,
|
||||
"-attime", str(INCIDENT["verification_time"]),
|
||||
"-partial_chain", "-x509_strict", "-purpose", "sslserver",
|
||||
"-verify_hostname", hostname, "-CAfile", str(bundle),
|
||||
"-untrusted", str(intermediates), str(leaf),
|
||||
], capture_output=True, text=True)
|
||||
|
||||
with self.subTest(hostname=hostname):
|
||||
positive = verify(affected)
|
||||
self.assertEqual(positive.returncode, 0, positive.stderr)
|
||||
negative = verify(clean)
|
||||
self.assertNotEqual(negative.returncode, 0)
|
||||
self.assertIn("unable to get local issuer certificate", negative.stderr)
|
||||
if verify(custom).returncode == 0:
|
||||
custom_accepted.append(hostname)
|
||||
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):
|
||||
with self.subTest(bundle=bundle.name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_empty_malformed_and_missing_bundles_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for content in ("", "not a certificate"):
|
||||
path = Path(tmp) / "bad.pem"
|
||||
path.write_text(content)
|
||||
with self.subTest(content=content), self.assertRaises((ssl.SSLError, ValueError)):
|
||||
checker.check_bundle(path)
|
||||
with self.assertRaises(OSError):
|
||||
checker.check_bundle(Path(tmp) / "missing.pem")
|
||||
|
||||
def test_each_configured_override_is_checked(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
affected = self.fixtures / f"{fingerprint}.pem"
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
for name in ("LLM_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
|
||||
"PIP_CERT", "NODE_EXTRA_CA_CERTS", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"):
|
||||
for bundle, expected in ((clean, 0), (affected, 1)):
|
||||
with self.subTest(variable=name, bundle=bundle.name), patch.dict(
|
||||
os.environ, {name: str(bundle)}, clear=True
|
||||
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(clean)]), expected)
|
||||
|
||||
def test_invalid_override_is_not_hidden_by_a_clean_explicit_bundle(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "invalid")):
|
||||
(directory / name).write_text(content)
|
||||
for name in ("missing.pem", "empty.pem", "malformed.pem"):
|
||||
with self.subTest(file=name), patch.dict(
|
||||
os.environ, {"LLM_CA_BUNDLE": str(directory / name)}, clear=True
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(self.fixtures / "emsign-g1.pem")]), 1)
|
||||
|
||||
def test_no_configured_overrides_does_not_report_validated_stores(self):
|
||||
with patch.dict(os.environ, {"LLM_CA_BUNDLE": " "}, clear=True), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_requests_directory_override_checks_hashed_intermediates(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT"):
|
||||
with self.subTest(variable=name), patch.dict(os.environ, {name: str(directory)}, clear=True):
|
||||
for content, expected in ((PUBLIC_CERTIFICATES["emsign-g1.pem"], 0),
|
||||
(INCIDENT["certificates"][fingerprint], 1)):
|
||||
(directory / "12345678.0").write_text(content)
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), expected)
|
||||
(directory / "12345678.0").unlink()
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_safe_cafile_does_not_hide_stale_hashed_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "14bc7599.0").symlink_to(self.fixtures / "emsign-g3.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_hashed_non_ca_certificate_does_not_reject_clean_store(self):
|
||||
# Ubuntu's OpenSSL directory can also hash ssl-cert-snakeoil.pem.
|
||||
# A valid server certificate has no CA entries in get_ca_certs().
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "ce275665.0").symlink_to(self.fixtures / "localhost-leaf.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--system"]), 0)
|
||||
|
||||
def test_multiple_ca_directories_are_all_checked(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
clean, stale = Path(tmp) / "clean", Path(tmp) / "stale"
|
||||
clean.mkdir()
|
||||
stale.mkdir()
|
||||
(stale / "ee64a828.0").symlink_to(self.fixtures / "aaa.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=None
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: os.pathsep.join(map(str, (clean, stale)))}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("AAA Certificate Services", errors.getvalue())
|
||||
|
||||
def test_grpc_bundled_roots_are_checked_without_an_override(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
package = Path(temporary) / "grpc"
|
||||
bundle = package / "_cython" / "_credentials" / "roots.pem"
|
||||
bundle.parent.mkdir(parents=True)
|
||||
bundle.write_bytes((self.fixtures / "emsign-g3.pem").read_bytes())
|
||||
errors = io.StringIO()
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=SimpleNamespace(origin=str(package / "__init__.py"))
|
||||
), contextlib.redirect_stderr(errors):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_grpc_explicit_clean_store_replaces_the_independent_bundle(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(self.fixtures / "emsign-g1.pem")}), patch.object(
|
||||
checker.importlib.util, "find_spec", side_effect=AssertionError("must use the explicit store")
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 0)
|
||||
|
||||
def test_grpc_invalid_override_does_not_fall_back_silently(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "not a certificate"), ("affected.pem", PUBLIC_CERTIFICATES["aaa.pem"])):
|
||||
bundle = Path(temporary) / name
|
||||
bundle.write_text(content)
|
||||
with self.subTest(name=name), patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(bundle)}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(Path(temporary) / "missing.pem")}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
def test_grpc_check_reports_a_missing_installation(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=None
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
|
||||
# Public Mozilla CA certificates; provenance: tests/fixtures/ca-trust/README.md.
|
||||
PUBLIC_CERTIFICATES = {
|
||||
'localhost-leaf.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIBOTCB7KADAgECAhRcVxnOQNEFWf92K4dm4I8ChyjVMzAFBgMrZXAwFDESMBAG
|
||||
A1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyMTE4NTgwN1oXDTM2MTAxODE4NTgwN1ow
|
||||
FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEARglphvNgp9qRCESLHnuu
|
||||
W8VC5urdZl+YgS+Ux1BoLryjUDBOMB0GA1UdDgQWBBSaEckyR/76lX3a5Ijh8B42
|
||||
oJwN1DAfBgNVHSMEGDAWgBSaEckyR/76lX3a5Ijh8B42oJwN1DAMBgNVHRMBAf8E
|
||||
AjAAMAUGAytlcANBAJkq9ayy+d4AUZnjhWM31rFke5DT+l9QFcPl34+dpVhqJV/L
|
||||
m4IpPHYagqrBRHn9Dv/C8qYyPBKnAwJRpNptsgw=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'aaa.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
|
||||
MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
|
||||
GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
|
||||
YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
|
||||
MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
|
||||
BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
|
||||
GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
|
||||
ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
|
||||
BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
|
||||
3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
|
||||
YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
|
||||
rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
|
||||
ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
|
||||
oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
|
||||
MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
|
||||
QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
|
||||
b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
|
||||
AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
|
||||
GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
|
||||
Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
|
||||
G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
|
||||
l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
|
||||
smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g1.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD
|
||||
VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU
|
||||
ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH
|
||||
MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO
|
||||
MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv
|
||||
Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz
|
||||
f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO
|
||||
8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq
|
||||
d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM
|
||||
tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt
|
||||
Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB
|
||||
o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD
|
||||
AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x
|
||||
PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM
|
||||
wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d
|
||||
GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH
|
||||
6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby
|
||||
RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
|
||||
iN66zB+Afko=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g3.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG
|
||||
EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo
|
||||
bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g
|
||||
RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ
|
||||
TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s
|
||||
b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw
|
||||
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0
|
||||
WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS
|
||||
fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB
|
||||
zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq
|
||||
hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB
|
||||
CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD
|
||||
+JbNR6iC8hZVdyR+EhCVBCyj
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""The managed browser policy must distrust exactly the incident's eight keys."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
# Public incident inventory: impacted-intermediates.json, 2026-10-21.
|
||||
COMPROMISED_SPKIS = {
|
||||
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
|
||||
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
|
||||
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
|
||||
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
|
||||
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
|
||||
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
|
||||
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
|
||||
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6",
|
||||
}
|
||||
|
||||
|
||||
class ChromiumCATrustTests(unittest.TestCase):
|
||||
def test_policy_distrusts_each_compromised_key_without_adding_trust(self):
|
||||
policy = json.loads((ROOT / "docker" / "chromium-emudhra.json").read_text())
|
||||
self.assertEqual(set(policy), {"CADistrustedCertificates"})
|
||||
self.assertEqual(len(policy["CADistrustedCertificates"]), 8)
|
||||
keys = set()
|
||||
for encoded in policy["CADistrustedCertificates"]:
|
||||
certificate = x509.load_der_x509_certificate(base64.b64decode(encoded, validate=True))
|
||||
self.assertTrue(certificate.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
|
||||
self.assertNotEqual(certificate.subject, certificate.issuer)
|
||||
public_key = certificate.public_key().public_bytes(
|
||||
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
keys.add(hashlib.sha256(public_key).hexdigest())
|
||||
self.assertEqual(keys, COMPROMISED_SPKIS)
|
||||
@@ -33,3 +33,16 @@ def test_python_tests_are_authoritative():
|
||||
|
||||
assert "python -m pytest -q" in python_tests
|
||||
assert "continue-on-error:" not in python_tests
|
||||
|
||||
|
||||
def test_ci_checks_system_trust_before_project_installation():
|
||||
python_tests = _indented_block(_WORKFLOW.read_text(), "python-tests", 2)
|
||||
system_check = python_tests.index("python scripts/check_ca_trust.py --system\n")
|
||||
system_update = python_tests.index("sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates")
|
||||
bootstrap = python_tests.index("uv pip install --system --require-hashes")
|
||||
python_check = python_tests.index("python scripts/check_ca_trust.py --python\n")
|
||||
application_install = python_tests.index("uv pip install --system -r requirements.txt")
|
||||
|
||||
assert system_update < system_check < bootstrap < python_check < application_install
|
||||
assert system_check < python_tests.index("npm ci")
|
||||
assert system_check < python_tests.index("npx playwright install")
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Execute the publication workflow's image-name setup without contacting GHCR."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
WORKFLOW = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ ".github"
|
||||
/ "workflows"
|
||||
/ "docker-publish.yml"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="Docker publication requires Bash")
|
||||
@pytest.mark.parametrize("job", ["build", "merge"])
|
||||
@pytest.mark.parametrize(
|
||||
("repository", "expected"),
|
||||
[
|
||||
("RaresKeY/odysseus-dev", "rareskey/odysseus-dev"),
|
||||
("ExampleOwner/Example-Repo", "exampleowner/example-repo"),
|
||||
("odysseus-dev/odysseus", "odysseus-dev/odysseus"),
|
||||
],
|
||||
)
|
||||
def test_image_name_is_lowercase_before_docker_steps(tmp_path, job, repository, expected):
|
||||
workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
image_name = workflow["env"].get("IMAGE_NAME", "").replace(
|
||||
"${{ github.repository }}", repository
|
||||
)
|
||||
github_env = tmp_path / "github-env"
|
||||
env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"GITHUB_REPOSITORY": repository,
|
||||
"GITHUB_ENV": str(github_env),
|
||||
"GITHUB_OUTPUT": str(tmp_path / "github-output"),
|
||||
"GITHUB_SHA": "a" * 40,
|
||||
}
|
||||
|
||||
# Run the real setup shell before the job starts using Docker actions.
|
||||
# Checkout, login, builds and registry publication are deliberately excluded.
|
||||
for step in workflow["jobs"][job]["steps"]:
|
||||
if step.get("uses", "").startswith("docker/"):
|
||||
break
|
||||
if "run" not in step:
|
||||
continue
|
||||
subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
|
||||
cwd=WORKFLOW.parents[2],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if github_env.exists():
|
||||
env.update(line.split("=", 1) for line in github_env.read_text().splitlines())
|
||||
image_name = env.get("IMAGE_NAME", image_name)
|
||||
|
||||
assert image_name == expected
|
||||
@@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
|
||||
assert "_libCacheWriteBack()" in src
|
||||
|
||||
|
||||
def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
def test_email_bulk_exports_messages_and_attachments_from_selected_context():
|
||||
frontend = email_library_source()
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
export_src = frontend[
|
||||
frontend.index("async function _exportSelectedAttachments()"):
|
||||
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
|
||||
]
|
||||
export_src = _function_source("_exportSelectedBundle")
|
||||
payload_src = _function_source("_selectedEmailExportPayload")
|
||||
|
||||
assert "Export Emails" in frontend
|
||||
assert "Email file (.eml)" in frontend
|
||||
assert "_exportSelectedEmailPdf" in frontend
|
||||
assert "Export Attachments" in frontend
|
||||
assert "All in view" in frontend
|
||||
assert "/api/email/attachments-download-bulk" in export_src
|
||||
assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src
|
||||
assert "method: 'POST'" in export_src
|
||||
assert "res.blob()" in export_src
|
||||
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
|
||||
@@ -63,6 +63,96 @@ def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
assert "_fixture_email_read(uid, folder, owner)" in backend
|
||||
assert "_is_likely_signature_image_attachment(att)" in backend
|
||||
|
||||
assert '@router.post("/messages-download-bulk")' in backend
|
||||
assert '"(UID RFC822)"' in backend
|
||||
assert "msg.as_bytes(policy=email_policy.SMTP)" in backend
|
||||
assert 'media_type="message/rfc822"' in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_print_pdf_export_reads_without_marking_read_and_sanitizes_body():
|
||||
"""Print / save PDF export must use the browser print dialog (SAN-157).
|
||||
|
||||
The removed html2pdf library must not be loaded or referenced. Instead
|
||||
the export uses a sandboxed iframe and window.print(), matching the
|
||||
established pattern in document.js.
|
||||
"""
|
||||
frontend = email_library_source()
|
||||
print_src = _function_source("_printSelectedEmails")
|
||||
menu_src = js_function_source("_showBulkActionsMenu", frontend)
|
||||
|
||||
assert "mark_seen=false&full=true" in print_src
|
||||
assert "_safeRenderEmailBody(data)" in print_src
|
||||
assert "new DOMParser()" in print_src
|
||||
assert "up to 20 emails" in print_src
|
||||
assert "Print / save PDF" in menu_src
|
||||
|
||||
# SAN-157: must NOT reference the removed html2pdf library.
|
||||
assert "html2pdf" not in print_src
|
||||
assert "html2pdf" not in frontend
|
||||
|
||||
# Must use the browser print pattern with a sandboxed iframe.
|
||||
assert "contentWindow.print()" in print_src
|
||||
assert "sandbox" in print_src
|
||||
assert "allow-same-origin" in print_src
|
||||
assert "allow-modals" in print_src
|
||||
assert "afterprint" in print_src
|
||||
assert "email-browser-print-frame" in print_src
|
||||
|
||||
|
||||
def test_email_eml_export_bounds_total_size():
|
||||
"""The /messages-download-bulk endpoint must enforce a total size cap
|
||||
to prevent unbounded memory growth when exporting many large messages."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "max_total_bytes" in backend
|
||||
assert "total_bytes" in backend
|
||||
assert "Export too large" in backend
|
||||
assert "status_code=413" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_validates_selection_count():
|
||||
"""The /messages-download-bulk endpoint must reject oversized selections."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert 'len(messages) > 250' in backend
|
||||
assert '"Too many emails selected"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_rejects_non_numeric_remote_uids():
|
||||
"""Non-numeric UIDs must not be passed to IMAP UID FETCH."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# After fixture lookup fails, non-numeric UIDs are skipped.
|
||||
assert 're.fullmatch(r"\\d+", uid)' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_handles_partial_batch_failures():
|
||||
"""When an IMAP fetch batch returns partial results, exported/skipped
|
||||
accounting must remain correct."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# The skipped counter increments for UIDs not found in the batch response.
|
||||
assert "skipped += len(batch) - len(found)" in backend
|
||||
# Whole-connection failures count remaining unprocessed rows as skipped.
|
||||
assert "skipped += remaining_rows" in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
assert '"X-Skipped-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_enforces_account_ownership():
|
||||
"""The /messages-download-bulk endpoint must assert account ownership."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "_assert_owns_account(account_id, owner)" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_deduplicates_selection():
|
||||
"""Duplicate items in selection must be skipped to avoid duplicate files."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "seen_items" in backend
|
||||
|
||||
|
||||
def test_fixture_tag_filter_supports_receipt_tag_flow():
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
@@ -171,3 +261,808 @@ def test_email_compose_can_attach_gallery_images():
|
||||
assert "GalleryImage" in backend
|
||||
assert "_gallery_image_path(img.filename)" in backend
|
||||
assert "_stage_compose_file" in backend
|
||||
|
||||
|
||||
class _MockImapConn:
|
||||
def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None, fetch_cb=None):
|
||||
self.messages = messages_by_uid or {}
|
||||
self.fetch_exc = fetch_exc
|
||||
self.fetch_cb = fetch_cb
|
||||
self.fetch_calls: list = []
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
if command == "FETCH":
|
||||
self.fetch_calls.append(args)
|
||||
if self.fetch_exc is not None:
|
||||
raise self.fetch_exc
|
||||
if self.fetch_cb:
|
||||
return self.fetch_cb(args)
|
||||
uid_set_b = args[0]
|
||||
uid_set = uid_set_b.decode() if isinstance(uid_set_b, bytes) else str(uid_set_b)
|
||||
uids = [u.strip() for u in uid_set.split(",") if u.strip()]
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
res = []
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822.SIZE {len(self.messages[u])})".encode()
|
||||
res.append(meta)
|
||||
return "OK", res
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822 {{{len(self.messages[u])}}}".encode()
|
||||
res.append((meta, self.messages[u]))
|
||||
res.append(b")")
|
||||
return "OK", res
|
||||
return "NO", []
|
||||
|
||||
|
||||
def _create_test_app(monkeypatch, mailbox: _MockImapConn | None = None, assert_owns_fn=None):
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
conn = mailbox or _MockImapConn()
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield conn
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
if assert_owns_fn:
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", assert_owns_fn)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_messages_download_bulk_validates_empty_and_oversized_payload(monkeypatch):
|
||||
client = _create_test_app(monkeypatch)
|
||||
|
||||
res_empty = client.post("/api/email/messages-download-bulk", json={"messages": []})
|
||||
assert res_empty.status_code == 400
|
||||
assert "No emails selected" in res_empty.text
|
||||
|
||||
oversized = [{"uid": str(i)} for i in range(251)]
|
||||
res_over = client.post("/api/email/messages-download-bulk", json={"messages": oversized})
|
||||
assert res_over.status_code == 400
|
||||
assert "Too many emails selected" in res_over.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_eml_export(monkeypatch):
|
||||
raw_rfc822 = b"From: sender@example.com\r\nTo: test-user\r\nSubject: Report\r\n\r\nReport content"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Quarterly Report", "date": "2026-10-20"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"].startswith("message/rfc822")
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
assert "email.eml" in res.headers["content-disposition"]
|
||||
assert res.content == raw_rfc822
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_message_zip_export(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: First\r\n\r\nOne"
|
||||
msg2 = b"From: b@example.com\r\nSubject: Second\r\n\r\nTwo"
|
||||
mailbox = _MockImapConn({"101": msg1, "102": msg2})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "First Msg", "date": "2026-10-18"},
|
||||
{"uid": "102", "subject": "Second Msg", "date": "2026-10-19"},
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
namelist = zf.namelist()
|
||||
assert len(namelist) == 2
|
||||
assert any("101.eml" in name for name in namelist)
|
||||
assert any("102.eml" in name for name in namelist)
|
||||
|
||||
|
||||
def test_messages_download_bulk_partial_failure_and_duplicate_dedup(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nExists body"
|
||||
# UID 101 exists, UID 999 does not exist in mailbox
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "Exists"},
|
||||
{"uid": "999", "subject": "Missing"},
|
||||
{"uid": "101", "subject": "Duplicate"}, # duplicate must be skipped
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "2"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_enforces_account_ownership_guard(monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
def mock_assert_owns(account_id, owner):
|
||||
if account_id == "other-tenant-acct":
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
client = _create_test_app(monkeypatch, assert_owns_fn=mock_assert_owns)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "account_id": "other-tenant-acct"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 404
|
||||
assert "Account not found" in res.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_mid_batch_failure_accounting(monkeypatch):
|
||||
"""When an exception occurs mid-batch after partial progress, already-exported
|
||||
records must not be double-counted as skipped."""
|
||||
class MidBatchFailImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"{i} (UID {i} RFC822.SIZE 100)".encode() for i in range(1, 11)]
|
||||
records = []
|
||||
for i in range(1, 5):
|
||||
records.append((f"{i} (UID {i} RFC822 {{{10}}}".encode(), f"Subject: Msg {i}\r\n\r\nBody {i}".encode()))
|
||||
class BadRecord:
|
||||
def __bytes__(self):
|
||||
raise RuntimeError("Corrupt memory buffer")
|
||||
records.append((b"5 (UID 5 RFC822 {10}", BadRecord()))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield MidBatchFailImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
rows = [{"uid": str(i)} for i in range(1, 11)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": rows})
|
||||
assert res.status_code == 200
|
||||
exp = int(res.headers.get("x-exported-emails"))
|
||||
skp = int(res.headers.get("x-skipped-emails"))
|
||||
assert exp == 4
|
||||
assert skp == 6
|
||||
assert exp + skp == 10
|
||||
|
||||
|
||||
def test_messages_download_bulk_size_cap_413_boundary(monkeypatch):
|
||||
"""Cumulative export size exceeding max_total_bytes returns HTTP 413."""
|
||||
class LargeMessageImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
raw_uids = args[0] if isinstance(args[0], str) else args[0].decode()
|
||||
if "(UID RFC822.SIZE)" in str(args):
|
||||
records = []
|
||||
for u in raw_uids.split(","):
|
||||
records.append(f"{u} (UID {u} RFC822.SIZE 10485760)".encode())
|
||||
return "OK", records
|
||||
records = []
|
||||
big_chunk = b"X" * (10 * 1024 * 1024)
|
||||
for u in raw_uids.split(","):
|
||||
records.append((f"{u} (UID {u} RFC822".encode(), big_chunk))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield LargeMessageImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
# 52 messages of 10 MB = 520 MB > 512 MiB limit
|
||||
payload_52 = [{"uid": str(i + 1), "subject": f"Big {i}"} for i in range(52)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload_52})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_per_message_limit_413(monkeypatch):
|
||||
"""A single oversized message (>64 MiB) triggers HTTP 413."""
|
||||
class HugeSingleMsgImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 73400320)"]
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield HugeSingleMsgImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Huge Email"}]
|
||||
})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_account_ownership_memoization(monkeypatch):
|
||||
"""Account ownership validation is memoized per distinct account_id to eliminate N+1 DB queries."""
|
||||
owns_calls = []
|
||||
def counting_assert_owns(account_id, owner):
|
||||
owns_calls.append(account_id)
|
||||
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
msgs = {str(i): raw_rfc822 for i in range(1, 26)}
|
||||
mailbox = _MockImapConn(msgs)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox, assert_owns_fn=counting_assert_owns)
|
||||
|
||||
# 25 messages all referencing the same account_id
|
||||
payload = [{"uid": str(i), "account_id": "acc-shared"} for i in range(1, 26)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload})
|
||||
assert res.status_code == 200
|
||||
assert len(owns_calls) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_corrupt_zip_fails_closed(monkeypatch):
|
||||
"""If ZIP construction fails (e.g. disk error), fail closed with 500 instead of returning corrupt archive."""
|
||||
import zipfile
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822, "102": raw_rfc822})
|
||||
|
||||
orig_writestr = zipfile.ZipFile.writestr
|
||||
def failing_writestr(self, zinfo_or_arcname, data=None, *args, **kwargs):
|
||||
raise OSError("Disk write failed: No space left on device")
|
||||
|
||||
monkeypatch.setattr(zipfile.ZipFile, "writestr", failing_writestr)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 500
|
||||
assert "Failed to create export archive" in res.json()["detail"]
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_no_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns NO, size safety is unknown: fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy", "102": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Unable to verify email size safety" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_empty_missing_mail(monkeypatch):
|
||||
"""When preflight returns empty data, all requested messages are missing on server: returns 404 with no body fetch."""
|
||||
mailbox = _MockImapConn() # no messages
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 404
|
||||
assert "No selected emails could be exported" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_malformed_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns records without RFC822.SIZE, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 FLAGS (\\Seen))"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_inconsistent_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns negative or corrupted size values, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 RFC822.SIZE -50)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_partial_missing_omitted_from_body_fetch(monkeypatch):
|
||||
"""When some requested UIDs do not exist on the server, omit them from body fetch and export verified items."""
|
||||
msg1 = b"From: a@example.com\r\nSubject: Only One\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": msg1}) # 102 does not exist on server
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
# Verify body fetch was only requested for 101, NOT 102
|
||||
body_fetches = [call for call in mailbox.fetch_calls if "(UID RFC822)" in str(call)]
|
||||
assert len(body_fetches) == 1
|
||||
requested_uids = body_fetches[0][0].decode() if isinstance(body_fetches[0][0], bytes) else str(body_fetches[0][0])
|
||||
assert requested_uids == "101"
|
||||
assert "102" not in requested_uids
|
||||
|
||||
|
||||
def test_messages_download_bulk_duplicate_imap_records_rejected(monkeypatch):
|
||||
"""Duplicate IMAP FETCH response records for the same UID must be ignored.
|
||||
They must not produce duplicate archive entries or corrupt accounting."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Unique\r\n\r\nBody 1"
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 100)", b"2 (UID 102 RFC822.SIZE 100)"]
|
||||
# In body fetch, server sends duplicate records for 101, and nothing for 102
|
||||
records = [
|
||||
(b"1 (UID 101 RFC822 {50}", msg1),
|
||||
b")",
|
||||
(b"1 (UID 101 RFC822 {50}", msg1), # DUPLICATE RECORD
|
||||
b")",
|
||||
]
|
||||
return "OK", records
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
assert "101.eml" in zf.namelist()[0]
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_preflight_failure_no_body_fetch(monkeypatch):
|
||||
"""In single message mode, bad preflight or missing mail fails safely without body fetch."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
|
||||
# Case A: preflight NO -> 502
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
res_no = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_no.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case B: missing mail -> 404
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_miss = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_miss.status_code == 404
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case C: malformed metadata -> 502
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 NO_SIZE)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_mal = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_mal.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_spooled_stream_lifecycle():
|
||||
"""Verify that _iter_spooled closes the underlying SpooledTemporaryFile on completion and GeneratorExit."""
|
||||
import tempfile
|
||||
from routes.email.email_routes import setup_email_routes
|
||||
|
||||
# Test normal completion
|
||||
f = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f.write(b"Hello world payload")
|
||||
f.seek(0)
|
||||
# Re-create generator helper pattern
|
||||
def _iter(file_obj, chunk_size=64):
|
||||
try:
|
||||
while True:
|
||||
chunk = file_obj.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
file_obj.close()
|
||||
|
||||
gen = _iter(f)
|
||||
chunks = list(gen)
|
||||
assert b"".join(chunks) == b"Hello world payload"
|
||||
assert f.closed
|
||||
|
||||
# Test early disconnect / cancellation (GeneratorExit)
|
||||
f2 = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f2.write(b"Chunk1Chunk2Chunk3")
|
||||
f2.seek(0)
|
||||
gen2 = _iter(f2, chunk_size=6)
|
||||
next(gen2)
|
||||
assert not f2.closed
|
||||
gen2.close() # Simulates client disconnect / generator cancellation
|
||||
assert f2.closed
|
||||
|
||||
|
||||
def test_uid_and_size_from_fetch_meta_case_insensitivity_and_validation():
|
||||
"""Unit test: _uid_from_fetch_meta and _size_from_fetch_meta case insensitivity and boundary validation."""
|
||||
from routes.email.email_routes import _uid_from_fetch_meta, _size_from_fetch_meta
|
||||
|
||||
# 1. Standard uppercase UID and RFC822.SIZE
|
||||
meta_upper = b"1 (UID 101 RFC822.SIZE 1234)"
|
||||
assert _uid_from_fetch_meta(meta_upper) == "101"
|
||||
assert _size_from_fetch_meta(meta_upper) == 1234
|
||||
|
||||
# 2. Lowercase uid and rfc822.size
|
||||
meta_lower = b"1 (uid 202 rfc822.size 5678)"
|
||||
assert _uid_from_fetch_meta(meta_lower) == "202"
|
||||
assert _size_from_fetch_meta(meta_lower) == 5678
|
||||
|
||||
# 3. Mixed-case combinations
|
||||
combos = [
|
||||
(b"1 (Uid 303 Rfc822.Size 1000)", "303", 1000),
|
||||
(b"1 (uID 404 RFC822.size 2000)", "404", 2000),
|
||||
(b"1 (UID 505 rfc822.size 3000)", "505", 3000),
|
||||
(b"1 (uid 606 RFC822.SIZE 4000)", "606", 4000),
|
||||
(b"1 (uId 707 Rfc822.SIZE 5000)", "707", 5000),
|
||||
(b"1 (FLAGS (\\Seen) uid 808 rfc822.Size 6000)", "808", 6000),
|
||||
]
|
||||
for meta_bytes, exp_uid, exp_sz in combos:
|
||||
assert _uid_from_fetch_meta(meta_bytes) == exp_uid
|
||||
assert _size_from_fetch_meta(meta_bytes) == exp_sz
|
||||
|
||||
# 4. Malformed metadata cases
|
||||
assert _uid_from_fetch_meta(b"1 (rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 flags (\\seen))") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid abc rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size invalid)") is None
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size -50)") is None
|
||||
|
||||
# 5. Boundary integrity (must not match substrings like UUID, GUID, NOT_RFC822.SIZE)
|
||||
assert _uid_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid101 rfc822.size1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid101 rfc822.size1234)") is None
|
||||
assert _uid_from_fetch_meta(b"") == ""
|
||||
assert _size_from_fetch_meta(b"") is None
|
||||
|
||||
|
||||
def test_messages_download_bulk_case_insensitive_imap_atoms(monkeypatch):
|
||||
"""End-to-end: Bulk and single-message export with lowercase and mixed-case IMAP atoms."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 101\r\n\r\nBody 1"
|
||||
msg102 = b"From: b@example.com\r\nSubject: Item 102\r\n\r\nBody 2"
|
||||
mailbox = _MockImapConn({"101": msg101, "102": msg102})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd or "rfc822.size" in fetch_cmd.lower():
|
||||
# Server returns lowercase/mixed-case atoms
|
||||
return "OK", [
|
||||
b"1 (uid 101 rfc822.size " + str(len(msg101)).encode() + b")",
|
||||
b"2 (Uid 102 Rfc822.Size " + str(len(msg102)).encode() + b")",
|
||||
]
|
||||
# In body fetch, server returns mixed-case atoms
|
||||
return "OK", [
|
||||
(b"1 (uid 101 rfc822 {" + str(len(msg101)).encode(), msg101),
|
||||
b")",
|
||||
(b"2 (Uid 102 RFC822 {" + str(len(msg102)).encode(), msg102),
|
||||
b")",
|
||||
]
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
# 1. Bulk download (2 items) with lowercase / mixed-case IMAP responses
|
||||
res_bulk = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res_bulk.status_code == 200
|
||||
assert res_bulk.headers["x-exported-emails"] == "2"
|
||||
assert res_bulk.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res_bulk.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
# 2. Single message download fast path with lowercase IMAP response
|
||||
mailbox.fetch_calls.clear()
|
||||
res_single = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_single.status_code == 200
|
||||
assert res_single.headers["content-type"].startswith("message/rfc822")
|
||||
assert res_single.content == msg101
|
||||
|
||||
# 3. Lowercase malformed preflight fails safely with 502 and NO body fetch
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: (
|
||||
("OK", [b"1 (uid 101 rfc822.size NaN)"])
|
||||
if "RFC822.SIZE" in str(args) or "rfc822.size" in str(args).lower()
|
||||
else ("OK", [])
|
||||
)
|
||||
res_malformed = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_malformed.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res_malformed.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_preflight_failure_propagates_502(monkeypatch):
|
||||
"""A preflight safety failure in any group produces HTTP 502 for the entire request,
|
||||
even when another group has already exported messages into the temporary ZIP.
|
||||
Confirms temporary-file cleanup, zero body FETCH for the failed group, and both group orderings."""
|
||||
import tempfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Good\r\n\r\nBody 1"
|
||||
|
||||
spooled_instances: list[tempfile.SpooledTemporaryFile] = []
|
||||
orig_spooled = tempfile.SpooledTemporaryFile
|
||||
|
||||
def tracking_spooled(*args, **kwargs):
|
||||
inst = orig_spooled(*args, **kwargs)
|
||||
spooled_instances.append(inst)
|
||||
return inst
|
||||
|
||||
monkeypatch.setattr(tempfile, "SpooledTemporaryFile", tracking_spooled)
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list[tuple[str, tuple]] = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", [] # Preflight failure for folder-fail
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE 100)".encode()]
|
||||
|
||||
# Body fetch
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", []
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{100}}".encode(), msg101), b")"]
|
||||
|
||||
# 1. Successful group followed by failed preflight
|
||||
conn1 = MultiMailboxConn()
|
||||
client1 = _create_test_app(monkeypatch, mailbox=conn1)
|
||||
spooled_instances.clear()
|
||||
|
||||
res1 = client1.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
]
|
||||
})
|
||||
assert res1.status_code == 502
|
||||
assert "Unable to verify email size safety" in res1.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches1 = [
|
||||
call for folder, call in conn1.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches1) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
# 2. Failed preflight followed by successful group
|
||||
conn2 = MultiMailboxConn()
|
||||
client2 = _create_test_app(monkeypatch, mailbox=conn2)
|
||||
spooled_instances.clear()
|
||||
|
||||
res2 = client2.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
]
|
||||
})
|
||||
assert res2.status_code == 502
|
||||
assert "Unable to verify email size safety" in res2.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches2 = [
|
||||
call for folder, call in conn2.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches2) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_both_successful(monkeypatch):
|
||||
"""When both groups succeed preflight and body fetch, return 200 with all messages in ZIP."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 1\r\n\r\nBody 1"
|
||||
msg202 = b"From: b@example.com\r\nSubject: Item 2\r\n\r\nBody 2"
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
msg = msg101 if "101" in uid_set else msg202
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE {len(msg)})".encode()]
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{{len(msg)}}}".encode(), msg), b")"]
|
||||
|
||||
conn = MultiMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "202", "folder": "folder-2"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_genuinely_missing_message(monkeypatch):
|
||||
"""Genuinely missing messages (unreturned by preflight) do not cause 502;
|
||||
they are skipped while verified messages in any group are successfully exported."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nBody"
|
||||
|
||||
class MissingMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
# 101 exists on server; 999 is genuinely missing (unreported in size metadata)
|
||||
res = []
|
||||
if "101" in uid_set:
|
||||
res.append(f"1 (UID 101 RFC822.SIZE {len(msg101)})".encode())
|
||||
return "OK", res
|
||||
# Body fetch only returns 101
|
||||
return "OK", [(f"1 (UID 101 RFC822 {{{len(msg101)}}}".encode(), msg101), b")"]
|
||||
|
||||
conn = MissingMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "999", "folder": "folder-1"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
# Verify 999 was never requested in body fetch
|
||||
body_fetches = [
|
||||
call for folder, call in conn.fetch_calls
|
||||
if "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(body_fetches) == 1
|
||||
assert "999" not in str(body_fetches[0])
|
||||
|
||||
|
||||
def test_email_print_browser_behaviors():
|
||||
"""Real Chromium DOM: placeholder stripping, button removal, and partial failure."""
|
||||
import shutil
|
||||
import subprocess
|
||||
if not shutil.which('node'):
|
||||
import pytest
|
||||
pytest.skip('Node is required')
|
||||
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=_REPO, capture_output=True)
|
||||
if probe.returncode:
|
||||
import pytest
|
||||
pytest.skip('Playwright is required')
|
||||
result = subprocess.run(
|
||||
['node', str(_REPO / 'tests/test_email_library_print_browser.cjs')],
|
||||
cwd=_REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "ALL_BROWSER_TESTS_PASSED" in result.stdout
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Browser print regression tests for Email Library print and PDF export.
|
||||
*
|
||||
* Requirements:
|
||||
* - Node.js (v20+)
|
||||
* - Playwright (`npm ci` or `npm install -D @playwright/test`)
|
||||
* - Chromium browser binary:
|
||||
* - Standard Playwright install: `npx playwright install chromium`
|
||||
* - Or custom executable via env vars:
|
||||
* - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome`
|
||||
* - `CHROME_PATH=/path/to/chrome`
|
||||
* - Or custom browsers cache dir via:
|
||||
* - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache`
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require('playwright'));
|
||||
} catch (err) {
|
||||
if (err && err.code === 'MODULE_NOT_FOUND') {
|
||||
console.error(
|
||||
'Error: Playwright module not found.\n' +
|
||||
'Please install dependencies by running:\n' +
|
||||
' npm ci\n' +
|
||||
'or ensure NODE_PATH contains the playwright package.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Discover available Chromium executable using standard Playwright resolution,
|
||||
// custom environment variables, standard cache paths, or system binaries.
|
||||
function findChromiumPath() {
|
||||
// 1. Explicit environment variable overrides
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) {
|
||||
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
}
|
||||
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
|
||||
return process.env.CHROME_PATH;
|
||||
}
|
||||
|
||||
// 2. Playwright's configured/default executable path
|
||||
try {
|
||||
const defaultPath = chromium.executablePath();
|
||||
if (defaultPath && fs.existsSync(defaultPath)) {
|
||||
return defaultPath;
|
||||
}
|
||||
} catch (e) {
|
||||
// chromium.executablePath() may throw if no default browser is installed
|
||||
}
|
||||
|
||||
// 3. Inspect standard Playwright browser cache directories
|
||||
const cacheDirs = [];
|
||||
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
||||
cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
|
||||
}
|
||||
cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright'));
|
||||
} else if (process.platform === 'darwin') {
|
||||
cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'));
|
||||
} else {
|
||||
cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright'));
|
||||
}
|
||||
|
||||
for (const cacheDir of cacheDirs) {
|
||||
if (!fs.existsSync(cacheDir)) continue;
|
||||
try {
|
||||
const entries = fs.readdirSync(cacheDir).sort().reverse();
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(cacheDir, entry);
|
||||
const candidates = [
|
||||
path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),
|
||||
path.join(entryPath, 'chrome-linux64', 'chrome'),
|
||||
path.join(entryPath, 'chrome-linux', 'chrome'),
|
||||
path.join(entryPath, 'chrome-win64', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-win', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 4. System browser fallbacks
|
||||
const systemCandidates = process.platform === 'win32' ? [
|
||||
path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
] : [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
];
|
||||
for (const sc of systemCandidates) {
|
||||
if (fs.existsSync(sc)) return sc;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
if (url.pathname.startsWith('/api/email/read/')) {
|
||||
const uid = url.pathname.replace('/api/email/read/', '');
|
||||
if (uid === 'fail_uid' || uid === 'net_fail') {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Mailbox disconnected' }));
|
||||
return;
|
||||
}
|
||||
if (uid === 'crash_network') {
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const markSeen = url.searchParams.get('mark_seen');
|
||||
if (markSeen !== 'false') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'mark_seen must be false' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body_html = '';
|
||||
if (uid === 'img_test') {
|
||||
body_html = [
|
||||
'<div>',
|
||||
'<p>Email with diverse images</p>',
|
||||
'<!-- Inline image with alt text -->',
|
||||
'<img src="cid:logo123" alt="Company Brand Logo">',
|
||||
'<!-- Remote image with title -->',
|
||||
'<img src="https://example.com/tracker.png" title="Tracking Pixel">',
|
||||
'<!-- Image without alt or title -->',
|
||||
'<img src="https://example.com/no-alt.jpg">',
|
||||
'<!-- Interactive control that shouldn\'t be in print -->',
|
||||
'<button type="button" class="btn-action">Interactive Action</button>',
|
||||
'<!-- XSS payload -->',
|
||||
'<script>window.__pwned = true;</script>',
|
||||
'</div>'
|
||||
].join('\n');
|
||||
} else if (uid === 'long_msg') {
|
||||
body_html = `<div><p>${'A'.repeat(5000)}</p></div>`;
|
||||
} else {
|
||||
body_html = `<div><p>Standard message body for ${uid}</p></div>`;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
uid: uid,
|
||||
subject: `Subject ${uid}`,
|
||||
from_name: 'Sender',
|
||||
from_address: 'sender@example.com',
|
||||
to: 'user@example.com',
|
||||
date: '2026-10-20',
|
||||
date_display: 'Oct 20, 2026',
|
||||
body: `Plain text for ${uid}`,
|
||||
body_html: body_html,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = path.join(ROOT, url.pathname);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath);
|
||||
const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain';
|
||||
res.writeHead(200, { 'Content-Type': mime });
|
||||
res.end(fs.readFileSync(filePath));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const port = server.address().port;
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
|
||||
const launchOpts = { headless: true };
|
||||
const chromePath = findChromiumPath();
|
||||
if (chromePath) launchOpts.executablePath = chromePath;
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch(launchOpts);
|
||||
} catch (launchErr) {
|
||||
throw new Error(
|
||||
`Failed to launch Chromium browser: ${launchErr.message}\n` +
|
||||
'Missing or unexecutable Chromium binary.\n' +
|
||||
'To install Playwright Chromium, run:\n' +
|
||||
' npx playwright install chromium\n' +
|
||||
'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.'
|
||||
);
|
||||
}
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const results = {};
|
||||
|
||||
try {
|
||||
await page.goto(`${origin}/static/index.html`);
|
||||
await page.evaluate(() => {
|
||||
if (!document.getElementById('email-lib-bulk-actions')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d);
|
||||
}
|
||||
if (!document.getElementById('email-lib-selected-count')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d);
|
||||
}
|
||||
});
|
||||
|
||||
// TEST 1: Full Print Image Sanitization and Replacement
|
||||
const printImageResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['img_test']);
|
||||
stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }];
|
||||
|
||||
let iframePrintInvoked = false;
|
||||
let printDocHtml = '';
|
||||
let afterPrintTriggered = false;
|
||||
let hasPlaceholderElement = false;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
iframePrintInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
const body = frame.contentDocument ? frame.contentDocument.body : null;
|
||||
hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder');
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
afterPrintTriggered = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
|
||||
return {
|
||||
iframePrintInvoked,
|
||||
afterPrintTriggered,
|
||||
frameRemovedAfterPrint: frameAfter === null,
|
||||
hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'),
|
||||
hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'),
|
||||
hasNoAltNotice: printDocHtml.includes('[Image omitted]'),
|
||||
hasPlaceholderElement,
|
||||
hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'),
|
||||
hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'),
|
||||
hasInteractiveActionBtn: printDocHtml.includes('btn-action'),
|
||||
hasXSS: printDocHtml.includes('<script>') || printDocHtml.includes('window.__pwned'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.printImageSanitization = printImageResult;
|
||||
|
||||
// TEST 2: Partial Failure - 1 failure out of 3 emails
|
||||
const partialFailureResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'msg1', subject: 'Email One', folder: 'INBOX' },
|
||||
{ uid: 'fail_uid', subject: 'Failing Email', folder: 'INBOX' },
|
||||
{ uid: 'msg2', subject: 'Email Two', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
let printDocHtml = '';
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
toast,
|
||||
hasMsg1: printDocHtml.includes('Subject msg1'),
|
||||
hasMsg2: printDocHtml.includes('Subject msg2'),
|
||||
hasOmittedNotice: printDocHtml.includes('1 selected email was unavailable and omitted from this print'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.partialFailureTest = partialFailureResult;
|
||||
|
||||
// TEST 3: All emails fail
|
||||
const allFailedResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'fail_uid', subject: 'Fail 1', folder: 'INBOX' },
|
||||
{ uid: 'net_fail', subject: 'Fail 2', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
let thrownError = null;
|
||||
try {
|
||||
await mod._exportSelectedEmailPdf();
|
||||
} catch (err) {
|
||||
thrownError = err.message || String(err);
|
||||
}
|
||||
observer.disconnect();
|
||||
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
frameOpened: frame !== null,
|
||||
thrownError,
|
||||
toast,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.allFailedTest = allFailedResult;
|
||||
|
||||
// TEST 4: Idempotent Iframe Cleanup & Window Afterprint
|
||||
const cleanupResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['msg1']);
|
||||
stateMod.state._libEmails = [{ uid: 'msg1', subject: 'Cleanup Test', folder: 'INBOX' }];
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
return {
|
||||
frameRemovedViaWindowAfterprint: frameAfter === null,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.cleanupTest = cleanupResult;
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
|
||||
// Assertions
|
||||
const t1 = results.printImageSanitization;
|
||||
if (!t1.iframePrintInvoked) throw new Error('Assertion failed: print not invoked');
|
||||
if (!t1.frameRemovedAfterPrint) throw new Error('Assertion failed: frame not removed');
|
||||
if (!t1.hasLogoNotice) throw new Error('Assertion failed: [Image: Company Brand Logo] missing');
|
||||
if (!t1.hasTrackerNotice) throw new Error('Assertion failed: [Image: Tracking Pixel] missing');
|
||||
if (!t1.hasNoAltNotice) throw new Error('Assertion failed: [Image omitted] missing');
|
||||
if (t1.hasPlaceholderElement) throw new Error('Assertion failed: .email-inline-image-placeholder found in print doc');
|
||||
if (t1.hasRemoteImageBlockedText) throw new Error('Assertion failed: Remote image blocked leaked into print doc');
|
||||
if (t1.hasDownloadButton) throw new Error('Assertion failed: download button leaked into print doc');
|
||||
if (t1.hasInteractiveActionBtn) throw new Error('Assertion failed: interactive button leaked into print doc');
|
||||
if (t1.hasXSS) throw new Error('Assertion failed: script tag found');
|
||||
|
||||
const t2 = results.partialFailureTest;
|
||||
if (!t2.printInvoked) throw new Error('Assertion failed: partial print was not invoked');
|
||||
if (!t2.hasMsg1 || !t2.hasMsg2) throw new Error('Assertion failed: successful messages missing from print');
|
||||
if (!t2.hasOmittedNotice) throw new Error('Assertion failed: omitted notice banner missing');
|
||||
if (!t2.toast.includes('Prepared 2 emails for printing; 1 unavailable')) {
|
||||
throw new Error('Assertion failed: toast text mismatch: ' + t2.toast);
|
||||
}
|
||||
|
||||
const t3 = results.allFailedTest;
|
||||
if (t3.printInvoked) throw new Error('Assertion failed: print should NOT be invoked when all fail');
|
||||
if (t3.frameOpened) throw new Error('Assertion failed: print frame should NOT remain in DOM');
|
||||
|
||||
const t4 = results.cleanupTest;
|
||||
if (!t4.frameRemovedViaWindowAfterprint) throw new Error('Assertion failed: top window afterprint did not clean up frame');
|
||||
|
||||
console.log('ALL_BROWSER_TESTS_PASSED');
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Browser test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Exercise removal using public X.509 certificates, without network access."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("searxng_repair", ROOT / "docker/searxng/repair_trust.py")
|
||||
repair = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(repair)
|
||||
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||
|
||||
|
||||
class SearxngTrustTests(unittest.TestCase):
|
||||
def test_inventory_matches_all_published_fixture_ca_fingerprints(self):
|
||||
expected = set(INCIDENT["roots"]) | set(INCIDENT["intermediates"]) | set(INCIDENT["cross_signed_roots"])
|
||||
self.assertEqual(set(repair.BLOCKED), expected)
|
||||
self.assertEqual(len(expected), 21)
|
||||
for fingerprint in expected:
|
||||
self.assertEqual(hashlib.sha256(ssl.PEM_cert_to_DER_cert(INCIDENT["certificates"][fingerprint])).hexdigest(), fingerprint)
|
||||
|
||||
def test_all_known_incident_certificates_are_removed_preserving_other_certificates(self):
|
||||
unaffected = (ROOT / "tests/fixtures/ca-trust/emsign-g1.txt").read_text()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "bundle.pem"
|
||||
for fingerprint in repair.BLOCKED:
|
||||
with self.subTest(fingerprint=fingerprint):
|
||||
path.write_text(INCIDENT["certificates"][fingerprint] + "\n" + unaffected)
|
||||
self.assertEqual(repair.filter_bundle(path), 1)
|
||||
self.assertEqual(path.read_text().strip(), unaffected.strip())
|
||||
self.assertEqual(repair.check_bundle(path), 1)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.load_verify_locations(cafile=str(path))
|
||||
self.assertEqual(len(context.get_ca_certs()), 1)
|
||||
self.assertEqual(repair.filter_bundle(path), 0)
|
||||
|
||||
def test_system_source_is_deleted_so_regeneration_cannot_restore_it(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "root.crt"
|
||||
path.write_text(INCIDENT["certificates"][next(iter(repair.BLOCKED))])
|
||||
self.assertEqual(repair.filter_bundle(path, allow_empty=True), 1)
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
def test_bundle_cannot_be_emptied(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "bundle.pem"
|
||||
original = INCIDENT["certificates"][next(iter(repair.BLOCKED))]
|
||||
path.write_text(original)
|
||||
with self.assertRaisesRegex(ValueError, "refusing to empty"):
|
||||
repair.filter_bundle(path)
|
||||
self.assertEqual(path.read_text(), original)
|
||||
|
||||
def test_derived_base_matches_every_compose_pin(self):
|
||||
# The overlay corrects whatever image Compose selects; a pin bump that
|
||||
# skips the derived Dockerfile would silently run the older base.
|
||||
base = re.search(r"^FROM (\S+) AS base$", (ROOT / "docker/searxng/Dockerfile").read_text(), re.M)
|
||||
self.assertIsNotNone(base)
|
||||
for name in ("docker-compose.yml", "docker-compose.gpu-amd.yml", "docker-compose.gpu-nvidia.yml"):
|
||||
with self.subTest(compose=name):
|
||||
pins = re.findall(r"image:\s*(\S*searxng/searxng:\S+)", (ROOT / name).read_text())
|
||||
self.assertEqual(pins, [base.group(1)])
|
||||
version = base.group(1).split(":", 1)[1].split("-", 1)[0]
|
||||
overlay = (ROOT / "docker/searxng/compose.yml").read_text()
|
||||
self.assertIn(f"image: localhost/odysseus-searxng:{version}-ca-trust", overlay)
|
||||
|
||||
def test_empty_and_malformed_input_fail_closed(self):
|
||||
for text in ("", "not a certificate", "-----BEGIN CERTIFICATE-----\nbroken\n-----END CERTIFICATE-----"):
|
||||
with self.subTest(text=text), self.assertRaises((ValueError, OSError)):
|
||||
list(repair.certificates(text))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""A network failure must not be mistaken for rejection of an incident chain."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validator():
|
||||
repair = ModuleType("repair_trust")
|
||||
repair.check_bundle = lambda path: None
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"searxng_validation", ROOT / "docker/searxng/validate_image.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"repair_trust": repair}):
|
||||
spec.loader.exec_module(module)
|
||||
module.__file__ = str(ROOT / "tests/fixtures/ca-trust/validate_image.py")
|
||||
return module
|
||||
|
||||
|
||||
class RequestFailure(Exception):
|
||||
def __init__(self, code):
|
||||
super().__init__(f"curl error {code}")
|
||||
self.code = code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [60, 6, 28, 35, None])
|
||||
def test_live_probe_requires_certificate_errors_for_every_host(validator, code):
|
||||
hosts = []
|
||||
|
||||
async def get(url, **kwargs):
|
||||
if url == "https://pypi.org/simple/certifi/":
|
||||
return SimpleNamespace(status_code=200)
|
||||
hosts.append(url.removeprefix("https://").removesuffix("/"))
|
||||
assert kwargs["allow_redirects"] is False
|
||||
if code is not None:
|
||||
raise RequestFailure(code)
|
||||
return SimpleNamespace(status_code=200)
|
||||
|
||||
client = SimpleNamespace(get=get, aclose=AsyncMock())
|
||||
factory = ModuleType("searx.network.client")
|
||||
|
||||
def new_client(**kwargs):
|
||||
assert kwargs["verify"] is True
|
||||
return client
|
||||
|
||||
factory.new_client = new_client
|
||||
errors = ModuleType("curl_cffi.requests.exceptions")
|
||||
errors.RequestException = RequestFailure
|
||||
modules = {"searx.network.client": factory, "curl_cffi.requests.exceptions": errors}
|
||||
with patch.dict(sys.modules, modules), patch.object(sys, "path", list(sys.path)):
|
||||
if code == 60:
|
||||
asyncio.run(validator.check_https())
|
||||
assert set(hosts) == set(INCIDENT["chains"])
|
||||
else:
|
||||
expected = "unexpectedly accepted" if code is None else "inconclusive"
|
||||
with pytest.raises(RuntimeError, match=expected):
|
||||
asyncio.run(validator.check_https())
|
||||
client.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
def test_offline_probe_rejects_openssl_execution_failure(validator):
|
||||
result = SimpleNamespace(returncode=1, stderr="openssl: unknown option", stdout="")
|
||||
with patch.dict(validator.os.environ, {"SSL_CERT_FILE": "/unused.pem", "SSL_CERT_DIR": "/unused"}), \
|
||||
patch.object(validator.subprocess, "run", return_value=result):
|
||||
with pytest.raises(RuntimeError, match="not a certificate trust rejection"):
|
||||
validator.check_chains()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("results", [[{"title": "ordinary result"}], []])
|
||||
def test_running_probe_checks_existing_service_without_repair_or_second_boot(validator, tmp_path, results):
|
||||
certificate = tmp_path / "01234567.0"
|
||||
certificate.write_text("certificate double")
|
||||
(tmp_path / "unrelated").write_text("ignored")
|
||||
health = io.BytesIO(b"OK")
|
||||
health.status = 200
|
||||
search = io.BytesIO(json.dumps({"results": results}).encode())
|
||||
with patch.dict(validator.os.environ, {"SSL_CERT_DIR": str(tmp_path), "GRANIAN_PORT": "8080"}), \
|
||||
patch.object(validator.urllib.request, "build_opener") as opener, \
|
||||
patch.object(validator, "check_chains") as chains, \
|
||||
patch.object(validator, "check_bundle") as bundle, \
|
||||
patch.object(validator, "check_https", new_callable=AsyncMock) as https, \
|
||||
patch.object(validator.subprocess, "Popen") as boot:
|
||||
opener.return_value.open.side_effect = [health, search]
|
||||
if results:
|
||||
validator.check_running(live=True)
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="no results"):
|
||||
validator.check_running(live=True)
|
||||
assert opener.return_value.open.call_args_list[0].args == ("http://127.0.0.1:8080/healthz",)
|
||||
chains.assert_called_once_with()
|
||||
bundle.assert_called_once_with(certificate)
|
||||
https.assert_awaited_once()
|
||||
boot.assert_not_called()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Publication must preserve the images that passed native, credential-free checks."""
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("searxng_release", ROOT / ".github/scripts/searxng_release.py")
|
||||
release = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(release)
|
||||
SHA = "a" * 40
|
||||
DIGESTS = {"amd64": "sha256:" + "1" * 64, "arm64": "sha256:" + "2" * 64}
|
||||
|
||||
|
||||
def descriptor(snapshot=None, manifest=None, digests=None):
|
||||
snapshot = snapshot or release.source_snapshot(ROOT, SHA, release.REPOSITORY)
|
||||
manifest = manifest or {
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"digest": "sha256:" + "3" * 64,
|
||||
"manifests": [
|
||||
{"digest": value, "platform": {"os": "linux", "architecture": arch}}
|
||||
for arch, value in DIGESTS.items()
|
||||
],
|
||||
}
|
||||
return release.release_descriptor(snapshot, copy.deepcopy(snapshot), manifest, SHA, "123", "sha-test", digests or DIGESTS)
|
||||
|
||||
|
||||
def test_release_binds_both_native_artifacts_and_immutable_index():
|
||||
value = descriptor()
|
||||
assert value["image"] == release.IMAGE + "@sha256:" + "3" * 64
|
||||
assert value["architecture_digests"] == DIGESTS
|
||||
assert value["platforms"] == ["linux/amd64", "linux/arm64"]
|
||||
assert set(value["source_inputs"]) == set(release.SOURCE_FILES)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["fork", "revision", "missing-input", "checksum"])
|
||||
def test_release_refuses_unmatched_source(change):
|
||||
snapshot = release.source_snapshot(ROOT, SHA, release.REPOSITORY)
|
||||
if change == "fork":
|
||||
snapshot["repository"] = "fork/odysseus"
|
||||
elif change == "revision":
|
||||
snapshot["source_revision"] = "b" * 40
|
||||
elif change == "missing-input":
|
||||
snapshot["source_inputs"].pop(release.SOURCE_FILES[0])
|
||||
else:
|
||||
snapshot["source_inputs"][release.SOURCE_FILES[0]] = "invalid"
|
||||
with pytest.raises(ValueError):
|
||||
descriptor(snapshot=snapshot)
|
||||
|
||||
|
||||
def test_release_refuses_different_native_build_inputs():
|
||||
snapshot = release.source_snapshot(ROOT, SHA, release.REPOSITORY)
|
||||
other = copy.deepcopy(snapshot)
|
||||
other["source_inputs"][release.SOURCE_FILES[0]] = "f" * 64
|
||||
with pytest.raises(ValueError, match="different source inputs"):
|
||||
release.release_descriptor(snapshot, other, {}, SHA, "123", "test", DIGESTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["missing-arm", "extra-platform", "wrong-os", "duplicate-digest", "wrong-digest", "moving-index"])
|
||||
def test_release_refuses_incomplete_or_substituted_index(change):
|
||||
manifest = {
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"digest": "sha256:" + "3" * 64,
|
||||
"manifests": [{"digest": value, "platform": {"os": "linux", "architecture": arch}} for arch, value in DIGESTS.items()],
|
||||
}
|
||||
if change == "missing-arm":
|
||||
manifest["manifests"].pop()
|
||||
elif change == "extra-platform":
|
||||
manifest["manifests"].append({"digest": "sha256:" + "4" * 64, "platform": {"os": "unknown", "architecture": "unknown"}})
|
||||
elif change == "wrong-os":
|
||||
manifest["manifests"][1]["platform"]["os"] = "windows"
|
||||
elif change == "duplicate-digest":
|
||||
manifest["manifests"][1]["digest"] = DIGESTS["amd64"]
|
||||
elif change == "wrong-digest":
|
||||
manifest["manifests"][1]["digest"] = "sha256:" + "4" * 64
|
||||
else:
|
||||
manifest["digest"] = "latest"
|
||||
with pytest.raises(ValueError):
|
||||
descriptor(manifest=manifest)
|
||||
|
||||
|
||||
def test_ci_publication_boundary_and_native_checks():
|
||||
workflow = yaml.safe_load((ROOT / ".github/workflows/searxng-image.yml").read_text())
|
||||
events = workflow.get("on", workflow.get(True))
|
||||
assert "pull_request_target" not in events
|
||||
assert workflow["permissions"] == {"contents": "read"}
|
||||
jobs = workflow["jobs"]
|
||||
assert [name for name, job in jobs.items() if job.get("permissions", {}).get("packages") == "write"] == ["publish"]
|
||||
publish = jobs["publish"]
|
||||
assert publish["needs"] == "validate"
|
||||
assert "github.repository == 'odysseus-dev/odysseus'" in publish["if"]
|
||||
assert "github.event_name == 'push'" in publish["if"]
|
||||
assert "refs/heads/release/searxng-ca-trust" in publish["if"]
|
||||
assert "github.ref)" in publish["if"]
|
||||
steps = publish["steps"]
|
||||
assert next(i for i, step in enumerate(steps) if "docker load" in step.get("run", "")) < next(i for i, step in enumerate(steps) if "docker/login-action" in step.get("uses", ""))
|
||||
for name in ("validate", "verify-published"):
|
||||
matrix = jobs[name]["strategy"]["matrix"]["include"]
|
||||
assert {(row["arch"], row["runner"]) for row in matrix} == {("amd64", "ubuntu-latest"), ("arm64", "ubuntu-24.04-arm")}
|
||||
assert any("validate-searxng-image.sh" in step.get("run", "") for step in jobs[name]["steps"])
|
||||
assert not any("login-action" in step.get("uses", "") for step in jobs["verify-published"]["steps"])
|
||||
assert set(jobs["release"]["needs"]) == {"publish", "verify-published"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure,expected", [("none", 0), ("exec", 7), ("up", 1), ("existing-volume", 1)])
|
||||
def test_compose_probe_owns_only_its_temporary_resources(tmp_path, failure, expected):
|
||||
"""Exercise the real helper with a Docker double, including failure cleanup."""
|
||||
binary = tmp_path / "docker"
|
||||
calls = tmp_path / "calls.jsonl"
|
||||
binary.write_text('''#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["DOCKER_CALLS"], "a") as log:
|
||||
log.write(json.dumps({"args": args, "volume": os.environ["SEARXNG_VALIDATION_VOLUME"], "secret": os.environ["SEARXNG_SECRET"]}) + "\\n")
|
||||
failure = os.environ["DOCKER_FAILURE"]
|
||||
if args[:2] == ["volume", "inspect"]:
|
||||
sys.exit(0 if failure == "existing-volume" else 1)
|
||||
if "exec" in args and failure == "exec":
|
||||
sys.exit(7)
|
||||
if "up" in args and failure == "up":
|
||||
sys.exit(9)
|
||||
''')
|
||||
binary.chmod(0o755)
|
||||
result = subprocess.run(["bash", str(ROOT / ".github/scripts/validate-searxng-image.sh"), "localhost/searxng:validated"], env={**os.environ, "PATH": str(tmp_path) + ":" + os.environ["PATH"], "DOCKER_CALLS": str(calls), "DOCKER_FAILURE": failure, "SEARXNG_SECRET": "operator-secret-must-not-propagate"}, capture_output=True, text=True)
|
||||
assert result.returncode == expected, result.stderr
|
||||
entries = [json.loads(line) for line in calls.read_text().splitlines()]
|
||||
assert all(entry["secret"] == "searxng-ci-validation-only" for entry in entries)
|
||||
commands = [entry["args"] for entry in entries]
|
||||
if failure == "existing-volume":
|
||||
assert len(commands) == 1
|
||||
return
|
||||
up = next(command for command in commands if "up" in command)
|
||||
assert up[up.index("--env-file") + 1] == "/dev/null"
|
||||
assert "--no-deps" in up and "--no-build" in up and "--wait" in up
|
||||
assert up[up.index("--pull") + 1] == "never"
|
||||
down = next(command for command in commands if "down" in command)
|
||||
assert "--volumes" not in down
|
||||
assert commands[-1] == ["volume", "rm", entries[-1]["volume"]]
|
||||
if failure != "up":
|
||||
execute = next(command for command in commands if "exec" in command)
|
||||
assert execute[-2:] == ["--running", "--live"]
|
||||
Reference in New Issue
Block a user