chiark / gitweb /
Merge branch 'docs' into 'master'
[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 get_gradle_compile_commands(thisbuild):
35     compileCommands = ['compile', 'releaseCompile']
36     if thisbuild['gradle'] != ['yes']:
37         compileCommands += [flavor + 'Compile' for flavor in thisbuild['gradle']]
38         compileCommands += [flavor + 'ReleaseCompile' for flavor in thisbuild['gradle']]
39
40     return [re.compile(r'\s*' + c, re.IGNORECASE) for c in compileCommands]
41
42
43 # Scan the source code in the given directory (and all subdirectories)
44 # and return the number of fatal problems encountered
45 def scan_source(build_dir, root_dir, thisbuild):
46
47     count = 0
48
49     # Common known non-free blobs (always lower case):
50     usual_suspects = {
51         exp: re.compile(r'.*' + exp, re.IGNORECASE) for exp in [
52             r'flurryagent',
53             r'paypal.*mpl',
54             r'google.*analytics',
55             r'admob.*sdk.*android',
56             r'google.*ad.*view',
57             r'google.*admob',
58             r'google.*play.*services',
59             r'crittercism',
60             r'heyzap',
61             r'jpct.*ae',
62             r'youtube.*android.*player.*api',
63             r'bugsense',
64             r'crashlytics',
65             r'ouya.*sdk',
66             r'libspen23',
67         ]
68     }
69
70     def suspects_found(s):
71         for n, r in usual_suspects.iteritems():
72             if r.match(s):
73                 yield n
74
75     scanignore = common.getpaths(build_dir, thisbuild, 'scanignore')
76     scandelete = common.getpaths(build_dir, thisbuild, 'scandelete')
77
78     scanignore_worked = set()
79     scandelete_worked = set()
80
81     def toignore(fd):
82         for p in scanignore:
83             if fd.startswith(p):
84                 scanignore_worked.add(p)
85                 return True
86         return False
87
88     def todelete(fd):
89         for p in scandelete:
90             if fd.startswith(p):
91                 scandelete_worked.add(p)
92                 return True
93         return False
94
95     def ignoreproblem(what, fd, fp):
96         logging.info('Ignoring %s at %s' % (what, fd))
97         return 0
98
99     def removeproblem(what, fd, fp):
100         logging.info('Removing %s at %s' % (what, fd))
101         os.remove(fp)
102         return 0
103
104     def warnproblem(what, fd):
105         logging.warn('Found %s at %s' % (what, fd))
106
107     def handleproblem(what, fd, fp):
108         if toignore(fd):
109             return ignoreproblem(what, fd, fp)
110         if todelete(fd):
111             return removeproblem(what, fd, fp)
112         logging.error('Found %s at %s' % (what, fd))
113         return 1
114
115     def is_executable(path):
116         return os.path.exists(path) and os.access(path, os.X_OK)
117
118     textchars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7f})
119
120     def is_binary(path):
121         d = None
122         with open(path, 'rb') as f:
123             d = f.read(1024)
124         return bool(d.translate(None, textchars))
125
126     gradle_compile_commands = get_gradle_compile_commands(thisbuild)
127
128     def is_used_by_gradle(line):
129         return any(command.match(line) for command in gradle_compile_commands)
130
131     # Iterate through all files in the source code
132     for r, d, f in os.walk(build_dir, topdown=True):
133
134         # It's topdown, so checking the basename is enough
135         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
136             if ignoredir in d:
137                 d.remove(ignoredir)
138
139         for curfile in f:
140
141             # Path (relative) to the file
142             fp = os.path.join(r, curfile)
143             fd = fp[len(build_dir) + 1:]
144
145             ext = common.get_extension(fd)
146
147             if ext == 'so':
148                 count += handleproblem('shared library', fd, fp)
149             elif ext == 'a':
150                 count += handleproblem('static library', fd, fp)
151             elif ext == 'class':
152                 count += handleproblem('Java compiled class', fd, fp)
153             elif ext == 'apk':
154                 removeproblem('APK file', fd, fp)
155
156             elif ext == 'jar':
157                 for name in suspects_found(curfile):
158                     count += handleproblem('usual supect \'%s\'' % name, fd, fp)
159                 warnproblem('JAR file', fd)
160
161             elif ext == 'java':
162                 if not os.path.isfile(fp):
163                     continue
164                 for line in file(fp):
165                     if 'DexClassLoader' in line:
166                         count += handleproblem('DexClassLoader', fd, fp)
167                         break
168
169             elif ext == 'gradle':
170                 if not os.path.isfile(fp):
171                     continue
172                 for i, line in enumerate(file(fp)):
173                     i = i + 1
174                     if is_used_by_gradle(line):
175                         for name in suspects_found(line):
176                             count += handleproblem('usual supect \'%s\' at line %d' % (name, i), fd, fp)
177
178             elif ext in ['', 'bin', 'out', 'exe']:
179                 if is_binary(fp):
180                     count += handleproblem('binary', fd, fp)
181
182             elif is_executable(fp):
183                 if is_binary(fp):
184                     warnproblem('possible binary', fd)
185
186     for p in scanignore:
187         if p not in scanignore_worked:
188             logging.error('Unused scanignore path: %s' % p)
189             count += 1
190
191     for p in scandelete:
192         if p not in scandelete_worked:
193             logging.error('Unused scandelete path: %s' % p)
194             count += 1
195
196     # Presence of a jni directory without buildjni=yes might
197     # indicate a problem (if it's not a problem, explicitly use
198     # buildjni=no to bypass this check)
199     if (os.path.exists(os.path.join(root_dir, 'jni')) and
200             not thisbuild['buildjni']):
201         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
202         count += 1
203
204     return count
205
206
207 def main():
208
209     global config, options
210
211     # Parse command line...
212     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
213     common.setup_global_opts(parser)
214     parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
215     options = parser.parse_args()
216
217     config = common.read_config(options)
218
219     # Read all app and srclib metadata
220     allapps = metadata.read_metadata()
221     apps = common.read_app_args(options.appid, allapps, True)
222
223     probcount = 0
224
225     build_dir = 'build'
226     if not os.path.isdir(build_dir):
227         logging.info("Creating build directory")
228         os.makedirs(build_dir)
229     srclib_dir = os.path.join(build_dir, 'srclib')
230     extlib_dir = os.path.join(build_dir, 'extlib')
231
232     for appid, app in apps.iteritems():
233
234         if app['Disabled']:
235             logging.info("Skipping %s: disabled" % appid)
236             continue
237         if not app['builds']:
238             logging.info("Skipping %s: no builds specified" % appid)
239             continue
240
241         logging.info("Processing " + appid)
242
243         try:
244
245             if app['Repo Type'] == 'srclib':
246                 build_dir = os.path.join('build', 'srclib', app['Repo'])
247             else:
248                 build_dir = os.path.join('build', appid)
249
250             # Set up vcs interface and make sure we have the latest code...
251             vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
252
253             for thisbuild in app['builds']:
254
255                 if thisbuild['disable']:
256                     logging.info("...skipping version %s - %s" % (
257                         thisbuild['version'], thisbuild.get('disable', thisbuild['commit'][1:])))
258                 else:
259                     logging.info("...scanning version " + thisbuild['version'])
260
261                     # Prepare the source code...
262                     root_dir, _ = common.prepare_source(vcs, app, thisbuild,
263                                                         build_dir, srclib_dir,
264                                                         extlib_dir, False)
265
266                     # Do the scan...
267                     count = scan_source(build_dir, root_dir, thisbuild)
268                     if count > 0:
269                         logging.warn('Scanner found %d problems in %s (%s)' % (
270                             count, appid, thisbuild['vercode']))
271                         probcount += count
272
273         except BuildException as be:
274             logging.warn("Could not scan app %s due to BuildException: %s" % (
275                 appid, be))
276             probcount += 1
277         except VCSException as vcse:
278             logging.warn("VCS error while scanning app %s: %s" % (appid, vcse))
279             probcount += 1
280         except Exception:
281             logging.warn("Could not scan app %s due to unknown error: %s" % (
282                 appid, traceback.format_exc()))
283             probcount += 1
284
285     logging.info("Finished:")
286     print "%d problems found" % probcount
287
288 if __name__ == "__main__":
289     main()