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