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