#!/usr/bin/env python
#
# Copyright 2017 - The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import print_function
from xml.dom import minidom
import argparse
import itertools
import os
import re
import subprocess
import sys
import tempfile
import shutil
DEVICE_PREFIX = 'device:'
ANDROID_NAME_REGEX = r'A: android:name\([\S]+\)=\"([\S]+)\"'
ANDROID_PROTECTION_LEVEL_REGEX = \
r'A: android:protectionLevel\([^\)]+\)=\(type [\S]+\)0x([\S]+)'
BASE_XML_FILENAME = 'privapp-permissions-platform.xml'
HELP_MESSAGE = """\
Generates privapp-permissions.xml file for priv-apps.
Usage:
Specify which apk to generate priv-app permissions for. If no apk is \
specified, this will default to all APKs under "<ANDROID_PRODUCT_OUT>/ \
system/priv-app".
Examples:
For all APKs under $ANDROID_PRODUCT_OUT:
# If the build environment has not been set up, do so:
. build/envsetup.sh
lunch product_name
m -j32
# then use:
cd development/tools/privapp_permissions/
./privapp_permissions.py
For a given apk:
./privapp_permissions.py path/to/the.apk
For an APK already on the device:
./privapp_permissions.py device:/device/path/to/the.apk
For all APKs on a device:
./privapp_permissions.py -d
# or if more than one device is attached
./privapp_permissions.py -s <ANDROID_SERIAL>\
"""
# An array of all generated temp directories.
temp_dirs = []
# An array of all generated temp files.
temp_files = []
class MissingResourceError(Exception):
"""Raised when a dependency cannot be located."""
class Adb(object):
"""A small wrapper around ADB calls."""
def __init__(self, path, serial=None):
self.path = path
self.serial = serial
def pull(self, src, dst=None):
"""A wrapper for `adb -s <SERIAL> pull <src> <dst>`.
Args:
src: The source path on the device
dst: The destination path on the host
Throws:
subprocess.CalledProcessError upon pull failure.
"""
if not dst:
if self.call('shell \'if [ -d "%s" ]; then echo True; fi\'' % src):
dst = tempfile.mkdtemp()
temp_dirs.append(dst)
else:
_, dst = tempfile.mkstemp()
temp_files.append(dst)
self.call('pull %s %s' % (src, dst))
return dst
def call(self, cmdline):
"""Calls an adb command.
Throws:
subprocess.CalledProcessError upon command failure.
"""
command = '%s -s %s %s' % (self.path, self.serial, cmdline)
return get_output(command)
class Aapt(object):
def __init__(self, path):
self.path = path
def call(self, arguments):
"""Run an aapt command with the given args.
Args:
arguments: a list of string arguments
Returns:
The output of the aapt command as a string.
"""
output = subprocess.check_output([self.path] + arguments,
stderr=subprocess.STDOUT)
return output.decode(encoding='UTF-8')
class Resources(object):
"""A class that contains the resources needed to generate permissions.
Attributes:
adb: A wrapper class around ADB with a default serial. Only needed when
using -d, -s, or "device:"
_aapt_path: The path to aapt.
"""
def __init__(self, adb_path=None, aapt_path=None, use_device=None,
serial=None, apks=None):
self.adb = Resources._resolve_adb(adb_path)
self.aapt = Resources._resolve_aapt(aapt_path)
self._is_android_env = 'ANDROID_PRODUCT_OUT' in os.environ and \
'ANDROID_HOST_OUT' in os.environ
use_device = use_device or serial or \
(apks and DEVICE_PREFIX in '&'.join(apks))
self.adb.serial = self._resolve_serial(use_device, serial)
if self.adb.serial:
self.adb.call('root')
self.adb.call('wait-for-device')
if self.adb.serial is None and not self._is_android_env:
raise MissingResourceError(
'You must either set up your build environment, or specify a '
'device to run against. See --help for more info.')
self.privapp_apks = self._resolve_apks(apks)
self.permissions_dir = self._resolve_sys_path('system/etc/permissions')
self.sysconfig_dir = self._resolve_sys_path('system/etc/sysconfig')
self.framework_res_apk = self._resolve_sys_path('system/framework/'
'framework-res.apk')
@staticmethod
def _resolve_adb(adb_path):
"""Resolves ADB from either the cmdline argument or the os environment.
Args:
adb_path: The argument passed in for adb. Can be None.
Returns:
An Adb object.
Raises:
MissingResourceError if adb cannot be resolved.
"""
if adb_path:
if os.path.isfile(adb_path):
adb = adb_path
else:
raise MissingResourceError('Cannot resolve adb: No such file '
'"%s" exists.' % adb_path)
else:
try:
adb = get_output('which adb').strip()
except subprocess.CalledProcessError as e:
print('Cannot resolve adb: ADB does not exist within path. '
'Did you forget to setup the build environment or set '
'--adb?',
file=sys.stderr)
raise MissingResourceError(e)
# Start the adb server immediately so server daemon startup
# does not get added to the output of subsequent adb calls.
try:
get_output('%s start-server' % adb)
return Adb(adb)
except:
print('Unable to reach adb server daemon.', file=sys.stderr)
raise
@staticmethod
def _resolve_aapt(aapt_path):
"""Resolves AAPT from either the cmdline argument or the os environment.
Returns:
An Aapt Object
"""
if aapt_path:
if os.path.isfile(aapt_path):
return Aapt(aapt_path)
else:
raise MissingResourceError('Cannot resolve aapt: No such file '
'%s exists.' % aapt_path)
else:
try:
return Aapt(get_output('which aapt').strip())
except subprocess.CalledProcessError:
print('Cannot resolve aapt: AAPT does not exist within path. '
'Did you forget to setup the build environment or set '
'--aapt?',
file=sys.stderr)
raise
def _resolve_serial(self, device, serial):
"""Resolves the serial used for device files or generating permissions.
Returns:
If -s/--serial is specified, it will return that serial.
If -d or device: is found, it will grab the only available device.
If there are multiple devices, it will use $ANDROID_SERIAL.
Raises:
MissingResourceError if the resolved serial would not be usable.
subprocess.CalledProcessError if a command error occurs.
"""
if device:
if serial:
try:
output = get_output('%s -s %s get-state' %
(self.adb.path, serial))
except subprocess.CalledProcessError:
raise MissingResourceError(
'Received error when trying to get the state of '
'device with serial "%s". Is it connected and in '
'device mode?' % serial)
if 'device' not in output:
raise MissingResourceError(
'Device "%s" is not in device mode. Reboot the phone '
'into device mode and try again.' % serial)
return serial
elif 'ANDROID_SERIAL' in os.environ:
serial = os.environ['ANDROID_SERIAL']
command = '%s -s %s get-state' % (self.adb, serial)
try:
output = get_output(command)
except subprocess.CalledProcessError:
raise MissingResourceError(
'Device with serial $ANDROID_SERIAL ("%s") not '
'found.' % serial)
if 'device' in output:
return serial
raise MissingResourceError(
'Device with serial $ANDROID_SERIAL ("%s") was '
'found, but was not in the "device" state.')
# Parses `adb devices` so it only returns a string of serials.
get_serials_cmd = ('%s devices | tail -n +2 | head -n -1 | '
'cut -f1' % self.adb.path)
try:
output = get_output(get_serials_cmd)
# If multiple serials appear in the output, raise an error.
if len(output.split()) > 1:
raise MissingResourceError(
'Multiple devices are connected. You must specify '
'which device to run against with flag --serial.')
return output.strip()
except subprocess.CalledProcessError:
print('Unexpected error when querying for connected '
'devices.', file=sys.stderr)
raise
def _resolve_apks(self, apks):
"""Resolves all APKs to run against.
Returns:
If no apk is specified in the arguments, return all apks in
system/priv-app. Otherwise, returns a list with the specified apk.
Throws:
MissingResourceError if the specified apk or system/priv-app cannot
be found.
"""
if not apks:
return self._resolve_all_privapps()
ret_apks = []
for apk in apks:
if apk.startswith(DEVICE_PREFIX):
device_apk = apk[len(DEVICE_PREFIX):]
try:
apk = self.adb.pull(device_apk)
except subprocess.CalledProcessError:
raise MissingResourceError(
'File "%s" could not be located on device "%s".' %
(device_apk, self.adb.serial))
ret_apks.append(apk)
elif not os.path.isfile(apk):
raise MissingResourceError('File "%s" does not exist.' % apk)
else:
ret_apks.append(apk)
return ret_apks
def _resolve_all_privapps(self):
"""Extract package name and requested permissions."""
if self._is_android_env:
priv_app_dir = os.path.join(os.environ['ANDROID_PRODUCT_OUT'],
'system/priv-app')
else:
try:
priv_app_dir = self.adb.pull('/system/priv-app/')
except subprocess.CalledProcessError:
raise MissingResourceError(
'Directory "/system/priv-app" could not be pulled from on '
'device "%s".' % self.adb.serial)
return get_output('find %s -name "*.apk"' % priv_app_dir).split()
def _resolve_sys_path(self, file_path):
"""Resolves a path that is a part of an Android System Image."""
if self._is_android_env:
return os.path.join(os.environ['ANDROID_PRODUCT_OUT'], file_path)
else:
return self.adb.pull(file_path)
def get_output(command):
"""Returns the output of the command as a string.
Throws:
subprocess.CalledProcessError if exit status is non-zero.
"""
output = subprocess.check_output(command, shell=True)
# For Python3.4, decode the byte string so it is usable.
return output.decode(encoding='UTF-8')
def parse_args():
"""Parses the CLI."""
parser = argparse.ArgumentParser(
description=HELP_MESSAGE,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
'-d',
'--device',
action='store_true',
default=False,
required=False,
help='Whether or not to generate the privapp_permissions file for the '
'build already on a device. See -s/--serial below for more '
'details.'
)
parser.add_argument(
'--adb',
type=str,
required=False,
metavar='<ADB_PATH',
help='Path to adb. If none specified, uses the environment\'s adb.'
)
parser.add_argument(
'--aapt',
type=str,
required=False,
metavar='<AAPT_PATH>',
help='Path to aapt. If none specified, uses the environment\'s aapt.'
)
parser.add_argument(
'-s',
'--serial',
type=str,
required=False,
metavar='<SERIAL>',
help='The serial of the device to generate permissions for. If no '
'serial is given, it will pick the only device connected over '
'adb. If multiple devices are found, it will default to '
'$ANDROID_SERIAL. Otherwise, the program will exit with error '
'code 1. If -s is given, -d is not needed.'
)
parser.add_argument(
'apks',
nargs='*',
type=str,
help='A list of paths to priv-app APKs to generate permissions for. '
'To make a path device-side, prefix the path with "device:".'
)
cmd_args = parser.parse_args()
return cmd_args
def create_permission_file(resources):
# Parse base XML files in /etc dir, permissions listed there don't have
# to be re-added
base_permissions = {}
base_xml_files = itertools.chain(list_xml_files(resources.permissions_dir),
list_xml_files(resources.sysconfig_dir))
for xml_file in base_xml_files:
parse_config_xml(xml_file, base_permissions)
priv_permissions = extract_priv_permissions(resources.aapt,
resources.framework_res_apk)
apps_redefine_base = []
results = {}
for priv_app in resources.privapp_apks:
pkg_info = extract_pkg_and_requested_permissions(resources.aapt,
priv_app)
pkg_name = pkg_info['package_name']
priv_perms = get_priv_permissions(pkg_info['permissions'],
priv_permissions)
# Compute diff against permissions defined in base file
if base_permissions and (pkg_name in base_permissions):
base_permissions_pkg = base_permissions[pkg_name]
priv_perms = remove_base_permissions(priv_perms,
base_permissions_pkg)
if priv_perms:
apps_redefine_base.append(pkg_name)
if priv_perms:
results[pkg_name] = sorted(priv_perms)
print_xml(results, apps_redefine_base)
def print_xml(results, apps_redefine_base, fd=sys.stdout):
"""Print results to the given file."""
fd.write('<?xml version="1.0" encoding="utf-8"?>\n<permissions>\n')
for package_name in sorted(results):
if package_name in apps_redefine_base:
fd.write(' <!-- Additional permissions on top of %s -->\n' %
BASE_XML_FILENAME)
fd.write(' <privapp-permissions package="%s">\n' % package_name)
for p in results[package_name]:
fd.write(' <permission name="%s"/>\n' % p)
fd.write(' </privapp-permissions>\n')
fd.write('\n')
fd.write('</permissions>\n')
def remove_base_permissions(priv_perms, base_perms):
"""Removes set of base_perms from set of priv_perms."""
if (not priv_perms) or (not base_perms):
return priv_perms
return set(priv_perms) - set(base_perms)
def get_priv_permissions(requested_perms, priv_perms):
"""Return only permissions that are in priv_perms set."""
return set(requested_perms).intersection(set(priv_perms))
def list_xml_files(directory):
"""Returns a list of all .xml files within a given directory.
Args:
directory: the directory to look for xml files in.
"""
xml_files = []
for dirName, subdirList, file_list in os.walk(directory):
for file in file_list:
if file.endswith('.xml'):
file_path = os.path.join(dirName, file)
xml_files.append(file_path)
return xml_files
def extract_pkg_and_requested_permissions(aapt, apk_path):
"""
Extract package name and list of requested permissions from the
dump of manifest file
"""
aapt_args = ['d', 'permissions', apk_path]
txt = aapt.call(aapt_args)
permissions = []
package_name = None
raw_lines = txt.split('\n')
for line in raw_lines:
regex = r"uses-permission.*: name='([\S]+)'"
matches = re.search(regex, line)
if matches:
name = matches.group(1)
permissions.append(name)
regex = r'package: ([\S]+)'
matches = re.search(regex, line)
if matches:
package_name = matches.group(1)
return {'package_name': package_name, 'permissions': permissions}
def extract_priv_permissions(aapt, apk_path):
"""Extract signature|privileged permissions from dump of manifest file."""
aapt_args = ['d', 'xmltree', apk_path, 'AndroidManifest.xml']
txt = aapt.call(aapt_args)
raw_lines = txt.split('\n')
n = len(raw_lines)
i = 0
permissions_list = []
while i < n:
line = raw_lines[i]
if line.find('E: permission (') != -1:
i += 1
name = None
level = None
while i < n:
line = raw_lines[i]
if line.find('E: ') != -1:
break
matches = re.search(ANDROID_NAME_REGEX, line)
if matches:
name = matches.group(1)
i += 1
continue
matches = re.search(ANDROID_PROTECTION_LEVEL_REGEX, line)
if matches:
level = int(matches.group(1), 16)
i += 1
continue
i += 1
if name and level and level & 0x12 == 0x12:
permissions_list.append(name)
else:
i += 1
return permissions_list
def parse_config_xml(base_xml, results):
"""Parse an XML file that will be used as base."""
dom = minidom.parse(base_xml)
nodes = dom.getElementsByTagName('privapp-permissions')
for node in nodes:
permissions = (node.getElementsByTagName('permission') +
node.getElementsByTagName('deny-permission'))
package_name = node.getAttribute('package')
plist = []
if package_name in results:
plist = results[package_name]
for p in permissions:
perm_name = p.getAttribute('name')
if perm_name:
plist.append(perm_name)
results[package_name] = plist
return results
def cleanup():
"""Cleans up temp files."""
for directory in temp_dirs:
shutil.rmtree(directory, ignore_errors=True)
for file in temp_files:
os.remove(file)
del temp_dirs[:]
del temp_files[:]
if __name__ == '__main__':
args = parse_args()
try:
tool_resources = Resources(
aapt_path=args.aapt,
adb_path=args.adb,
use_device=args.device,
serial=args.serial,
apks=args.apks
)
create_permission_file(tool_resources)
except MissingResourceError as e:
print(str(e), file=sys.stderr)
exit(1)
finally:
cleanup()