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