1#!/usr/bin/env python
2#
3#   Copyright 2017 - The Android Open Source Project
4#
5#   Licensed under the Apache License, Version 2.0 (the "License");
6#   you may not use this file except in compliance with the License.
7#   You may obtain a copy of the License at
8#
9#       http://www.apache.org/licenses/LICENSE-2.0
10#
11#   Unless required by applicable law or agreed to in writing, software
12#   distributed under the License is distributed on an "AS IS" BASIS,
13#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14#   See the License for the specific language governing permissions and
15#   limitations under the License.
16
17from __future__ import print_function
18from xml.dom import minidom
19
20import argparse
21import itertools
22import os
23import re
24import subprocess
25import sys
26import tempfile
27import shutil
28
29DEVICE_PREFIX = 'device:'
30ANDROID_NAME_REGEX = r'A: android:name\([\S]+\)=\"([\S]+)\"'
31ANDROID_PROTECTION_LEVEL_REGEX = \
32    r'A: android:protectionLevel\([^\)]+\)=\(type [\S]+\)0x([\S]+)'
33BASE_XML_FILENAME = 'privapp-permissions-platform.xml'
34
35HELP_MESSAGE = """\
36Generates privapp-permissions.xml file for priv-apps.
37
38Usage:
39    Specify which apk to generate priv-app permissions for. If no apk is \
40specified, this will default to all APKs under "<ANDROID_PRODUCT_OUT>/ \
41system/priv-app".
42
43Examples:
44
45    For all APKs under $ANDROID_PRODUCT_OUT:
46        # If the build environment has not been set up, do so:
47        . build/envsetup.sh
48        lunch product_name
49        m -j32
50        # then use:
51        cd development/tools/privapp_permissions/
52        ./privapp_permissions.py
53
54    For a given apk:
55        ./privapp_permissions.py path/to/the.apk
56
57    For an APK already on the device:
58        ./privapp_permissions.py device:/device/path/to/the.apk
59
60    For all APKs on a device:
61        ./privapp_permissions.py -d
62        # or if more than one device is attached
63        ./privapp_permissions.py -s <ANDROID_SERIAL>\
64"""
65
66# An array of all generated temp directories.
67temp_dirs = []
68# An array of all generated temp files.
69temp_files = []
70
71
72class MissingResourceError(Exception):
73    """Raised when a dependency cannot be located."""
74
75
76class Adb(object):
77    """A small wrapper around ADB calls."""
78
79    def __init__(self, path, serial=None):
80        self.path = path
81        self.serial = serial
82
83    def pull(self, src, dst=None):
84        """A wrapper for `adb -s <SERIAL> pull <src> <dst>`.
85        Args:
86            src: The source path on the device
87            dst: The destination path on the host
88
89        Throws:
90            subprocess.CalledProcessError upon pull failure.
91        """
92        if not dst:
93            if self.call('shell \'if [ -d "%s" ]; then echo True; fi\'' % src):
94                dst = tempfile.mkdtemp()
95                temp_dirs.append(dst)
96            else:
97                _, dst = tempfile.mkstemp()
98                temp_files.append(dst)
99        self.call('pull %s %s' % (src, dst))
100        return dst
101
102    def call(self, cmdline):
103        """Calls an adb command.
104
105        Throws:
106            subprocess.CalledProcessError upon command failure.
107        """
108        command = '%s -s %s %s' % (self.path, self.serial, cmdline)
109        return get_output(command)
110
111
112class Aapt(object):
113    def __init__(self, path):
114        self.path = path
115
116    def call(self, arguments):
117        """Run an aapt command with the given args.
118
119        Args:
120            arguments: a list of string arguments
121        Returns:
122            The output of the aapt command as a string.
123        """
124        output = subprocess.check_output([self.path] + arguments,
125                                         stderr=subprocess.STDOUT)
126        return output.decode(encoding='UTF-8')
127
128
129class Resources(object):
130    """A class that contains the resources needed to generate permissions.
131
132    Attributes:
133        adb: A wrapper class around ADB with a default serial. Only needed when
134             using -d, -s, or "device:"
135        _aapt_path: The path to aapt.
136    """
137
138    def __init__(self, adb_path=None, aapt_path=None, use_device=None,
139                 serial=None, apks=None):
140        self.adb = Resources._resolve_adb(adb_path)
141        self.aapt = Resources._resolve_aapt(aapt_path)
142
143        self._is_android_env = 'ANDROID_PRODUCT_OUT' in os.environ and \
144                               'ANDROID_HOST_OUT' in os.environ
145        use_device = use_device or serial or \
146                     (apks and DEVICE_PREFIX in '&'.join(apks))
147
148        self.adb.serial = self._resolve_serial(use_device, serial)
149
150        if self.adb.serial:
151            self.adb.call('root')
152            self.adb.call('wait-for-device')
153
154        if self.adb.serial is None and not self._is_android_env:
155            raise MissingResourceError(
156                'You must either set up your build environment, or specify a '
157                'device to run against. See --help for more info.')
158
159        self.privapp_apks = self._resolve_apks(apks)
160        self.permissions_dir = self._resolve_sys_path('system/etc/permissions')
161        self.sysconfig_dir = self._resolve_sys_path('system/etc/sysconfig')
162        self.framework_res_apk = self._resolve_sys_path('system/framework/'
163                                                        'framework-res.apk')
164
165    @staticmethod
166    def _resolve_adb(adb_path):
167        """Resolves ADB from either the cmdline argument or the os environment.
168
169        Args:
170            adb_path: The argument passed in for adb. Can be None.
171        Returns:
172            An Adb object.
173        Raises:
174            MissingResourceError if adb cannot be resolved.
175        """
176        if adb_path:
177            if os.path.isfile(adb_path):
178                adb = adb_path
179            else:
180                raise MissingResourceError('Cannot resolve adb: No such file '
181                                           '"%s" exists.' % adb_path)
182        else:
183            try:
184                adb = get_output('which adb').strip()
185            except subprocess.CalledProcessError as e:
186                print('Cannot resolve adb: ADB does not exist within path. '
187                      'Did you forget to setup the build environment or set '
188                      '--adb?',
189                      file=sys.stderr)
190                raise MissingResourceError(e)
191        # Start the adb server immediately so server daemon startup
192        # does not get added to the output of subsequent adb calls.
193        try:
194            get_output('%s start-server' % adb)
195            return Adb(adb)
196        except:
197            print('Unable to reach adb server daemon.', file=sys.stderr)
198            raise
199
200    @staticmethod
201    def _resolve_aapt(aapt_path):
202        """Resolves AAPT from either the cmdline argument or the os environment.
203
204        Returns:
205            An Aapt Object
206        """
207        if aapt_path:
208            if os.path.isfile(aapt_path):
209                return Aapt(aapt_path)
210            else:
211                raise MissingResourceError('Cannot resolve aapt: No such file '
212                                           '%s exists.' % aapt_path)
213        else:
214            try:
215                return Aapt(get_output('which aapt').strip())
216            except subprocess.CalledProcessError:
217                print('Cannot resolve aapt: AAPT does not exist within path. '
218                      'Did you forget to setup the build environment or set '
219                      '--aapt?',
220                      file=sys.stderr)
221                raise
222
223    def _resolve_serial(self, device, serial):
224        """Resolves the serial used for device files or generating permissions.
225
226        Returns:
227            If -s/--serial is specified, it will return that serial.
228            If -d or device: is found, it will grab the only available device.
229            If there are multiple devices, it will use $ANDROID_SERIAL.
230        Raises:
231            MissingResourceError if the resolved serial would not be usable.
232            subprocess.CalledProcessError if a command error occurs.
233        """
234        if device:
235            if serial:
236                try:
237                    output = get_output('%s -s %s get-state' %
238                                        (self.adb.path, serial))
239                except subprocess.CalledProcessError:
240                    raise MissingResourceError(
241                        'Received error when trying to get the state of '
242                        'device with serial "%s". Is it connected and in '
243                        'device mode?' % serial)
244                if 'device' not in output:
245                    raise MissingResourceError(
246                        'Device "%s" is not in device mode. Reboot the phone '
247                        'into device mode and try again.' % serial)
248                return serial
249
250            elif 'ANDROID_SERIAL' in os.environ:
251                serial = os.environ['ANDROID_SERIAL']
252                command = '%s -s %s get-state' % (self.adb, serial)
253                try:
254                    output = get_output(command)
255                except subprocess.CalledProcessError:
256                    raise MissingResourceError(
257                        'Device with serial $ANDROID_SERIAL ("%s") not '
258                        'found.' % serial)
259                if 'device' in output:
260                    return serial
261                raise MissingResourceError(
262                    'Device with serial $ANDROID_SERIAL ("%s") was '
263                    'found, but was not in the "device" state.')
264
265            # Parses `adb devices` so it only returns a string of serials.
266            get_serials_cmd = ('%s devices | tail -n +2 | head -n -1 | '
267                               'cut -f1' % self.adb.path)
268            try:
269                output = get_output(get_serials_cmd)
270                # If multiple serials appear in the output, raise an error.
271                if len(output.split()) > 1:
272                    raise MissingResourceError(
273                        'Multiple devices are connected. You must specify '
274                        'which device to run against with flag --serial.')
275                return output.strip()
276            except subprocess.CalledProcessError:
277                print('Unexpected error when querying for connected '
278                      'devices.', file=sys.stderr)
279                raise
280
281    def _resolve_apks(self, apks):
282        """Resolves all APKs to run against.
283
284        Returns:
285            If no apk is specified in the arguments, return all apks in
286            system/priv-app. Otherwise, returns a list with the specified apk.
287        Throws:
288            MissingResourceError if the specified apk or system/priv-app cannot
289            be found.
290        """
291        if not apks:
292            return self._resolve_all_privapps()
293
294        ret_apks = []
295        for apk in apks:
296            if apk.startswith(DEVICE_PREFIX):
297                device_apk = apk[len(DEVICE_PREFIX):]
298                try:
299                    apk = self.adb.pull(device_apk)
300                except subprocess.CalledProcessError:
301                    raise MissingResourceError(
302                        'File "%s" could not be located on device "%s".' %
303                        (device_apk, self.adb.serial))
304                ret_apks.append(apk)
305            elif not os.path.isfile(apk):
306                raise MissingResourceError('File "%s" does not exist.' % apk)
307            else:
308                ret_apks.append(apk)
309        return ret_apks
310
311    def _resolve_all_privapps(self):
312        """Extract package name and requested permissions."""
313        if self._is_android_env:
314            priv_app_dir = os.path.join(os.environ['ANDROID_PRODUCT_OUT'],
315                                        'system/priv-app')
316        else:
317            try:
318                priv_app_dir = self.adb.pull('/system/priv-app/')
319            except subprocess.CalledProcessError:
320                raise MissingResourceError(
321                    'Directory "/system/priv-app" could not be pulled from on '
322                    'device "%s".' % self.adb.serial)
323
324        return get_output('find %s -name "*.apk"' % priv_app_dir).split()
325
326    def _resolve_sys_path(self, file_path):
327        """Resolves a path that is a part of an Android System Image."""
328        if self._is_android_env:
329            return os.path.join(os.environ['ANDROID_PRODUCT_OUT'], file_path)
330        else:
331            return self.adb.pull(file_path)
332
333
334def get_output(command):
335    """Returns the output of the command as a string.
336
337    Throws:
338        subprocess.CalledProcessError if exit status is non-zero.
339    """
340    output = subprocess.check_output(command, shell=True)
341    # For Python3.4, decode the byte string so it is usable.
342    return output.decode(encoding='UTF-8')
343
344
345def parse_args():
346    """Parses the CLI."""
347    parser = argparse.ArgumentParser(
348        description=HELP_MESSAGE,
349        formatter_class=argparse.RawDescriptionHelpFormatter)
350    parser.add_argument(
351        '-d',
352        '--device',
353        action='store_true',
354        default=False,
355        required=False,
356        help='Whether or not to generate the privapp_permissions file for the '
357             'build already on a device. See -s/--serial below for more '
358             'details.'
359    )
360    parser.add_argument(
361        '--adb',
362        type=str,
363        required=False,
364        metavar='<ADB_PATH',
365        help='Path to adb. If none specified, uses the environment\'s adb.'
366    )
367    parser.add_argument(
368        '--aapt',
369        type=str,
370        required=False,
371        metavar='<AAPT_PATH>',
372        help='Path to aapt. If none specified, uses the environment\'s aapt.'
373    )
374    parser.add_argument(
375        '-s',
376        '--serial',
377        type=str,
378        required=False,
379        metavar='<SERIAL>',
380        help='The serial of the device to generate permissions for. If no '
381             'serial is given, it will pick the only device connected over '
382             'adb. If multiple devices are found, it will default to '
383             '$ANDROID_SERIAL. Otherwise, the program will exit with error '
384             'code 1. If -s is given, -d is not needed.'
385    )
386    parser.add_argument(
387        'apks',
388        nargs='*',
389        type=str,
390        help='A list of paths to priv-app APKs to generate permissions for. '
391             'To make a path device-side, prefix the path with "device:".'
392    )
393    cmd_args = parser.parse_args()
394
395    return cmd_args
396
397
398def create_permission_file(resources):
399    # Parse base XML files in /etc dir, permissions listed there don't have
400    # to be re-added
401    base_permissions = {}
402    base_xml_files = itertools.chain(list_xml_files(resources.permissions_dir),
403                                     list_xml_files(resources.sysconfig_dir))
404    for xml_file in base_xml_files:
405        parse_config_xml(xml_file, base_permissions)
406
407    priv_permissions = extract_priv_permissions(resources.aapt,
408                                                resources.framework_res_apk)
409
410    apps_redefine_base = []
411    results = {}
412    for priv_app in resources.privapp_apks:
413        pkg_info = extract_pkg_and_requested_permissions(resources.aapt,
414                                                         priv_app)
415        pkg_name = pkg_info['package_name']
416        priv_perms = get_priv_permissions(pkg_info['permissions'],
417                                          priv_permissions)
418        # Compute diff against permissions defined in base file
419        if base_permissions and (pkg_name in base_permissions):
420            base_permissions_pkg = base_permissions[pkg_name]
421            priv_perms = remove_base_permissions(priv_perms,
422                                                 base_permissions_pkg)
423            if priv_perms:
424                apps_redefine_base.append(pkg_name)
425        if priv_perms:
426            results[pkg_name] = sorted(priv_perms)
427
428    print_xml(results, apps_redefine_base)
429
430
431def print_xml(results, apps_redefine_base, fd=sys.stdout):
432    """Print results to the given file."""
433    fd.write('<?xml version="1.0" encoding="utf-8"?>\n<permissions>\n')
434    for package_name in sorted(results):
435        if package_name in apps_redefine_base:
436            fd.write('    <!-- Additional permissions on top of %s -->\n' %
437                     BASE_XML_FILENAME)
438        fd.write('    <privapp-permissions package="%s">\n' % package_name)
439        for p in results[package_name]:
440            fd.write('        <permission name="%s"/>\n' % p)
441        fd.write('    </privapp-permissions>\n')
442        fd.write('\n')
443
444    fd.write('</permissions>\n')
445
446
447def remove_base_permissions(priv_perms, base_perms):
448    """Removes set of base_perms from set of priv_perms."""
449    if (not priv_perms) or (not base_perms):
450        return priv_perms
451    return set(priv_perms) - set(base_perms)
452
453
454def get_priv_permissions(requested_perms, priv_perms):
455    """Return only permissions that are in priv_perms set."""
456    return set(requested_perms).intersection(set(priv_perms))
457
458
459def list_xml_files(directory):
460    """Returns a list of all .xml files within a given directory.
461
462    Args:
463        directory: the directory to look for xml files in.
464    """
465    xml_files = []
466    for dirName, subdirList, file_list in os.walk(directory):
467        for file in file_list:
468            if file.endswith('.xml'):
469                file_path = os.path.join(dirName, file)
470                xml_files.append(file_path)
471    return xml_files
472
473
474def extract_pkg_and_requested_permissions(aapt, apk_path):
475    """
476    Extract package name and list of requested permissions from the
477    dump of manifest file
478    """
479    aapt_args = ['d', 'permissions', apk_path]
480    txt = aapt.call(aapt_args)
481
482    permissions = []
483    package_name = None
484    raw_lines = txt.split('\n')
485    for line in raw_lines:
486        regex = r"uses-permission.*: name='([\S]+)'"
487        matches = re.search(regex, line)
488        if matches:
489            name = matches.group(1)
490            permissions.append(name)
491        regex = r'package: ([\S]+)'
492        matches = re.search(regex, line)
493        if matches:
494            package_name = matches.group(1)
495
496    return {'package_name': package_name, 'permissions': permissions}
497
498
499def extract_priv_permissions(aapt, apk_path):
500    """Extract signature|privileged permissions from dump of manifest file."""
501    aapt_args = ['d', 'xmltree', apk_path, 'AndroidManifest.xml']
502    txt = aapt.call(aapt_args)
503    raw_lines = txt.split('\n')
504    n = len(raw_lines)
505    i = 0
506    permissions_list = []
507    while i < n:
508        line = raw_lines[i]
509        if line.find('E: permission (') != -1:
510            i += 1
511            name = None
512            level = None
513            while i < n:
514                line = raw_lines[i]
515                if line.find('E: ') != -1:
516                    break
517                matches = re.search(ANDROID_NAME_REGEX, line)
518                if matches:
519                    name = matches.group(1)
520                    i += 1
521                    continue
522                matches = re.search(ANDROID_PROTECTION_LEVEL_REGEX, line)
523                if matches:
524                    level = int(matches.group(1), 16)
525                    i += 1
526                    continue
527                i += 1
528            if name and level and level & 0x12 == 0x12:
529                permissions_list.append(name)
530        else:
531            i += 1
532
533    return permissions_list
534
535
536def parse_config_xml(base_xml, results):
537    """Parse an XML file that will be used as base."""
538    dom = minidom.parse(base_xml)
539    nodes = dom.getElementsByTagName('privapp-permissions')
540    for node in nodes:
541        permissions = (node.getElementsByTagName('permission') +
542                       node.getElementsByTagName('deny-permission'))
543        package_name = node.getAttribute('package')
544        plist = []
545        if package_name in results:
546            plist = results[package_name]
547        for p in permissions:
548            perm_name = p.getAttribute('name')
549            if perm_name:
550                plist.append(perm_name)
551        results[package_name] = plist
552    return results
553
554
555def cleanup():
556    """Cleans up temp files."""
557    for directory in temp_dirs:
558        shutil.rmtree(directory, ignore_errors=True)
559    for file in temp_files:
560        os.remove(file)
561    del temp_dirs[:]
562    del temp_files[:]
563
564
565if __name__ == '__main__':
566    args = parse_args()
567    try:
568        tool_resources = Resources(
569            aapt_path=args.aapt,
570            adb_path=args.adb,
571            use_device=args.device,
572            serial=args.serial,
573            apks=args.apks
574        )
575        create_permission_file(tool_resources)
576    except MissingResourceError as e:
577        print(str(e), file=sys.stderr)
578        exit(1)
579    finally:
580        cleanup()
581