mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-24 15:52:20 +02:00
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:
@@ -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)
|
||||
Reference in New Issue
Block a user