chiark / gitweb /
Merge branch 'buildserver-verbose' into 'master'
[fdroidserver.git] / fdroidserver / dscanner.py
1 #!/usr/bin/env python3
2 #
3 # dscanner.py - part of the FDroid server tools
4 # Copyright (C) 2016-2017 Shawn Gustaw <self@shawngustaw.com>
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU Affero General Public License for more details.
15 #
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19 import logging
20 import os
21 import json
22 import sys
23 from time import sleep
24 from argparse import ArgumentParser
25 from subprocess import CalledProcessError, check_output
26
27 from fdroidserver import common, metadata
28
29 try:
30     from docker import Client
31 except ImportError:
32     logging.error(("Docker client not installed."
33                    "Install it using pip install docker-py"))
34
35 config = None
36 options = None
37
38
39 class DockerConfig:
40     ALIAS = "dscanner"
41     CONTAINER = "dscanner/fdroidserver"
42     EMULATOR = "android-19"
43     ARCH = "armeabi-v7a"
44
45
46 class DockerDriver(object):
47     """
48     Handles all the interactions with the docker container the
49     Android emulator runs in.
50     """
51     class Commands:
52         build = ['docker', 'build', '--no-cache=false', '--pull=true',
53                  '--quiet=false', '--rm=true', '-t',
54                  '{0}:latest'.format(DockerConfig.CONTAINER), '.']
55         run = [
56             'docker', 'run',
57             '-e', '"EMULATOR={0}"'.format(DockerConfig.EMULATOR),
58             '-e', '"ARCH={0}"'.format(DockerConfig.ARCH),
59             '-d', '-P', '--name',
60             '{0}'.format(DockerConfig.ALIAS), '--log-driver=json-file',
61             DockerConfig.CONTAINER]
62         start = ['docker', 'start', '{0}'.format(DockerConfig.ALIAS)]
63         inspect = ['docker', 'inspect', '{0}'.format(DockerConfig.ALIAS)]
64         pm_list = 'adb shell "pm list packages"'
65         install_drozer = "docker exec {0} python /home/drozer/install_agent.py"
66         run_drozer = 'python /home/drozer/drozer.py {0}'
67         copy_to_container = 'docker cp "{0}" {1}:{2}'
68         copy_from_container = 'docker cp {0}:{1} "{2}"'
69
70     def __init__(self, init_only=False, fresh_start=False, clean_only=False):
71         self.container_id = None
72         self.ip_address = None
73
74         self.cli = Client(base_url='unix://var/run/docker.sock')
75
76         if fresh_start or clean_only:
77             self.clean()
78
79         if clean_only:
80             logging.info("Cleaned containers and quitting.")
81             exit(0)
82
83         self.init_docker()
84
85         if init_only:
86             logging.info("Initialized and quitting.")
87             exit(0)
88
89     def _copy_to_container(self, src_path, dest_path):
90         """
91         Copies a file (presumed to be an apk) from src_path
92         to home directory on container.
93         """
94         path = '/home/drozer/{path}.apk'.format(path=dest_path)
95         command = self.Commands.copy_to_container.format(src_path,
96                                                          self.container_id,
97                                                          path)
98
99         try:
100             check_output(command, shell=True)
101         except CalledProcessError as e:
102             logging.error(('Command "{command}" failed with '
103                            'error code {code}'.format(command=command,
104                                                       code=e.returncode)))
105             raise
106
107     def _copy_from_container(self, src_path, dest_path):
108         """
109         Copies a file from src_path on the container to
110         dest_path on the host machine.
111         """
112         command = self.Commands.copy_from_container.format(self.container_id,
113                                                            src_path,
114                                                            dest_path)
115         try:
116             check_output(command, shell=True)
117         except CalledProcessError as e:
118             logging.error(('Command "{command}" failed with '
119                            'error code {code}'.format(command=command,
120                                                       code=e.returncode)))
121             raise
122
123         logging.info("Log stored at {path}".format(path=dest_path))
124
125     def _adb_install_apk(self, apk_path):
126         """
127         Installs an apk on the device running in the container
128         using adb.
129         """
130         logging.info("Attempting to install an apk.")
131         exec_id = self.cli.exec_create(
132             self.container_id, 'adb install {0}'
133             .format(apk_path)
134             )['Id']
135         output = self.cli.exec_start(exec_id).decode('utf-8')
136
137         if "INSTALL_PARSE_FAILED_NO_CERTIFICATES" in output:
138             raise Exception('Install parse failed, no certificates')
139         elif "INSTALL_FAILED_ALREADY_EXISTS" in output:
140             logging.info("APK already installed. Skipping.")
141         elif "Success" not in output:
142             logging.error("APK didn't install properly")
143             return False
144         return True
145
146     def _adb_uninstall_apk(self, app_id):
147         """
148         Uninstalls an application from the device running in the container
149         via its app_id.
150         """
151         logging.info(
152             "Uninstalling {app_id} from the emulator."
153             .format(app_id=app_id)
154             )
155         exec_id = self.cli.exec_create(
156             self.container_id,
157             'adb uninstall {0}'.format(app_id)
158             )['Id']
159         output = self.cli.exec_start(exec_id).decode('utf-8')
160
161         if 'Success' in output:
162             logging.info("Successfully uninstalled.")
163
164         return True
165
166     def _verify_apk_install(self, app_id):
167         """
168         Checks that the app_id is installed on the device running in the
169         container.
170         """
171         logging.info(
172             "Verifying {app} is installed on the device."
173             .format(app=app_id)
174             )
175         exec_id = self.cli.exec_create(
176             self.container_id, self.Commands.pm_list
177             )['Id']
178         output = self.cli.exec_start(exec_id).decode('utf-8')
179
180         if ("Could not access the Package Manager" in output or
181                 "device offline" in output):
182             logging.info("Device or package manager isn't up")
183
184         if app_id.split('_')[0] in output:   # TODO: this is a temporary fix
185             logging.info("{app} is installed.".format(app=app_id))
186             return True
187
188         logging.error("APK not found in packages list on emulator.")
189
190     def _delete_file(self, path):
191         """
192         Deletes file off the container to preserve space if scanning many apps
193         """
194         command = "rm {path}".format(path=path)
195         exec_id = self.cli.exec_create(self.container_id, command)['Id']
196         logging.info("Deleting {path} on the container.".format(path=path))
197         self.cli.exec_start(exec_id)
198
199     def _install_apk(self, apk_path, app_id):
200         """
201         Installs apk found at apk_path on the emulator. Will then
202         verify it installed properly by looking up its app_id in
203         the package manager.
204         """
205         if not all([self.container_id, self.ip_address]):
206             # TODO: maybe have this fail nicely
207             raise Exception("Went to install apk and couldn't find container")
208
209         path = "/home/drozer/{app_id}.apk".format(app_id=app_id)
210         self._copy_to_container(apk_path, app_id)
211         self._adb_install_apk(path)
212         self._verify_apk_install(app_id)
213         self._delete_file(path)
214
215     def _install_drozer(self):
216         """
217         Performs all the initialization of drozer within the emulator.
218         """
219         logging.info("Attempting to install com.mwr.dz on the emulator")
220         logging.info("This could take a while so be patient...")
221         logging.info(("We need to wait for the device to boot AND"
222                       " the package manager to come online."))
223         command = self.Commands.install_drozer.format(self.container_id)
224         try:
225             output = check_output(command,
226                                   shell=True).decode('utf-8')
227         except CalledProcessError as e:
228             logging.error(('Command "{command}" failed with '
229                            'error code {code}'.format(command=command,
230                                                       code=e.returncode)))
231             raise
232
233         if 'Installed ok' in output:
234             return True
235
236     def _run_drozer_scan(self, app):
237         """
238         Runs the drozer agent which connects to the app running
239         on the emulator.
240         """
241         logging.info("Running the drozer agent")
242         exec_id = self.cli.exec_create(
243             self.container_id,
244             self.Commands.run_drozer.format(app)
245             )['Id']
246         self.cli.exec_start(exec_id)
247
248     def _container_is_running(self):
249         """
250         Checks whether the emulator container is running.
251         """
252         for container in self.cli.containers():
253             if DockerConfig.ALIAS in container['Image']:
254                 return True
255
256     def _docker_image_exists(self):
257         """
258         Check whether the docker image exists already.
259         If this returns false we'll need to build the image
260         from the DockerFile.
261         """
262         for image in self.cli.images():
263             for tag in image['RepoTags']:
264                 if DockerConfig.ALIAS in tag:
265                     return True
266
267     _image_queue = {}
268
269     def _build_docker_image(self):
270         """
271         Builds the docker container so we can run the android emulator
272         inside it.
273         """
274         logging.info("Pulling the container from docker hub")
275         logging.info("Image is roughly 5 GB so be patient")
276
277         logging.info("(Progress output is slow and requires a tty.)")
278         # we pause briefly to narrow race condition windows of opportunity
279         sleep(1)
280
281         is_a_tty = os.isatty(sys.stdout.fileno())
282
283         for output in self.cli.pull(
284                 DockerConfig.CONTAINER,
285                 stream=True,
286                 tag="latest"):
287             if not is_a_tty:
288                 # run silent, run quick
289                 continue
290             try:
291                 p = json.loads(output.decode('utf-8'))
292                 p_id = p['id']
293                 self._image_queue[p_id] = p
294                 t, c, j = 1, 1, 0
295                 for k in sorted(self._image_queue):
296                     j += 1
297                     v = self._image_queue[k]
298                     vd = v['progressDetail']
299                     t += vd['total']
300                     c += vd['current']
301                 msg = "\rDownloading: {0}/{1} {2}% [{3} jobs]"
302                 msg = msg.format(c, t, int(c / t * 100), j)
303                 sys.stdout.write(msg)
304                 sys.stdout.flush()
305             except Exception:
306                 pass
307         print("\nDONE!\n")
308
309     def _verify_apk_exists(self, full_apk_path):
310         """
311         Verifies that the apk path we have is actually a file.
312         """
313         return os.path.isfile(full_apk_path)
314
315     def init_docker(self):
316         """
317         Perform all the initialization required before a drozer scan.
318         1. build the image
319         2. run the container
320         3. install drozer and enable the service within the app
321         """
322         built = self._docker_image_exists()
323
324         if not built:
325             self._build_docker_image()
326
327         running = self._container_is_running()
328
329         if not running:
330             logging.info('Trying to run container...')
331             try:
332                 check_output(self.Commands.run)
333             except CalledProcessError as e:
334                 logging.error((
335                     'Command "{command}" failed with error code {code}'
336                     .format(command=self.Commands.run, code=e.returncode)
337                     ))
338             running = self._container_is_running()
339
340         if not running:
341             logging.info('Trying to start container...')
342             try:
343                 check_output(self.Commands.start)
344             except CalledProcessError as e:
345                 logging.error((
346                     'Command "{command}" failed with error code {code}'
347                     .format(command=self.Commands.run, code=e.returncode)
348                     ))
349             running = self._container_is_running()
350
351         if not running:
352             raise Exception("Running container not found, critical error.")
353
354         containers = self.cli.containers()
355
356         for container in containers:
357             if DockerConfig.ALIAS in container['Image']:
358                 self.container_id = container['Id']
359                 n = container['NetworkSettings']['Networks']
360                 self.ip_address = n['bridge']['IPAddress']
361                 break
362
363         if not self.container_id or not self.ip_address:
364             logging.error("No ip address or container id found.")
365             exit(1)
366
367         if self._verify_apk_install('com.mwr.dz'):
368             return
369
370         self._install_drozer()
371
372     def clean(self):
373         """
374         Clean up all the containers made by this script.
375         Should be run after the drozer scan completes.
376         """
377         for container in self.cli.containers():
378             if DockerConfig.ALIAS in container['Image']:
379                 logging.info("Removing container {0}".format(container['Id']))
380                 self.cli.remove_container(container['Id'], force=True)
381
382     def perform_drozer_scan(self, apk_path, app_id):
383         """
384         Entrypoint for scanning an android app. Performs the following steps:
385         1. installs an apk on the device
386         2. runs a drozer scan
387         3. copies the report off the container
388         4. uninstalls the apk to save space on the device
389         """
390         self._install_apk(apk_path, app_id)
391         logging.info("Running the drozer scan.")
392         self._run_drozer_scan(app_id)
393         logging.info("Scan finished. Moving the report off the container")
394         dest = apk_path + '.drozer'
395         self._copy_from_container('/tmp/drozer_report.log', dest)
396         self._adb_uninstall_apk(app_id)
397
398
399 def main():
400     global config, options
401
402     # Parse command line...
403     parser = ArgumentParser(
404         usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]"
405         )
406     common.setup_global_opts(parser)
407
408     parser.add_argument(
409         "app_id", nargs='*',
410         help="app-id with optional versioncode in the form APPID[:VERCODE]")
411     parser.add_argument(
412         "-l", "--latest", action="store_true", default=False,
413         help="Scan only the latest version of each package")
414     parser.add_argument(
415         "--clean-after", default=False, action='store_true',
416         help="Clean after all scans have finished")
417     parser.add_argument(
418         "--clean-before", default=False, action='store_true',
419         help="Clean before the scans start and rebuild the container")
420     parser.add_argument(
421         "--clean-only", default=False, action='store_true',
422         help="Clean up all containers and then exit")
423     parser.add_argument(
424         "--init-only", default=False, action='store_true',
425         help="Prepare drozer to run a scan")
426     parser.add_argument(
427         "--repo-path", default="repo", action="store",
428         help="Override path for repo APKs (default: ./repo)")
429
430     options = parser.parse_args()
431     config = common.read_config(options)
432
433     if not os.path.isdir(options.repo_path):
434         sys.stderr.write("repo-path not found: \"" + options.repo_path + "\"")
435         exit(1)
436
437     # Read all app and srclib metadata
438     allapps = metadata.read_metadata()
439     apps = common.read_app_args(options.app_id, allapps, True)
440
441     docker = DockerDriver(
442         init_only=options.init_only,
443         fresh_start=options.clean_before,
444         clean_only=options.clean_only
445     )
446
447     if options.clean_before:
448         docker.clean()
449
450     if options.clean_only:
451         exit(0)
452
453     for app_id, app in apps.items():
454         vercode = 0
455         if ':' in app_id:
456             vercode = app_id.split(':')[1]
457         for build in reversed(app.builds):
458             if build.disable:
459                 continue
460             if options.latest or vercode == 0 or build.versionCode == vercode:
461                 app.builds = [build]
462                 break
463             continue
464         continue
465
466     for app_id, app in apps.items():
467         for build in app.builds:
468             apks = []
469             for f in os.listdir(options.repo_path):
470                 n = common.get_release_filename(app, build)
471                 if f == n:
472                     apks.append(f)
473             for apk in sorted(apks):
474                 apk_path = os.path.join(options.repo_path, apk)
475                 docker.perform_drozer_scan(apk_path, app.id)
476
477     if options.clean_after:
478         docker.clean()
479
480
481 if __name__ == "__main__":
482     main()