Skip to content

Commit 1d63385

Browse files
committed
Authenticate downloaded binaries
Summary: This commit adds logic to authenticate all Bazel binaries that are downloaded, as long as the user has GPG installed. If a user does not have GPG installed, a new warning will be printed when a binary is downloaded, but Bazelisk will function the same way as before. (GPG is installed by default on Debian and Ubuntu.) No new subprocesses are spawned when an already-downloaded version of Bazel is run. The only appreciable overhead is incurred at download time. Resolves #15. Test Plan: - Remove the `~/.bazelisk` directory. Run `./bazelisk.py version`. Note that it downloads the latest binary and the latest signature, then prints “Authenticity verified” before invoking Bazel. - Run `./bazelisk.py version` again. Note that it does not verify the signature. - Remove the `~/.bazelisk` directory. Symlink `/bin/false` to `~/bin/gpg`, and ensure that the symlink precedes the real `gpg` on your path. Run Bazelisk, and note that it prints a warning that GPG is not available but executes Bazel anyway. Run Bazelisk again, and note that it does not print the warning (because it reuses the existing executable without reauthenticating). Remove the symlink. - Remove the `~/.bazelisk` directory. Edit `bazelisk.py`, changing the `determine_urls` function so that the returned `binary_url` is an arbitrary web page (like `http://example.com/`) but the signature URL is unchanged. Run Bazelisk, and note that Bazelisk reports, “Failed to authenticate binary!”, includes the GPG output (“BAD signature”), and aborts with exit code 2 _without_ invoking Bazel. Run `ls ~/.bazelisk/bin` and note that it does not include the invalid binary (though the signature is still there). Revert the changes to `bazelisk.py`. - Remove the `~/.bazelisk` directory. Create an arbitrary document and use `gpg --detach-sign` to sign it with a key that is not the Bazel signing key. Spawn a web server (`python -m SimpleHTTPServer`) to serve the “malicious executable” and its signature. Edit `bazelisk.py`, changing the `determine_urls` function to point both the binary and the signature to this local web server. Run Bazelisk, and note that it fails to authenticate the binary, with the message “public key not found”. Repeat the above steps in Python 2 and Python 3. Verify that your personal GnuPG database has not been modified (in particular, the Bazel key should not have been installed, and the trust settings should not have been modified). I have tested this on Linux with gpg (GnuPG) 1.4.20. I don’t see any reason that it shouldn’t work on macOS or Windows as long as the gpg(1) interfaces are the same. wchargin-branch: authenticate-binaries
1 parent 6619ce0 commit 1d63385

File tree

2 files changed

+149
-12
lines changed

2 files changed

+149
-12
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**A user-friendly launcher for Bazel.**
44

5-
Bazelisk is a wrapper for Bazel. It automatically picks a good version of Bazel given your current working directory, downloads it from the official server (if required) and then transparently passes through all command-line arguments to the real Bazel binary. You can call it just like you would call Bazel.
5+
Bazelisk is a wrapper for Bazel. It automatically picks a good version of Bazel given your current working directory, downloads it from the official server (if required) and then transparently passes through all command-line arguments to the real Bazel binary. You can call it just like you would call Bazel. If you have [`gpg`][GnuPG] installed, Bazelisk will authenticate all Bazel downloads.
66

77
Bazelisk is currently not an official part of Bazel and is not tested or code reviewed as thoroughly as Bazel itself. It's a personal project that @philwo (a core contributor to Bazel) wrote in his free time. If users like it, we might merge it into the bazelbuild organization and make it an official tool.
88

@@ -29,6 +29,10 @@ In the future I will add support for release candidates and for building Bazel f
2929

3030
For ease of use, Bazelisk is written to work with Python 2.7 and 3.x and only uses modules provided by the standard library.
3131

32+
If [GnuPG] is installed and `gpg` is available on the system path, Bazelisk will verify the integrity of the binaries that it downloads.
33+
34+
[GnuPG]: https://www.gnupg.org/
35+
3236
## Ideas for the future
3337

3438
- Add a Homebrew recipe for Bazelisk to make it easy to install on macOS.

bazelisk.py

Lines changed: 144 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
limitations under the License.
1616
"""
1717

18+
import collections
1819
from contextlib import closing
1920
from distutils.version import LooseVersion
2021
import json
@@ -24,6 +25,7 @@
2425
import shutil
2526
import subprocess
2627
import sys
28+
import tempfile
2729
import time
2830

2931
try:
@@ -34,6 +36,10 @@
3436

3537
ONE_HOUR = 1 * 60 * 60
3638

39+
# Bazelisk exits with this code when GPG is installed but the binary
40+
# cannot be authenticated.
41+
AUTHENTICATION_FAILURE_EXIT_CODE = 2
42+
3743

3844
def decide_which_bazel_version_to_use():
3945
# Check in this order:
@@ -116,25 +122,152 @@ def normalized_machine_arch_name():
116122
return machine
117123

118124

119-
def determine_url(version, bazel_filename):
125+
SubprocessResult = collections.namedtuple("SubprocessResult", ("exit_code",))
126+
127+
128+
def subprocess_run(command, input=None, error_message=None):
129+
"""Kind of like Python 3's subprocess.run, but works in Python 2.
130+
131+
The contents of stdout and stderr are captured. If the command
132+
succeeds (exit code 0), they are not printed. If the command fails,
133+
stderr is printed along with the provided error message (if any).
134+
135+
Args:
136+
command: The command to be executed, as a list of strings
137+
input: A bytestring to use as stdin, or None.
138+
error_message: If not None, will be logged on failure.
139+
140+
Returns:
141+
A `SubprocessResult` including the process's exit code.
142+
"""
143+
process = subprocess.Popen(
144+
command,
145+
stdin=subprocess.PIPE,
146+
stdout=subprocess.PIPE,
147+
stderr=subprocess.PIPE)
148+
(stdout, stderr) = process.communicate(input=input)
149+
exit_code = process.wait()
150+
if exit_code != 0 and error_message is not None:
151+
if error_message is not None:
152+
sys.stderr.write("bazelisk: {}\n".format(error_message))
153+
write_binary_to_stderr(stderr)
154+
return SubprocessResult(exit_code=exit_code)
155+
156+
157+
def write_binary_to_stderr(bytestring):
158+
# Python 2 compatibility hack. In Python 3, you can't write byte
159+
# strings to stdio; instead, you have to use the `sys.stderr.buffer`
160+
# attribute, which is not available in Python 2.
161+
buffer = getattr(sys.stderr, "buffer", sys.stderr)
162+
buffer.write(bytestring)
163+
164+
165+
def verify_authenticity(binary_path, signature_path):
166+
"""Authenticate a binary and signature against the Bazel public key.
167+
168+
This will use a fresh temporary keyring populated only with the
169+
Bazel team's signing key; it is independent of any existing PGP data
170+
or settings that the user may have.
171+
172+
Args:
173+
binary_path: File path to the Bazel binary to be executed.
174+
signature_path: File path to the detached signature made by the
175+
Bazel release PGP key to sign the provided binary.
176+
177+
Returns:
178+
True if the binary is valid or gpg is not installed; False if gpg is
179+
installed but we cannot determine that the binary is valid.
180+
"""
181+
if subprocess_run(
182+
["gpg", "--batch", "--version"],
183+
error_message=
184+
"Warning: skipping authenticity check because GPG is not installed.",
185+
).exit_code != 0:
186+
return True
187+
188+
tempdir = tempfile.mkdtemp(prefix="tmp_bazelisk_gpg_")
189+
try:
190+
gpg_invocation = [
191+
"gpg",
192+
"--batch",
193+
"--no-default-keyring",
194+
"--homedir",
195+
tempdir,
196+
]
197+
if subprocess_run(
198+
gpg_invocation + ["--import-ownertrust"],
199+
input=BAZEL_ULTIMATE_OWNERTRUST,
200+
error_message="Failed to initialize GPG keyring").exit_code != 0:
201+
return False
202+
if subprocess_run(
203+
gpg_invocation + ["--import"],
204+
input=BAZEL_PUBLIC_KEY,
205+
error_message="Failed to import Bazel public key").exit_code != 0:
206+
return False
207+
if subprocess_run(
208+
gpg_invocation + ["--verify", signature_path, binary_path],
209+
error_message="Failed to authenticate binary!").exit_code != 0:
210+
return False
211+
sys.stderr.write("Verified authenticity.\n")
212+
return True
213+
214+
finally:
215+
shutil.rmtree(tempdir)
216+
217+
218+
DownloadUrls = collections.namedtuple("DownloadUrls",
219+
("binary_url", "signature_url"))
220+
221+
222+
def determine_urls(version, bazel_filename):
120223
# Split version into base version and optional additional identifier.
121224
# Example: '0.19.1' -> ('0.19.1', None), '0.20.0rc1' -> ('0.20.0', 'rc1')
122225
(version, rc) = re.match(r'(\d*\.\d*(?:\.\d*)?)(rc\d)?', version).groups()
123-
return "https://releases.bazel.build/{}/{}/{}".format(
226+
binary_url = "https://releases.bazel.build/{}/{}/{}".format(
124227
version, rc if rc else "release", bazel_filename)
228+
signature_url = "{}.sig".format(binary_url)
229+
return DownloadUrls(binary_url=binary_url, signature_url=signature_url)
230+
231+
232+
def download_file(url, destination_path):
233+
"""Download a file from the given URL, saving it to the given path."""
234+
sys.stderr.write("Downloading {}...\n".format(url))
235+
with closing(urlopen(url)) as response:
236+
with open(destination_path, 'wb') as out_file:
237+
shutil.copyfileobj(response, out_file)
125238

126239

127240
def download_bazel_into_directory(version, directory):
241+
"""Download and authenticate the specified version of Bazel.
242+
243+
If the binary already exists, it will not be re-downloaded.
244+
245+
If the binary does not exist, it and its signature will be downloaded.
246+
The binary will only be saved and made executable if the signature is
247+
valid (or if we are unable to validate the signature because GPG is
248+
not installed).
249+
250+
If the signature is invalid, a `SystemExit` exception will be raised.
251+
252+
Returns:
253+
The path to the valid, executable Bazel binary within the provided
254+
directory.
255+
"""
128256
bazel_filename = determine_bazel_filename(version)
129-
url = determine_url(version, bazel_filename)
130-
destination_path = os.path.join(directory, bazel_filename)
131-
if not os.path.exists(destination_path):
132-
sys.stderr.write("Downloading {}...\n".format(url))
133-
with closing(urlopen(url)) as response:
134-
with open(destination_path, 'wb') as out_file:
135-
shutil.copyfileobj(response, out_file)
136-
os.chmod(destination_path, 0o755)
137-
return destination_path
257+
urls = determine_urls(version, bazel_filename)
258+
binary_path = os.path.join(directory, bazel_filename)
259+
if not os.path.exists(binary_path):
260+
untrusted_binary_path = "{}.untrusted".format(binary_path)
261+
signature_path = "{}.sig".format(binary_path)
262+
download_file(urls.binary_url, untrusted_binary_path)
263+
download_file(urls.signature_url, signature_path)
264+
if verify_authenticity(untrusted_binary_path, signature_path):
265+
os.rename(untrusted_binary_path, binary_path)
266+
else:
267+
os.unlink(untrusted_binary_path)
268+
raise SystemExit(AUTHENTICATION_FAILURE_EXIT_CODE)
269+
os.chmod(binary_path, 0o755)
270+
return binary_path
138271

139272

140273
def maybe_makedirs(path):

0 commit comments

Comments
 (0)