chiark / gitweb /
3a87c003f0cd8a67915571fc67bbf2c89351e67e
[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             # These files are often found - avoid checking if they are binary
164             # to speed up the scanner
165             elif ext in ['xml', 'md', 'txt', 'html', 'sh', 'png']:
166                 pass
167
168             elif is_binary(fp):
169                 if is_executable(fp):
170                     count += handleproblem('executable binary', fd, fp)
171                 elif ext == '':
172                     count += handleproblem('unknown binary', fd, fp)
173
174     for p in scanignore:
175         if p not in scanignore_worked:
176             logging.error('Unused scanignore path: %s' % p)
177             count += 1
178
179     for p in scandelete:
180         if p not in scandelete_worked:
181             logging.error('Unused scandelete path: %s' % p)
182             count += 1
183
184     # Presence of a jni directory without buildjni=yes might
185     # indicate a problem (if it's not a problem, explicitly use
186     # buildjni=no to bypass this check)
187     if (os.path.exists(os.path.join(root_dir, 'jni')) and
188             not thisbuild['buildjni']):
189         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
190         count += 1
191
192     return count
193
194
195 def main():
196
197     global config, options
198
199     # Parse command line...
200     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
201     common.setup_global_opts(parser)
202     parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
203     options = parser.parse_args()
204
205     config = common.read_config(options)
206
207     # Read all app and srclib metadata
208     allapps = metadata.read_metadata()
209     apps = common.read_app_args(options.appid, allapps, True)
210
211     probcount = 0
212
213     build_dir = 'build'
214     if not os.path.isdir(build_dir):
215         logging.info("Creating build directory")
216         os.makedirs(build_dir)
217     srclib_dir = os.path.join(build_dir, 'srclib')
218     extlib_dir = os.path.join(build_dir, 'extlib')
219
220     for appid, app in apps.iteritems():
221
222         if app['Disabled']:
223             logging.info("Skipping %s: disabled" % appid)
224             continue
225         if not app['builds']:
226             logging.info("Skipping %s: no builds specified" % appid)
227             continue
228
229         logging.info("Processing " + appid)
230
231         try:
232
233             if app['Repo Type'] == 'srclib':
234                 build_dir = os.path.join('build', 'srclib', app['Repo'])
235             else:
236                 build_dir = os.path.join('build', appid)
237
238             # Set up vcs interface and make sure we have the latest code...
239             vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
240
241             for thisbuild in app['builds']:
242
243                 if thisbuild['disable']:
244                     logging.info("...skipping version %s - %s" % (
245                         thisbuild['version'], thisbuild.get('disable', thisbuild['commit'][1:])))
246                 else:
247                     logging.info("...scanning version " + thisbuild['version'])
248
249                     # Prepare the source code...
250                     root_dir, _ = common.prepare_source(vcs, app, thisbuild,
251                                                         build_dir, srclib_dir,
252                                                         extlib_dir, False)
253
254                     # Do the scan...
255                     count = scan_source(build_dir, root_dir, thisbuild)
256                     if count > 0:
257                         logging.warn('Scanner found %d problems in %s (%s)' % (
258                             count, appid, thisbuild['vercode']))
259                         probcount += count
260
261         except BuildException as be:
262             logging.warn("Could not scan app %s due to BuildException: %s" % (
263                 appid, be))
264             probcount += 1
265         except VCSException as vcse:
266             logging.warn("VCS error while scanning app %s: %s" % (appid, vcse))
267             probcount += 1
268         except Exception:
269             logging.warn("Could not scan app %s due to unknown error: %s" % (
270                 appid, traceback.format_exc()))
271             probcount += 1
272
273     logging.info("Finished:")
274     print "%d problems found" % probcount
275
276 if __name__ == "__main__":
277     main()