fix(security): refresh client trust and publish validated SearXNG images

Repair SearXNG system and certifi stores, validate native amd64/arm64 runtime images through Compose, and publish the exact verified artifacts through a trusted CI job. Adopt released Python, certifi, grpcio, uv and Docker CLI trust fixes, with strict controls for all fourteen public incident chains.

Part of #6784 and #6790.

Co-authored-by: Léo <leograndcontact@gmail.com>
This commit is contained in:
RaresKeY
2026-10-23 22:32:33 +00:00
co-authored by Léo
parent f8e043e8ca
commit c6d76b9a9b
23 changed files with 1200 additions and 17 deletions
+22
View File
@@ -0,0 +1,22 @@
# Keep the application version unchanged while correcting its independent trust.
FROM docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6 AS base
FROM base AS baseline-validation
COPY docker/searxng/repair_trust.py docker/searxng/blocked-certificates.json docker/searxng/validate_image.py /opt/odysseus-trust/
COPY tests/fixtures/ca-trust/incident.json /opt/odysseus-trust/incident.json
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --baseline
FROM base AS repaired
COPY docker/searxng/repair_trust.py docker/searxng/blocked-certificates.json /opt/odysseus-trust/
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/repair_trust.py
# Optional offline validation runs inside the actual SearXNG filesystem.
FROM repaired AS validation
COPY tests/fixtures/ca-trust/incident.json /opt/odysseus-trust/incident.json
COPY docker/searxng/validate_image.py /opt/odysseus-trust/validate_image.py
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py
FROM validation AS live-validation
RUN /usr/local/searxng/.venv/bin/python /opt/odysseus-trust/validate_image.py --live
FROM repaired AS runtime
+8
View File
@@ -0,0 +1,8 @@
**
!docker/
!docker/searxng/
!docker/searxng/**
!tests/
!tests/fixtures/
!tests/fixtures/ca-trust/
!tests/fixtures/ca-trust/incident.json
+23
View File
@@ -0,0 +1,23 @@
{
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3",
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (cross-signed)",
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
}
+9
View File
@@ -0,0 +1,9 @@
# Opt in to the locally built trust-corrected image without publishing a tag.
# Run from the repository root with the base Compose file first.
services:
searxng:
image: localhost/odysseus-searxng:2026.9.25-ca-trust
build:
context: .
dockerfile: docker/searxng/Dockerfile
target: runtime
+75
View File
@@ -0,0 +1,75 @@
"""Remove known incident certificates without downloading packages or roots.
Inventory provenance: https://emudhra.we-have-a-problem.com/roots.json and
https://emudhra.we-have-a-problem.com/impacted-intermediates.json; the additional
cross-signed issuer is from the incident's public chains.zip.
"""
import hashlib
import json
from pathlib import Path
import re
import ssl
import subprocess
BLOCKED = json.loads(Path(__file__).with_name("blocked-certificates.json").read_text())
PEM = re.compile(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", re.S)
def certificates(text):
blocks = PEM.findall(text)
if not blocks or "-----BEGIN" in PEM.sub("", text):
raise ValueError("empty or malformed certificate input")
for block in blocks:
# Validate the X.509 object as well as its base64 encoding.
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.load_verify_locations(cadata=block)
yield block, hashlib.sha256(ssl.PEM_cert_to_DER_cert(block)).hexdigest()
def filter_bundle(path, *, allow_empty=False):
original = list(certificates(path.read_text()))
retained = [block for block, digest in original if digest not in BLOCKED]
removed = len(original) - len(retained)
if not retained and not allow_empty:
raise ValueError(f"refusing to empty trust bundle: {path}")
if removed:
if retained:
path.write_text("\n".join(retained) + "\n")
else:
path.unlink()
return removed
def check_bundle(path):
blocks = list(certificates(path.read_text()))
if any(digest in BLOCKED for _, digest in blocks):
raise ValueError(f"incident certificate remains: {path}")
return len(blocks)
def main():
import certifi
sources = list(Path("/usr/share/ca-certificates").rglob("*.crt"))
sources += list(Path("/usr/local/share/ca-certificates").rglob("*.crt"))
if not sources:
raise ValueError("no system CA sources found")
removed = sum(filter_bundle(path, allow_empty=True) for path in sources)
removed += filter_bundle(Path(certifi.where()))
# The upstream entrypoint also regenerates trust at root startup. Removing
# source certificates ensures that it cannot restore the incident roots.
subprocess.run(["update-ca-certificates", "--fresh"], check=True)
paths = [Path("/etc/ssl/certs/ca-certificates.crt"), Path(certifi.where())]
paths += [p for p in Path("/etc/ssl/certs").iterdir()
if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", p.name)]
if len(paths) <= 2:
raise ValueError("no hashed system CA entries generated")
for path in paths:
check_bundle(path)
print(f"Removed {removed} incident certificates; checked {len(paths)} trust files")
if __name__ == "__main__":
main()
+173
View File
@@ -0,0 +1,173 @@
"""Offline incident-chain checks and loopback boot smoke for the derived image."""
import hashlib
import asyncio
import json
import os
from pathlib import Path
import re
import signal
import ssl
import subprocess
import sys
import tempfile
import time
import urllib.request
import certifi
from repair_trust import check_bundle
def check_chains(baseline=False):
incident = json.loads(Path(__file__).with_name("incident.json").read_text())
system = Path(os.environ["SSL_CERT_FILE"])
capath = os.environ["SSL_CERT_DIR"]
stores = [["-CAfile", str(system), "-no-CApath"],
["-CAfile", certifi.where(), "-no-CApath"],
["-no-CAfile", "-CApath", capath]]
if not baseline:
check_bundle(system)
check_bundle(Path(certifi.where()))
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
for fingerprint, pem in incident["certificates"].items():
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
raise ValueError(f"incident fixture fingerprint mismatch: {fingerprint}")
(directory / fingerprint).write_text(pem)
positive = directory / "affected.pem"
positive.write_text("\n".join(incident["certificates"][fp] for fp in incident["roots"]))
for hostname, chain in incident["chains"].items():
untrusted = directory / "untrusted.pem"
untrusted.write_text("\n".join(incident["certificates"][fp] for fp in chain[1:]))
base = ["openssl", "verify", "-no-CAstore", "-attime", str(incident["verification_time"]),
"-purpose", "sslserver", "-verify_hostname", hostname,
"-x509_strict", "-partial_chain", "-trusted_first", "-untrusted", str(untrusted)]
expectations = [(s, baseline and (index != 1 or "-aaa-" not in hostname))
for index, s in enumerate(stores)]
for selection, expected in expectations + [
(["-CAfile", str(positive), "-no-CApath"], True)]:
result = subprocess.run(base + selection + [str(directory / chain[0])],
capture_output=True, text=True)
if (result.returncode == 0) != expected:
raise RuntimeError(f"unexpected chain result: {hostname}: {selection}: {result.stderr}")
# Accept issuer lookup, self-signed and explicitly untrusted
# certificate errors, rather than tool or fixture failures.
if not expected and not re.search(r"error (?:2|18|19|20|21|27) at \d+ depth lookup", result.stderr):
raise RuntimeError(f"not a certificate trust rejection: {hostname}: {result.stderr}")
if baseline:
total = len(incident["chains"])
certifi_accepted = sum("-aaa-" not in host for host in incident["chains"])
print(f"Affected base reproduced: system PEM and directory accept {total}/{total}; certifi accepts {certifi_accepted}/{total}")
else:
print(f"{len(incident['chains'])}/{len(incident['chains'])} incident chains rejected by system PEM, hashed directory and certifi; affected controls accepted")
def check_boot(live=False):
with tempfile.TemporaryDirectory() as directory:
settings = Path(directory) / "settings.yml"
settings.write_text("use_default_settings: true\nserver:\n secret_key: offline-build-validation-only\n limiter: false\nsearch:\n formats: [html, json]\n")
cache = Path(directory) / "cache"
cache.mkdir()
env = dict(os.environ, SEARXNG_SETTINGS_PATH=str(settings),
__SEARXNG_CONFIG_PATH=directory, __SEARXNG_DATA_PATH=str(cache),
GRANIAN_HOST="127.0.0.1", GRANIAN_PORT="18888")
# Do not expose a port or issue an outbound search during offline builds.
with tempfile.TemporaryFile() as log:
process = subprocess.Popen(["/usr/local/searxng/entrypoint.sh"],
cwd="/usr/local/searxng", env=env, stdout=log,
stderr=subprocess.STDOUT, start_new_session=True)
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
healthy = False
for _ in range(60):
if process.poll() is not None:
break
try:
with opener.open("http://127.0.0.1:18888/healthz", timeout=1) as response:
healthy = response.status == 200
except OSError:
pass
if healthy:
break
time.sleep(0.5)
if not healthy:
log.seek(0)
raise RuntimeError("SearXNG failed boot smoke: " + log.read().decode(errors="replace")[-6000:])
print("SearXNG loopback healthcheck passed")
# Its root entrypoint regenerates system trust. Repeat the
# behavioral checks after that real startup path completes.
check_running(live=live, port=18888)
finally:
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
def check_running(live=False, port=None):
"""Verify an existing service without repairing it or starting another one."""
port = int(os.environ.get("GRANIAN_PORT", "8080")) if port is None else port
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(f"http://127.0.0.1:{port}/healthz", timeout=5) as response:
if response.status != 200:
raise RuntimeError("SearXNG running-service healthcheck failed")
check_chains()
for path in Path(os.environ["SSL_CERT_DIR"]).iterdir():
if re.fullmatch(r"[0-9a-fA-F]{8}\.[0-9]+", path.name):
check_bundle(path)
if live:
asyncio.run(check_https())
with opener.open(f"http://127.0.0.1:{port}/search?q=python+documentation&format=json", timeout=90) as search:
payload = json.load(search)
results = payload.get("results", [])
if not results:
raise RuntimeError("live search returned no results")
print(f"SearXNG live JSON search passed: {len(results)} results")
async def check_https():
# Exercise the actual pinned SearXNG client factory with its default CA
# selection, rather than a different HTTP library's trust configuration.
sys.path.insert(0, "/usr/local/searxng")
from curl_cffi.requests.exceptions import RequestException
from searx.network.client import new_client
client = new_client(enable_http=False, verify=True, enable_http2=True,
enable_http3=False, max_connections=2, proxies={},
local_address=None, max_redirects=3)
try:
response = await client.get("https://pypi.org/simple/certifi/", timeout=30)
if response.status_code != 200:
raise RuntimeError(f"ordinary HTTPS failed: {response.status_code}")
print("SearXNG actual curl client ordinary HTTPS passed with verification enabled")
incident = json.loads(Path(__file__).with_name("incident.json").read_text())
for hostname in incident["chains"]:
try:
await client.get(f"https://{hostname}/", timeout=30, allow_redirects=False)
except RequestException as exc:
# libcurl code 60 is peer certificate verification failure.
# DNS failures, timeouts and handshake EOF do not prove distrust.
if exc.code != 60:
raise RuntimeError(f"inconclusive incident TLS failure: {hostname}: {exc}") from exc
else:
raise RuntimeError(f"incident HTTPS unexpectedly accepted: {hostname}")
print(f"SearXNG actual curl client rejects {len(incident['chains'])}/{len(incident['chains'])} incident hosts with certificate errors after startup")
finally:
await client.aclose()
if __name__ == "__main__":
baseline = "--baseline" in sys.argv
live = "--live" in sys.argv
if "--running" in sys.argv:
if baseline:
raise ValueError("--running cannot validate an affected baseline")
check_running(live=live)
else:
check_chains(baseline=baseline)
if not baseline:
check_boot(live=live)