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