diff --git a/README.md b/README.md index cff0717..55401f4 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,8 @@ people's projects) and drives tools that install software, so: - It's signed with the hardened runtime, so injected `DYLD_*` libraries are ignored, and the projects folder must be inside your home folder (not `~/Library`). - **The dashboard** escapes everything it shows and only links advisory references that use `http(s)`. -- Registry and OSV requests must be `https://`. Notifications pass package names to `osascript` as +- Registry and OSV requests must be `https://`, and don't trust eMudhra's (emSign) CA roots, whose + intermediate keys turned out to be guessable in October 2026, or Sectigo's AAA root, which cross-signed them. Notifications pass package names to `osascript` as arguments, never as script source. - The Socket.dev token lives only in the macOS Keychain. diff --git a/server.py b/server.py index 4130430..6a39d69 100644 --- a/server.py +++ b/server.py @@ -7,6 +7,7 @@ """ import calendar import concurrent.futures +import functools import getpass import hashlib import json @@ -17,6 +18,7 @@ import shlex import shutil import socket +import ssl import subprocess import tarfile import tempfile @@ -167,6 +169,53 @@ def loads(text, default): return default +# eMudhra's (emSign) roots are left out of what the registry, OSV and Socket requests trust: in October 2026 the keys of +# 8 of their intermediate CAs turned out to be guessable. Browsers block those intermediates, but macOS's +# /etc/ssl/cert.pem still trusts the roots above them, and Sectigo's AAA root, which cross-signed twins of them. +# Dropped by SHA-256, or if the root names eMudhra at all. +DISTRUSTED_ROOTS = { + "40f6af0346a99aa1cd1d555a4e9cce62c7f9634603ee406615833dc8c8d00367", # emSign Root CA - G1 + "86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b", # emSign ECC Root CA - G3 + "125609aa301da0a249b97a8239cb6a34216f44dcac9f3954b14292f2e8c8608f", # emSign Root CA - C1 + "bc4d809b15189d78db3e1d8cf4f9726a795da1643ca5f1358e1ddb0edc0d7eb3", # emSign ECC Root CA - C3 + # Sectigo's legacy AAA root: it cross-signed twins of the 8 compromised intermediates. Browsers and certifi + # stopped trusting it for websites in 2025, but macOS's /etc/ssl/cert.pem still does. + "d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4", # AAA Certificate Services (Comodo) +} +PEM_RE = re.compile(r"-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----", re.DOTALL) + + +def distrusted(pem): + der = ssl.PEM_cert_to_DER_cert(pem) + return hashlib.sha256(der).hexdigest() in DISTRUSTED_ROOTS or b"eMudhra" in der + + +def ca_bundle(): + """The roots Python trusts by default, as PEM: $SSL_CERT_FILE, else its default file, else its default folder.""" + paths = ssl.get_default_verify_paths() + cafile = os.environ.get(paths.openssl_cafile_env) or paths.cafile + if cafile: + files = [cafile] + elif paths.capath and os.path.isdir(paths.capath): + files = [os.path.join(paths.capath, n) for n in sorted(os.listdir(paths.capath))] + else: + files = [] + out = [] + for f in files: + try: + with open(f, encoding="utf-8", errors="replace") as fh: # labels in comments can be non-ASCII + out.append(fh.read()) + except (OSError, ValueError): + pass + return "\n".join(out) + + +@functools.lru_cache(maxsize=None) +def tls_context(): + pems = list(dict.fromkeys(p for p in PEM_RE.findall(ca_bundle()) if not distrusted(p))) # a folder repeats them + return ssl.create_default_context(cadata="\n".join(pems)) if pems else ssl.create_default_context() + + def http_json(url, body=None, timeout=30, headers=None): if not url.startswith("https://"): # registries and OSV only: never file: or plain http raise ValueError("not an https URL: " + url[:80]) @@ -174,7 +223,7 @@ def http_json(url, body=None, timeout=30, headers=None): url, data=json.dumps(body).encode() if body is not None else None, headers={"User-Agent": "pkgdash/1.0 (local dashboard)", "Content-Type": "application/json", **(headers or {})}) - with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 + with urllib.request.urlopen(req, timeout=timeout, context=tls_context()) as r: # noqa: S310 return json.loads(r.read()) @@ -595,7 +644,7 @@ def socket_api(token, path, body=None, timeout=30): "https://api.socket.dev/v0/" + path, data=json.dumps(body).encode() if body is not None else None, headers={"Authorization": "Bearer " + token, "User-Agent": "pkgdash/1.0 (local dashboard)", "Content-Type": "application/json"}) - with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 - fixed https://api.socket.dev/ prefix + with urllib.request.urlopen(req, timeout=timeout, context=tls_context()) as r: # noqa: S310 - fixed https://api.socket.dev/ prefix return r.read().decode() diff --git a/tests/test_server.py b/tests/test_server.py index bce7fb6..1762498 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -8,6 +8,7 @@ import os import re import shutil +import ssl import subprocess import sys import tempfile @@ -318,6 +319,41 @@ def test_advisory_ids_are_validated(self): self.assertEqual(self.req("GET", "/api/advisory?id=" + urllib.request.quote(bad), token=False)[0], 400, bad) +class Trust(unittest.TestCase): + """Requests to registries, OSV and Socket trust the default roots minus eMudhra's.""" + + def test_context_has_roots_but_no_emudhra(self): + server.tls_context.cache_clear() + ctx = server.tls_context() + names = [str(c.get("subject")) for c in ctx.get_ca_certs()] + self.assertGreater(len(names), 100) + self.assertEqual([n for n in names if "eMudhra" in n or "emSign" in n or "AAA Certificate" in n], []) + self.assertTrue(ctx.check_hostname) + + def test_only_emudhra_roots_are_dropped(self): + pems = server.PEM_RE.findall(server.ca_bundle()) + dropped = [p for p in pems if server.distrusted(p)] + if not dropped: + self.skipTest("this machine's default roots have no eMudhra root") + for pem in dropped: + der = ssl.PEM_cert_to_DER_cert(pem) + self.assertTrue(b"emSign" in der or b"AAA Certificate" in der) + self.assertGreater(len(pems) - len(dropped), 100) + + def test_ssl_cert_file_is_honoured_and_filtered(self): + pems = server.PEM_RE.findall(server.ca_bundle()) + bundle = os.path.join(UNIT_HOME, "corp.pem") + with open(bundle, "w") as f: + f.write("\n".join([next(p for p in pems if not server.distrusted(p))] + [p for p in pems if server.distrusted(p)])) + os.environ["SSL_CERT_FILE"] = bundle + server.tls_context.cache_clear() + try: + self.assertEqual(len(server.tls_context().get_ca_certs()), 1) + finally: + del os.environ["SSL_CERT_FILE"] + server.tls_context.cache_clear() + + class Demo(unittest.TestCase): def test_demo_serves_only_made_up_data(self): home = tempfile.mkdtemp(prefix="pkgdash-demo-test-")