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 and (system/)product/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.system_privapp_apks, self.product_privapp_apks =(
160                self._resolve_apks(apks))
161        self.system_permissions_dir = (
162                self._resolve_sys_path('system/etc/permissions'))
163        self.system_sysconfig_dir = (
164                self._resolve_sys_path('system/etc/sysconfig'))
165        self.product_permissions_dir = (
166                self._resolve_sys_path('product/etc/permissions',
167                                       'system/product/etc/permissions'))
168        self.product_sysconfig_dir = (
169                self._resolve_sys_path('product/etc/sysconfig',
170                                       'system/product/etc/sysconfig'))
171        self.framework_res_apk = self._resolve_sys_path('system/framework/'
172                                                        'framework-res.apk')
173
174    @staticmethod
175    def _resolve_adb(adb_path):
176        """Resolves ADB from either the cmdline argument or the os environment.
177
178        Args:
179            adb_path: The argument passed in for adb. Can be None.
180        Returns:
181            An Adb object.
182        Raises:
183            MissingResourceError if adb cannot be resolved.
184        """
185        if adb_path:
186            if os.path.isfile(adb_path):
187                adb = adb_path
188            else:
189                raise MissingResourceError('Cannot resolve adb: No such file '
190                                           '"%s" exists.' % adb_path)
191        else:
192            try:
193                adb = get_output('which adb').strip()
194            except subprocess.CalledProcessError as e:
195                print('Cannot resolve adb: ADB does not exist within path. '
196                      'Did you forget to setup the build environment or set '
197                      '--adb?',
198                      file=sys.stderr)
199                raise MissingResourceError(e)
200        # Start the adb server immediately so server daemon startup
201        # does not get added to the output of subsequent adb calls.
202        try:
203            get_output('%s start-server' % adb)
204            return Adb(adb)
205        except:
206            print('Unable to reach adb server daemon.', file=sys.stderr)
207            raise
208
209    @staticmethod
210    def _resolve_aapt(aapt_path):
211        """Resolves AAPT from either the cmdline argument or the os environment.
212
213        Returns:
214            An Aapt Object
215        """
216        if aapt_path:
217            if os.path.isfile(aapt_path):
218                return Aapt(aapt_path)
219            else:
220                raise MissingResourceError('Cannot resolve aapt: No such file '
221                                           '%s exists.' % aapt_path)
222        else:
223            try:
224                return Aapt(get_output('which aapt').strip())
225            except subprocess.CalledProcessError:
226                print('Cannot resolve aapt: AAPT does not exist within path. '
227                      'Did you forget to setup the build environment or set '
228                      '--aapt?',
229                      file=sys.stderr)
230                raise
231
232    def _resolve_serial(self, device, serial):
233        """Resolves the serial used for device files or generating permissions.
234
235        Returns:
236            If -s/--serial is specified, it will return that serial.
237            If -d or device: is found, it will grab the only available device.
238            If there are multiple devices, it will use $ANDROID_SERIAL.
239        Raises:
240            MissingResourceError if the resolved serial would not be usable.
241            subprocess.CalledProcessError if a command error occurs.
242        """
243        if device:
244            if serial:
245                try:
246                    output = get_output('%s -s %s get-state' %
247                                        (self.adb.path, serial))
248                except subprocess.CalledProcessError:
249                    raise MissingResourceError(
250                        'Received error when trying to get the state of '
251                        'device with serial "%s". Is it connected and in '
252                        'device mode?' % serial)
253                if 'device' not in output:
254                    raise MissingResourceError(
255                        'Device "%s" is not in device mode. Reboot the phone '
256                        'into device mode and try again.' % serial)
257                return serial
258
259            elif 'ANDROID_SERIAL' in os.environ:
260                serial = os.environ['ANDROID_SERIAL']
261                command = '%s -s %s get-state' % (self.adb, serial)
262                try:
263                    output = get_output(command)
264                except subprocess.CalledProcessError:
265                    raise MissingResourceError(
266                        'Device with serial $ANDROID_SERIAL ("%s") not '
267                        'found.' % serial)
268                if 'device' in output:
269                    return serial
270                raise MissingResourceError(
271                    'Device with serial $ANDROID_SERIAL ("%s") was '
272                    'found, but was not in the "device" state.')
273
274            # Parses `adb devices` so it only returns a string of serials.
275            get_serials_cmd = ('%s devices | tail -n +2 | head -n -1 | '
276                               'cut -f1' % self.adb.path)
277            try:
278                output = get_output(get_serials_cmd)
279                # If multiple serials appear in the output, raise an error.
280                if len(output.split()) > 1:
281                    raise MissingResourceError(
282                        'Multiple devices are connected. You must specify '
283                        'which device to run against with flag --serial.')
284                return output.strip()
285            except subprocess.CalledProcessError:
286                print('Unexpected error when querying for connected '
287                      'devices.', file=sys.stderr)
288                raise
289
290    def _resolve_apks(self, apks):
291        """Resolves all APKs to run against.
292
293        Returns:
294            If no apk is specified in the arguments, return all apks in
295            system/priv-app. Otherwise, returns a list with the specified apk.
296        Throws:
297            MissingResourceError if the specified apk or system/priv-app cannot
298            be found.
299        """
300        if not apks:
301            return (self._resolve_all_system_privapps(),
302                   self._resolve_all_product_privapps())
303
304        ret_apks = []
305        for apk in apks:
306            if apk.startswith(DEVICE_PREFIX):
307                device_apk = apk[len(DEVICE_PREFIX):]
308                try:
309                    apk = self.adb.pull(device_apk)
310                except subprocess.CalledProcessError:
311                    raise MissingResourceError(
312                        'File "%s" could not be located on device "%s".' %
313                        (device_apk, self.adb.serial))
314                ret_apks.append(apk)
315            elif not os.path.isfile(apk):
316                raise MissingResourceError('File "%s" does not exist.' % apk)
317            else:
318                ret_apks.append(apk)
319        return ret_apks, None
320
321    def _resolve_all_system_privapps(self):
322        """Extract package name and requested permissions."""
323        if self._is_android_env:
324            system_priv_app_dir = (
325                    os.path.join(os.environ['ANDROID_PRODUCT_OUT'],
326                                            'system/priv-app'))
327        else:
328            try:
329                system_priv_app_dir = self.adb.pull('/system/priv-app/')
330            except subprocess.CalledProcessError:
331                raise MissingResourceError(
332                    'Directory "/system/priv-app" could not be pulled from on '
333                    'device "%s".' % self.adb.serial)
334
335        return get_output('find %s -name "*.apk"' % system_priv_app_dir).split()
336
337    def _resolve_all_product_privapps(self):
338        """Extract package name and requested permissions."""
339        if self._is_android_env:
340            product_priv_app_dir = (
341                    os.path.join(os.environ['ANDROID_PRODUCT_OUT'],
342                                            'product/priv-app'))
343            if not os.path.exists(product_priv_app_dir):
344                product_priv_app_dir  = (
345                        os.path.join(os.environ['ANDROID_PRODUCT_OUT'],
346                                                'system/product/priv-app'))
347        else:
348            try:
349                product_priv_app_dir = self.adb.pull('/product/priv-app/')
350            except subprocess.CalledProcessError:
351                print('Warning: Directory "/product/priv-app" could not be '
352                        'pulled from on device "%s". Trying '
353                        '"/system/product/priv-app"' % self.adb.serial,
354                        file=sys.stderr)
355                try:
356                    product_priv_app_dir = (
357                            self.adb.pull('/system/product/priv-app/'))
358                except subprocess.CalledProcessError:
359                    raise MissingResourceError(
360                        'Directory "/system/product/priv-app" could not be '
361                        'pulled from on device "%s".' % self.adb.serial)
362
363        return get_output(
364                'find %s -name "*.apk"' % product_priv_app_dir).split()
365
366    def _resolve_sys_path(self, file_path, fallback_file_path=None):
367        """Resolves a path that is a part of an Android System Image."""
368        if self._is_android_env:
369            sys_path = (
370                    os.path.join(os.environ['ANDROID_PRODUCT_OUT'], file_path))
371            if not os.path.exists(sys_path):
372                sys_path = (
373                        os.path.join(os.environ['ANDROID_PRODUCT_OUT'],
374                        fallback_file_path))
375        else:
376            try:
377                sys_path = self.adb.pull(file_path)
378            except subprocess.CalledProcessError:
379                print('Warning: Directory %s could not be pulled from on device'
380                        '"%s". Trying "/system/product/priv-app"'
381                        % (file_path, self.adb.serial), file=sys.stderr)
382                try:
383                    sys_path = self.adb.pull(fallback_file_path)
384                except subprocess.CalledProcessError:
385                    raise MissingResourceError(
386                        'Directory %s could not be pulled from on '
387                        'device "%s".' % (fallback_file_path, self.adb.serial))
388
389        return sys_path
390
391
392def get_output(command):
393    """Returns the output of the command as a string.
394
395    Throws:
396        subprocess.CalledProcessError if exit status is non-zero.
397    """
398    output = subprocess.check_output(command, shell=True)
399    # For Python3.4, decode the byte string so it is usable.
400    return output.decode(encoding='UTF-8')
401
402
403def parse_args():
404    """Parses the CLI."""
405    parser = argparse.ArgumentParser(
406        description=HELP_MESSAGE,
407        formatter_class=argparse.RawDescriptionHelpFormatter)
408    parser.add_argument(
409        '-d',
410        '--device',
411        action='store_true',
412        default=False,
413        required=False,
414        help='Whether or not to generate the privapp_permissions file for the '
415             'build already on a device. See -s/--serial below for more '
416             'details.'
417    )
418    parser.add_argument(
419        '--adb',
420        type=str,
421        required=False,
422        metavar='<ADB_PATH',
423        help='Path to adb. If none specified, uses the environment\'s adb.'
424    )
425    parser.add_argument(
426        '--aapt',
427        type=str,
428        required=False,
429        metavar='<AAPT_PATH>',
430        help='Path to aapt. If none specified, uses the environment\'s aapt.'
431    )
432    parser.add_argument(
433        '-s',
434        '--serial',
435        type=str,
436        required=False,
437        metavar='<SERIAL>',
438        help='The serial of the device to generate permissions for. If no '
439             'serial is given, it will pick the only device connected over '
440             'adb. If multiple devices are found, it will default to '
441             '$ANDROID_SERIAL. Otherwise, the program will exit with error '
442             'code 1. If -s is given, -d is not needed.'
443    )
444    parser.add_argument(
445        'apks',
446        nargs='*',
447        type=str,
448        help='A list of paths to priv-app APKs to generate permissions for. '
449             'To make a path device-side, prefix the path with "device:".'
450    )
451    parser.add_argument(
452        '-w',
453        '--writetodisk',
454        action='store_true',
455        default=False,
456        required=False,
457        help='Whether or not to store the generated permissions directly to '
458             'a file. See --systemfile/--productfile for more information.'
459    )
460    parser.add_argument(
461        '--systemfile',
462        default='./system.xml',
463        required=False,
464        help='Path to system permissions file. Default value is ./system.xml'
465    )
466    parser.add_argument(
467        '--productfile',
468        default='./product.xml',
469        required=False,
470        help='Path to system permissions file. Default value is ./product.xml'
471    )
472    cmd_args = parser.parse_args()
473
474    return cmd_args
475
476def create_permission_file(resources, privapp_apks, permissions_dir,
477            sysconfig_dir, file=None):
478    # Parse base XML files in /etc dir, permissions listed there don't have
479    # to be re-added
480    base_permissions = {}
481    base_xml_files = itertools.chain(list_xml_files(permissions_dir),
482                                     list_xml_files(sysconfig_dir))
483    for xml_file in base_xml_files:
484        parse_config_xml(xml_file, base_permissions)
485
486    priv_permissions = extract_priv_permissions(resources.aapt,
487                                                resources.framework_res_apk)
488
489    apps_redefine_base = []
490    results = {}
491    for priv_app in privapp_apks:
492        pkg_info = extract_pkg_and_requested_permissions(resources.aapt,
493                                                         priv_app)
494        pkg_name = pkg_info['package_name']
495        priv_perms = get_priv_permissions(pkg_info['permissions'],
496                                          priv_permissions)
497        # Compute diff against permissions defined in base file
498        if base_permissions and (pkg_name in base_permissions):
499            base_permissions_pkg = base_permissions[pkg_name]
500            priv_perms = remove_base_permissions(priv_perms,
501                                                 base_permissions_pkg)
502            if priv_perms:
503                apps_redefine_base.append(pkg_name)
504        if priv_perms:
505            results[pkg_name] = sorted(priv_perms)
506
507    print_xml(results, apps_redefine_base)
508    if file is not None:
509        print_xml(results, apps_redefine_base, file)
510
511def print_xml(results, apps_redefine_base, fd=sys.stdout):
512    """Print results to the given file."""
513    fd.write('<?xml version="1.0" encoding="utf-8"?>\n<permissions>\n')
514    for package_name in sorted(results):
515        if package_name in apps_redefine_base:
516            fd.write('    <!-- Additional permissions on top of %s -->\n' %
517                     BASE_XML_FILENAME)
518        fd.write('    <privapp-permissions package="%s">\n' % package_name)
519        for p in results[package_name]:
520            fd.write('        <permission name="%s"/>\n' % p)
521        fd.write('    </privapp-permissions>\n')
522        fd.write('\n')
523
524    fd.write('</permissions>\n')
525
526
527def remove_base_permissions(priv_perms, base_perms):
528    """Removes set of base_perms from set of priv_perms."""
529    if (not priv_perms) or (not base_perms):
530        return priv_perms
531    return set(priv_perms) - set(base_perms)
532
533
534def get_priv_permissions(requested_perms, priv_perms):
535    """Return only permissions that are in priv_perms set."""
536    return set(requested_perms).intersection(set(priv_perms))
537
538
539def list_xml_files(directory):
540    """Returns a list of all .xml files within a given directory.
541
542    Args:
543        directory: the directory to look for xml files in.
544    """
545    xml_files = []
546    for dirName, subdirList, file_list in os.walk(directory):
547        for file in file_list:
548            if file.endswith('.xml'):
549                file_path = os.path.join(dirName, file)
550                xml_files.append(file_path)
551    return xml_files
552
553
554def extract_pkg_and_requested_permissions(aapt, apk_path):
555    """
556    Extract package name and list of requested permissions from the
557    dump of manifest file
558    """
559    aapt_args = ['d', 'permissions', apk_path]
560    txt = aapt.call(aapt_args)
561
562    permissions = []
563    package_name = None
564    raw_lines = txt.split('\n')
565    for line in raw_lines:
566        regex = r"uses-permission.*: name='([\S]+)'"
567        matches = re.search(regex, line)
568        if matches:
569            name = matches.group(1)
570            permissions.append(name)
571        regex = r'package: ([\S]+)'
572        matches = re.search(regex, line)
573        if matches:
574            package_name = matches.group(1)
575
576    return {'package_name': package_name, 'permissions': permissions}
577
578
579def extract_priv_permissions(aapt, apk_path):
580    """Extract signature|privileged permissions from dump of manifest file."""
581    aapt_args = ['d', 'xmltree', apk_path, 'AndroidManifest.xml']
582    txt = aapt.call(aapt_args)
583    raw_lines = txt.split('\n')
584    n = len(raw_lines)
585    i = 0
586    permissions_list = []
587    while i < n:
588        line = raw_lines[i]
589        if line.find('E: permission (') != -1:
590            i += 1
591            name = None
592            level = None
593            while i < n:
594                line = raw_lines[i]
595                if line.find('E: ') != -1:
596                    break
597                matches = re.search(ANDROID_NAME_REGEX, line)
598                if matches:
599                    name = matches.group(1)
600                    i += 1
601                    continue
602                matches = re.search(ANDROID_PROTECTION_LEVEL_REGEX, line)
603                if matches:
604                    level = int(matches.group(1), 16)
605                    i += 1
606                    continue
607                i += 1
608            if name and level and level & 0x12 == 0x12:
609                permissions_list.append(name)
610        else:
611            i += 1
612
613    return permissions_list
614
615
616def parse_config_xml(base_xml, results):
617    """Parse an XML file that will be used as base."""
618    dom = minidom.parse(base_xml)
619    nodes = dom.getElementsByTagName('privapp-permissions')
620    for node in nodes:
621        permissions = (node.getElementsByTagName('permission') +
622                       node.getElementsByTagName('deny-permission'))
623        package_name = node.getAttribute('package')
624        plist = []
625        if package_name in results:
626            plist = results[package_name]
627        for p in permissions:
628            perm_name = p.getAttribute('name')
629            if perm_name:
630                plist.append(perm_name)
631        results[package_name] = plist
632    return results
633
634
635def cleanup():
636    """Cleans up temp files."""
637    for directory in temp_dirs:
638        shutil.rmtree(directory, ignore_errors=True)
639    for file in temp_files:
640        os.remove(file)
641    del temp_dirs[:]
642    del temp_files[:]
643
644
645if __name__ == '__main__':
646    args = parse_args()
647    try:
648        tool_resources = Resources(
649            aapt_path=args.aapt,
650            adb_path=args.adb,
651            use_device=args.device,
652            serial=args.serial,
653            apks=args.apks
654        )
655        system_permission_file=None
656        product_permission_file=None
657        print('#' * 80)
658        print('#')
659        if args.writetodisk:
660            print('#System XML written to %s:' % args.systemfile)
661            system_permission_file = open(args.systemfile, 'w')
662        else:
663            print('#System XML:')
664        print('#')
665        print('#' * 80)
666        create_permission_file(
667            tool_resources,
668            tool_resources.system_privapp_apks,
669            tool_resources.system_permissions_dir,
670            tool_resources.system_sysconfig_dir,
671            system_permission_file)
672        if args.writetodisk:
673            system_permission_file.close()
674        if tool_resources.product_privapp_apks:
675            print('#' * 80)
676            print('#')
677            if args.writetodisk:
678                print('#Product XML written to %s:' % args.productfile)
679                product_permission_file = open(args.productfile, 'w')
680            else:
681                print('#Product XML:')
682            print('#')
683            print('#' * 80)
684            create_permission_file(
685                tool_resources,
686                tool_resources.product_privapp_apks,
687                tool_resources.product_permissions_dir,
688                tool_resources.product_sysconfig_dir,
689                product_permission_file)
690            if args.writetodisk:
691                product_permission_file.close()
692    except MissingResourceError as e:
693        print(str(e), file=sys.stderr)
694        exit(1)
695    finally:
696        cleanup()
697