build: support blacklisting dependencies for deterministic builds
What changed, and why it matters
This commit changes Electrum's internal build tooling. It introduces 'ghost packages'—empty placeholder packages used only during the build process to satisfy dependency resolvers—so that certain unwanted dependencies can be excluded from final deterministic builds. The change also makes a helper script read package metadata from the local build environment instead of downloading it from PyPI. There is no direct evidence in the commit that this fixes an active security vulnerability; it appears to be a build-hygiene improvement.
Treat as a routine build-maintenance commit. Review the ghost.txt blacklist to confirm these dependencies are intentionally excluded and that their absence does not weaken runtime security (e.g., keyring, platformdirs may be used on some platforms). Verify that install_ghost.py cannot be abused to inject malicious packages during the build, and that removing direct_url.json does not interfere with reproducibility or auditability. No urgent security response is indicated by the diff alone.
Security signals we found
Build tooling change only; no runtime Electrum wallet code modified
Introduces empty 'ghost' packages to manipulate dependency resolver behavior
Removes direct_url.json metadata from installed ghost packages
Switches dependency metadata lookup from PyPI network API to local installed metadata
No CVE, advisory, or vendor security disclosure referenced in commit
Evidence from the diff
The patch adds contrib/install_ghost.py, which builds and installs empty PEP 517 packages from a templated pyproject.toml, then removes direct_url.json metadata so pip freeze does not expose the temporary source. contrib/freeze_packages.sh installs these ghosts before dependency resolution and uninstalls them before hashing. find_restricted_dependencies.py is refactored to use importlib.metadata.requires() against locally installed distributions rather than querying PyPI JSON APIs. A new contrib/requirements/ghost.txt lists click, construct, construct-classes, platformdirs, and keyring as dependencies to be blacklisted/ghosted. The stated goal is deterministic builds with fewer unwanted transitive dependencies.
Changed components
contrib/deterministic-build/find_restricted_dependencies.pycontrib/freeze_packages.shcontrib/install_ghost.pycontrib/requirements/ghost.txtInspect captured patch +66 / −25
diff --git a/contrib/deterministic-build/find_restricted_dependencies.py b/contrib/deterministic-build/find_restricted_dependencies.py
index 5cc2c45..55bda1a 100755
--- a/contrib/deterministic-build/find_restricted_dependencies.py
+++ b/contrib/deterministic-build/find_restricted_dependencies.py
@@ -1,10 +1,6 @@
#!/usr/bin/env python3
import sys
-
-try:
- import requests
-except ImportError as e:
- sys.exit(f"Error: {str(e)}. Try 'python3 -m pip install <module-name>'")
+from importlib.metadata import requires, PackageNotFoundError
def is_dependency_edge_blacklisted(*, parent_pkg: str, dep: str) -> bool:
"""Sometimes a package declares a hard dependency
@@ -38,26 +34,24 @@ def main():
if not p:
continue
assert "==" in p, "This script expects a list of packages with pinned version, e.g. package==1.2.3, not {}".format(p)
- p, v = p.rsplit("==", 1)
- try:
- data = requests.get("https://pypi.org/pypi/{}/{}/json".format(p, v)).json()["info"]
- except ValueError:
- raise Exception("Package could not be found: {}=={}".format(p, v))
+ pkg_name, _ = p.rsplit("==", 1)
try:
- for r in data["requires_dist"]: # type: str
- if ";" not in r:
- continue
- # example value for "r" at this point: "pefile (>=2017.8.1) ; sys_platform == \"win32\""
- dep, restricted = r.split(";", 1)
- dep = dep.strip()
- restricted = restricted.strip()
- dep_basename = dep.split(" ")[0]
- if check_restriction(dep=dep, restricted=restricted, parent_pkg=p):
- print(dep_basename, sep=" ")
- print("Installing {} from {} although it is only needed for {}".format(dep, p, restricted), file=sys.stderr)
- except TypeError:
- # Has no dependencies at all
+ reqs = requires(pkg_name)
+ except PackageNotFoundError:
+ raise Exception("Package not found in this environment: {}. Install it first.".format(p))
+ if reqs is None:
continue
+ for r in reqs:
+ if ";" not in r:
+ continue
+ # example value for "r": "pefile (>=2017.8.1) ; sys_platform == \"win32\""
+ dep, restricted = r.split(";", 1)
+ dep = dep.strip()
+ restricted = restricted.strip()
+ dep_basename = dep.split(" ")[0]
+ if check_restriction(dep=dep, restricted=restricted, parent_pkg=pkg_name):
+ print(dep_basename, sep=" ")
+ print("Installing {} from {} although it is only needed for {}".format(dep, pkg_name, restricted), file=sys.stderr)
if __name__ == "__main__":
main()
diff --git a/contrib/freeze_packages.sh b/contrib/freeze_packages.sh
index 17542a0..93e0a25 100755
--- a/contrib/freeze_packages.sh
+++ b/contrib/freeze_packages.sh
@@ -34,16 +34,20 @@ for suffix in '' '-hw' '-binaries' '-binaries-mac' '-build-wine' '-build-mac' '-
# that we should explicitly install them now, so that we pin latest versions if possible.
python -m pip install --upgrade pip setuptools wheel
+ # install ghost packages to satisfy dependency resolvers
+ for package in $(cat "$contrib/requirements/ghost.txt"); do
+ python "$contrib/install_ghost.py" "$package"
+ done
+
python -m pip install -r "$contrib/requirements/${reqfile}" --upgrade
echo "OK."
requirements=$(pip freeze --all)
- restricted=$(echo $requirements | ${SYSTEM_PYTHON} "$contrib/deterministic-build/find_restricted_dependencies.py")
+ restricted=$(echo $requirements | python "$contrib/deterministic-build/find_restricted_dependencies.py")
if [ ! -z "$restricted" ]; then
python -m pip install $restricted
- requirements=$(pip freeze --all)
fi
echo "Generating package hashes... (${reqfile})"
@@ -65,6 +69,12 @@ for suffix in '' '-hw' '-binaries' '-binaries-mac' '-build-wine' '-build-mac' '-
HASHIN_FLAGS="--python-version source"
fi
+ # remove ghost packages before hashin step, so that they aren't hashed
+ for package in $(cat "$contrib/requirements/ghost.txt"); do
+ python -m pip uninstall -y $package
+ done
+ requirements=$(pip freeze --all)
+
echo -e "\r Hashing requirements for $reqfile..."
${SYSTEM_PYTHON} -m hashin $HASHIN_FLAGS -r "$contrib/deterministic-build/${reqfile}" $requirements
diff --git a/contrib/install_ghost.py b/contrib/install_ghost.py
new file mode 100644
index 0000000..f6c978c
--- /dev/null
+++ b/contrib/install_ghost.py
@@ -0,0 +1,32 @@
+import sys, tempfile, subprocess
+from pathlib import Path
+from importlib.metadata import distribution
+
+
+PYPROJECT_TOML = """
+[project]
+name = "{name}"
+version = "{version}"
+description = "Ghost package to satisfy dependencies"
+"""
+
+
+def install_ghost(name: str, version: str) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ pyproject_toml = PYPROJECT_TOML.format(name=name, version=version)
+ (Path(tmpdir) / "pyproject.toml").write_text(pyproject_toml)
+ subprocess.check_call([sys.executable, "-m", "pip", "install", tmpdir])
+
+ dist = distribution(name)
+ for file in dist.files:
+ path = file.locate()
+ if path.name == "direct_url.json":
+ path.unlink()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ print("Usage: python install_ghost.py <package name>==<package version>")
+ sys.exit(1)
+ name, version = sys.argv[1].split("==")
+ install_ghost(name, version)
diff --git a/contrib/requirements/ghost.txt b/contrib/requirements/ghost.txt
new file mode 100644
index 0000000..f506ac0
--- /dev/null
+++ b/contrib/requirements/ghost.txt
@@ -0,0 +1,5 @@
+click==8.3.1
+construct==2.10.70
+construct-classes==0.2.2
+platformdirs==4.9.4
+keyring==25.7.0
Why this scored 14/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.