chiark / gitweb /
a291794aecf0bca282104661b1da611511a82180
[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'] and 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     gradle_mavenrepo = re.compile(r'maven *{ *(url)? *[\'"]?([^ \'"]*)[\'"]?')
76
77     allowed_repos = [re.compile(r'^https?://' + re.escape(repo) + r'/*') for repo in [
78         'repo1.maven.org/maven2',  # mavenCentral()
79         'jcenter.bintray.com',     # jcenter()
80         'jitpack.io',
81         'oss.sonatype.org/content/repositories/snapshots',
82         'oss.sonatype.org/content/repositories/releases',
83         ]
84     ]
85
86     scanignore = common.getpaths_map(build_dir, thisbuild['scanignore'])
87     scandelete = common.getpaths_map(build_dir, thisbuild['scandelete'])
88
89     scanignore_worked = set()
90     scandelete_worked = set()
91
92     def toignore(fd):
93         for k, paths in scanignore.iteritems():
94             for p in paths:
95                 if fd.startswith(p):
96                     scanignore_worked.add(k)
97                     return True
98         return False
99
100     def todelete(fd):
101         for k, paths in scandelete.iteritems():
102             for p in paths:
103                 if fd.startswith(p):
104                     scandelete_worked.add(k)
105                     return True
106         return False
107
108     def ignoreproblem(what, fd, fp):
109         logging.info('Ignoring %s at %s' % (what, fd))
110         return 0
111
112     def removeproblem(what, fd, fp):
113         logging.info('Removing %s at %s' % (what, fd))
114         os.remove(fp)
115         return 0
116
117     def warnproblem(what, fd):
118         logging.warn('Found %s at %s' % (what, fd))
119
120     def handleproblem(what, fd, fp):
121         if toignore(fd):
122             return ignoreproblem(what, fd, fp)
123         if todelete(fd):
124             return removeproblem(what, fd, fp)
125         logging.error('Found %s at %s' % (what, fd))
126         return 1
127
128     def is_executable(path):
129         return os.path.exists(path) and os.access(path, os.X_OK)
130
131     textchars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7f})
132
133     def is_binary(path):
134         d = None
135         with open(path, 'rb') as f:
136             d = f.read(1024)
137         return bool(d.translate(None, textchars))
138
139     gradle_compile_commands = get_gradle_compile_commands(thisbuild)
140
141     def is_used_by_gradle(line):
142         return any(command.match(line) for command in gradle_compile_commands)
143
144     # Iterate through all files in the source code
145     for r, d, f in os.walk(build_dir, topdown=True):
146
147         # It's topdown, so checking the basename is enough
148         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
149             if ignoredir in d:
150                 d.remove(ignoredir)
151
152         for curfile in f:
153
154             # Path (relative) to the file
155             fp = os.path.join(r, curfile)
156
157             if os.path.islink(fp):
158                 continue
159
160             fd = fp[len(build_dir) + 1:]
161             _, ext = common.get_extension(fd)
162
163             if ext == 'so':
164                 count += handleproblem('shared library', fd, fp)
165             elif ext == 'a':
166                 count += handleproblem('static library', fd, fp)
167             elif ext == 'class':
168                 count += handleproblem('Java compiled class', fd, fp)
169             elif ext == 'apk':
170                 removeproblem('APK file', fd, fp)
171
172             elif ext == 'jar':
173                 for name in suspects_found(curfile):
174                     count += handleproblem('usual supect \'%s\'' % name, fd, fp)
175                 warnproblem('JAR file', fd)
176
177             elif ext == 'java':
178                 if not os.path.isfile(fp):
179                     continue
180                 for line in file(fp):
181                     if 'DexClassLoader' in line:
182                         count += handleproblem('DexClassLoader', fd, fp)
183                         break
184
185             elif ext == 'gradle':
186                 if not os.path.isfile(fp):
187                     continue
188                 with open(fp, 'r') as f:
189                     lines = f.readlines()
190                 for i, line in enumerate(lines):
191                     if is_used_by_gradle(line):
192                         for name in suspects_found(line):
193                             count += handleproblem('usual supect \'%s\' at line %d' % (name, i+1), fd, fp)
194                 noncomment_lines = [l for l in lines if not common.gradle_comment.match(l)]
195                 joined = re.sub(r'[\n\r\s]+', ' ', ' '.join(noncomment_lines))
196                 for m in gradle_mavenrepo.finditer(joined):
197                     url = m.group(2)
198                     if not any(r.match(url) for r in allowed_repos):
199                         count += handleproblem('unknown maven repo \'%s\'' % url, fd, fp)
200
201             elif ext in ['', 'bin', 'out', 'exe']:
202                 if is_binary(fp):
203                     count += handleproblem('binary', fd, fp)
204
205             elif is_executable(fp):
206                 if is_binary(fp):
207                     warnproblem('possible binary', fd)
208
209     for p in scanignore:
210         if p not in scanignore_worked:
211             logging.error('Unused scanignore path: %s' % p)
212             count += 1
213
214     for p in scandelete:
215         if p not in scandelete_worked:
216             logging.error('Unused scandelete path: %s' % p)
217             count += 1
218
219     # Presence of a jni directory without buildjni=yes might
220     # indicate a problem (if it's not a problem, explicitly use
221     # buildjni=no to bypass this check)
222     if (os.path.exists(os.path.join(root_dir, 'jni')) and
223             not thisbuild['buildjni']):
224         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
225         count += 1
226
227     return count
228
229
230 def main():
231
232     global config, options
233
234     # Parse command line...
235     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
236     common.setup_global_opts(parser)
237     parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
238     options = parser.parse_args()
239
240     config = common.read_config(options)
241
242     # Read all app and srclib metadata
243     allapps = metadata.read_metadata()
244     apps = common.read_app_args(options.appid, allapps, True)
245
246     probcount = 0
247
248     build_dir = 'build'
249     if not os.path.isdir(build_dir):
250         logging.info("Creating build directory")
251         os.makedirs(build_dir)
252     srclib_dir = os.path.join(build_dir, 'srclib')
253     extlib_dir = os.path.join(build_dir, 'extlib')
254
255     for appid, app in apps.iteritems():
256
257         if app['Disabled']:
258             logging.info("Skipping %s: disabled" % appid)
259             continue
260         if not app['builds']:
261             logging.info("Skipping %s: no builds specified" % appid)
262             continue
263
264         logging.info("Processing " + appid)
265
266         try:
267
268             if app['Repo Type'] == 'srclib':
269                 build_dir = os.path.join('build', 'srclib', app['Repo'])
270             else:
271                 build_dir = os.path.join('build', appid)
272
273             # Set up vcs interface and make sure we have the latest code...
274             vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
275
276             for thisbuild in app['builds']:
277
278                 if thisbuild['disable']:
279                     logging.info("...skipping version %s - %s" % (
280                         thisbuild['version'], thisbuild.get('disable', thisbuild['commit'][1:])))
281                 else:
282                     logging.info("...scanning version " + thisbuild['version'])
283
284                     # Prepare the source code...
285                     root_dir, _ = common.prepare_source(vcs, app, thisbuild,
286                                                         build_dir, srclib_dir,
287                                                         extlib_dir, False)
288
289                     # Do the scan...
290                     count = scan_source(build_dir, root_dir, thisbuild)
291                     if count > 0:
292                         logging.warn('Scanner found %d problems in %s (%s)' % (
293                             count, appid, thisbuild['vercode']))
294                         probcount += count
295
296         except BuildException as be:
297             logging.warn("Could not scan app %s due to BuildException: %s" % (
298                 appid, be))
299             probcount += 1
300         except VCSException as vcse:
301             logging.warn("VCS error while scanning app %s: %s" % (appid, vcse))
302             probcount += 1
303         except Exception:
304             logging.warn("Could not scan app %s due to unknown error: %s" % (
305                 appid, traceback.format_exc()))
306             probcount += 1
307
308     logging.info("Finished:")
309     print "%d problems found" % probcount
310
311 if __name__ == "__main__":
312     main()