chiark / gitweb /
dce0180717eeca8ef8ca649f6dd9efb34384e68e
[fdroidserver.git] / fdroidserver / scanner.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # scanner.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import os
21 import re
22 import traceback
23 from argparse import ArgumentParser
24 import logging
25
26 import common
27 import metadata
28 from common import BuildException, VCSException
29
30 config = None
31 options = None
32
33
34 def init_mime_type():
35     '''
36     There are two incompatible versions of the 'magic' module, one
37     that comes as part of libmagic, which is what Debian includes as
38     python-magic, then another called python-magic that is a separate
39     project that wraps libmagic.  The second is 'magic' on pypi, so
40     both need to be supported.  Then on platforms where libmagic is
41     not easily included, e.g. OSX and Windows, fallback to the
42     built-in 'mimetypes' module so this will work without
43     libmagic. Hence this function with the following hacks:
44     '''
45
46     init_path = ''
47     method = ''
48     ms = None
49
50     def mime_from_file(path):
51         try:
52             return magic.from_file(path, mime=True)
53         except UnicodeError:
54             return None
55
56     def mime_file(path):
57         try:
58             return ms.file(path)
59         except UnicodeError:
60             return None
61
62     def mime_guess_type(path):
63         return mimetypes.guess_type(path, strict=False)
64
65     try:
66         import magic
67         try:
68             ms = magic.open(magic.MIME_TYPE)
69             ms.load()
70             magic.from_file(init_path, mime=True)
71             method = 'from_file'
72         except AttributeError:
73             ms.file(init_path)
74             method = 'file'
75     except ImportError:
76         import mimetypes
77         mimetypes.init()
78         method = 'guess_type'
79
80     logging.info("Using magic method " + method)
81     if method == 'from_file':
82         return mime_from_file
83     if method == 'file':
84         return mime_file
85     if method == 'guess_type':
86         return mime_guess_type
87
88     logging.critical("unknown magic method!")
89
90
91 # Scan the source code in the given directory (and all subdirectories)
92 # and return the number of fatal problems encountered
93 def scan_source(build_dir, root_dir, thisbuild):
94
95     count = 0
96
97     # Common known non-free blobs (always lower case):
98     usual_suspects = [
99         re.compile(r'.*flurryagent', re.IGNORECASE),
100         re.compile(r'.*paypal.*mpl', re.IGNORECASE),
101         re.compile(r'.*google.*analytics', re.IGNORECASE),
102         re.compile(r'.*admob.*sdk.*android', re.IGNORECASE),
103         re.compile(r'.*google.*ad.*view', re.IGNORECASE),
104         re.compile(r'.*google.*admob', re.IGNORECASE),
105         re.compile(r'.*google.*play.*services', re.IGNORECASE),
106         re.compile(r'.*crittercism', re.IGNORECASE),
107         re.compile(r'.*heyzap', re.IGNORECASE),
108         re.compile(r'.*jpct.*ae', re.IGNORECASE),
109         re.compile(r'.*youtube.*android.*player.*api', re.IGNORECASE),
110         re.compile(r'.*bugsense', re.IGNORECASE),
111         re.compile(r'.*crashlytics', re.IGNORECASE),
112         re.compile(r'.*ouya.*sdk', re.IGNORECASE),
113         re.compile(r'.*libspen23', re.IGNORECASE),
114     ]
115
116     scanignore = common.getpaths(build_dir, thisbuild, 'scanignore')
117     scandelete = common.getpaths(build_dir, thisbuild, 'scandelete')
118
119     scanignore_worked = set()
120     scandelete_worked = set()
121
122     def toignore(fd):
123         for p in scanignore:
124             if fd.startswith(p):
125                 scanignore_worked.add(p)
126                 return True
127         return False
128
129     def todelete(fd):
130         for p in scandelete:
131             if fd.startswith(p):
132                 scandelete_worked.add(p)
133                 return True
134         return False
135
136     def ignoreproblem(what, fd, fp):
137         logging.info('Ignoring %s at %s' % (what, fd))
138         return 0
139
140     def removeproblem(what, fd, fp):
141         logging.info('Removing %s at %s' % (what, fd))
142         os.remove(fp)
143         return 0
144
145     def warnproblem(what, fd):
146         logging.warn('Found %s at %s' % (what, fd))
147
148     def handleproblem(what, fd, fp):
149         if toignore(fd):
150             return ignoreproblem(what, fd, fp)
151         if todelete(fd):
152             return removeproblem(what, fd, fp)
153         logging.error('Found %s at %s' % (what, fd))
154         return 1
155
156     get_mime_type = init_mime_type()
157
158     # Iterate through all files in the source code
159     for r, d, f in os.walk(build_dir, topdown=True):
160
161         # It's topdown, so checking the basename is enough
162         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
163             if ignoredir in d:
164                 d.remove(ignoredir)
165
166         for curfile in f:
167
168             # Path (relative) to the file
169             fp = os.path.join(r, curfile)
170             fd = fp[len(build_dir) + 1:]
171
172             mime = get_mime_type(fp)
173
174             if mime == 'application/x-sharedlib':
175                 count += handleproblem('shared library', fd, fp)
176
177             elif mime == 'application/x-archive':
178                 count += handleproblem('static library', fd, fp)
179
180             elif mime == 'application/x-executable' or mime == 'application/x-mach-binary':
181                 count += handleproblem('binary executable', fd, fp)
182
183             elif mime == 'application/x-java-applet':
184                 count += handleproblem('Java compiled class', fd, fp)
185
186             elif mime in (
187                     'application/jar',
188                     'application/zip',
189                     'application/java-archive',
190                     'application/octet-stream',
191                     'binary', ):
192
193                 if common.has_extension(fp, 'apk'):
194                     removeproblem('APK file', fd, fp)
195
196                 elif common.has_extension(fp, 'jar'):
197
198                     if any(suspect.match(curfile) for suspect in usual_suspects):
199                         count += handleproblem('usual supect', fd, fp)
200                     else:
201                         warnproblem('JAR file', fd)
202
203                 elif common.has_extension(fp, 'zip'):
204                     warnproblem('ZIP file', fd)
205
206                 else:
207                     warnproblem('unknown compressed or binary file', fd)
208
209             elif common.has_extension(fp, 'java'):
210                 if not os.path.isfile(fp):
211                     continue
212                 for line in file(fp):
213                     if 'DexClassLoader' in line:
214                         count += handleproblem('DexClassLoader', fd, fp)
215                         break
216
217             elif common.has_extension(fp, 'gradle'):
218                 if not os.path.isfile(fp):
219                     continue
220                 for i, line in enumerate(file(fp)):
221                     i = i + 1
222                     if any(suspect.match(line) for suspect in usual_suspects):
223                         count += handleproblem('usual suspect at line %d' % i, fd, fp)
224                         break
225
226     for p in scanignore:
227         if p not in scanignore_worked:
228             logging.error('Unused scanignore path: %s' % p)
229             count += 1
230
231     for p in scandelete:
232         if p not in scandelete_worked:
233             logging.error('Unused scandelete path: %s' % p)
234             count += 1
235
236     # Presence of a jni directory without buildjni=yes might
237     # indicate a problem (if it's not a problem, explicitly use
238     # buildjni=no to bypass this check)
239     if (os.path.exists(os.path.join(root_dir, 'jni')) and
240             not thisbuild['buildjni']):
241         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
242         count += 1
243
244     return count
245
246
247 def main():
248
249     global config, options
250
251     # Parse command line...
252     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
253     common.setup_global_opts(parser)
254     parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
255     options = parser.parse_args()
256
257     config = common.read_config(options)
258
259     # Read all app and srclib metadata
260     allapps = metadata.read_metadata()
261     apps = common.read_app_args(options.appid, allapps, True)
262
263     probcount = 0
264
265     build_dir = 'build'
266     if not os.path.isdir(build_dir):
267         logging.info("Creating build directory")
268         os.makedirs(build_dir)
269     srclib_dir = os.path.join(build_dir, 'srclib')
270     extlib_dir = os.path.join(build_dir, 'extlib')
271
272     for appid, app in apps.iteritems():
273
274         if app['Disabled']:
275             logging.info("Skipping %s: disabled" % appid)
276             continue
277         if not app['builds']:
278             logging.info("Skipping %s: no builds specified" % appid)
279             continue
280
281         logging.info("Processing " + appid)
282
283         try:
284
285             if app['Repo Type'] == 'srclib':
286                 build_dir = os.path.join('build', 'srclib', app['Repo'])
287             else:
288                 build_dir = os.path.join('build', appid)
289
290             # Set up vcs interface and make sure we have the latest code...
291             vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
292
293             for thisbuild in app['builds']:
294
295                 if thisbuild['disable']:
296                     logging.info("...skipping version %s - %s" % (
297                         thisbuild['version'], thisbuild.get('disable', thisbuild['commit'][1:])))
298                 else:
299                     logging.info("...scanning version " + thisbuild['version'])
300
301                     # Prepare the source code...
302                     root_dir, _ = common.prepare_source(vcs, app, thisbuild,
303                                                         build_dir, srclib_dir,
304                                                         extlib_dir, False)
305
306                     # Do the scan...
307                     count = scan_source(build_dir, root_dir, thisbuild)
308                     if count > 0:
309                         logging.warn('Scanner found %d problems in %s (%s)' % (
310                             count, appid, thisbuild['vercode']))
311                         probcount += count
312
313         except BuildException as be:
314             logging.warn("Could not scan app %s due to BuildException: %s" % (
315                 appid, be))
316             probcount += 1
317         except VCSException as vcse:
318             logging.warn("VCS error while scanning app %s: %s" % (appid, vcse))
319             probcount += 1
320         except Exception:
321             logging.warn("Could not scan app %s due to unknown error: %s" % (
322                 appid, traceback.format_exc()))
323             probcount += 1
324
325     logging.info("Finished:")
326     print "%d app(s) with problems" % probcount
327
328 if __name__ == "__main__":
329     main()