chiark / gitweb /
scanner: Ignore certain binary executable files
[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     # False positives patterns for files that are binary and executable.
142     safe_paths = [re.compile(r) for r in [
143         r".*/drawable[^/]*/.*\.png$",  # png drawables
144         r".*/mipmap[^/]*/.*\.png$",    # png mipmaps
145         ]
146     ]
147
148     def safe_path(path):
149         for sp in safe_paths:
150             if sp.match(path):
151                 return True
152         return False
153
154     gradle_compile_commands = get_gradle_compile_commands(thisbuild)
155
156     def is_used_by_gradle(line):
157         return any(command.match(line) for command in gradle_compile_commands)
158
159     # Iterate through all files in the source code
160     for r, d, f in os.walk(build_dir, topdown=True):
161
162         # It's topdown, so checking the basename is enough
163         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
164             if ignoredir in d:
165                 d.remove(ignoredir)
166
167         for curfile in f:
168
169             if curfile in ['.DS_Store']:
170                 continue
171
172             # Path (relative) to the file
173             fp = os.path.join(r, curfile)
174
175             if os.path.islink(fp):
176                 continue
177
178             fd = fp[len(build_dir) + 1:]
179             _, ext = common.get_extension(fd)
180
181             if ext == 'so':
182                 count += handleproblem('shared library', fd, fp)
183             elif ext == 'a':
184                 count += handleproblem('static library', fd, fp)
185             elif ext == 'class':
186                 count += handleproblem('Java compiled class', fd, fp)
187             elif ext == 'apk':
188                 removeproblem('APK file', fd, fp)
189
190             elif ext == 'jar':
191                 for name in suspects_found(curfile):
192                     count += handleproblem('usual supect \'%s\'' % name, fd, fp)
193                 warnproblem('JAR file', fd)
194
195             elif ext == 'java':
196                 if not os.path.isfile(fp):
197                     continue
198                 for line in file(fp):
199                     if 'DexClassLoader' in line:
200                         count += handleproblem('DexClassLoader', fd, fp)
201                         break
202
203             elif ext == 'gradle':
204                 if not os.path.isfile(fp):
205                     continue
206                 with open(fp, 'r') as f:
207                     lines = f.readlines()
208                 for i, line in enumerate(lines):
209                     if is_used_by_gradle(line):
210                         for name in suspects_found(line):
211                             count += handleproblem('usual supect \'%s\' at line %d' % (name, i+1), fd, fp)
212                 noncomment_lines = [l for l in lines if not common.gradle_comment.match(l)]
213                 joined = re.sub(r'[\n\r\s]+', ' ', ' '.join(noncomment_lines))
214                 for m in gradle_mavenrepo.finditer(joined):
215                     url = m.group(2)
216                     if not any(r.match(url) for r in allowed_repos):
217                         count += handleproblem('unknown maven repo \'%s\'' % url, fd, fp)
218
219             elif ext in ['', 'bin', 'out', 'exe']:
220                 if is_binary(fp):
221                     count += handleproblem('binary', fd, fp)
222
223             elif is_executable(fp):
224                 if is_binary(fp) and not safe_path(fd):
225                     warnproblem('possible binary', fd)
226
227     for p in scanignore:
228         if p not in scanignore_worked:
229             logging.error('Unused scanignore path: %s' % p)
230             count += 1
231
232     for p in scandelete:
233         if p not in scandelete_worked:
234             logging.error('Unused scandelete path: %s' % p)
235             count += 1
236
237     # Presence of a jni directory without buildjni=yes might
238     # indicate a problem (if it's not a problem, explicitly use
239     # buildjni=no to bypass this check)
240     if (os.path.exists(os.path.join(root_dir, 'jni')) and
241             not thisbuild['buildjni']):
242         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
243         count += 1
244
245     return count
246
247
248 def main():
249
250     global config, options
251
252     # Parse command line...
253     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
254     common.setup_global_opts(parser)
255     parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
256     options = parser.parse_args()
257
258     config = common.read_config(options)
259
260     # Read all app and srclib metadata
261     allapps = metadata.read_metadata()
262     apps = common.read_app_args(options.appid, allapps, True)
263
264     probcount = 0
265
266     build_dir = 'build'
267     if not os.path.isdir(build_dir):
268         logging.info("Creating build directory")
269         os.makedirs(build_dir)
270     srclib_dir = os.path.join(build_dir, 'srclib')
271     extlib_dir = os.path.join(build_dir, 'extlib')
272
273     for appid, app in apps.iteritems():
274
275         if app['Disabled']:
276             logging.info("Skipping %s: disabled" % appid)
277             continue
278         if not app['builds']:
279             logging.info("Skipping %s: no builds specified" % appid)
280             continue
281
282         logging.info("Processing " + appid)
283
284         try:
285
286             if app['Repo Type'] == 'srclib':
287                 build_dir = os.path.join('build', 'srclib', app['Repo'])
288             else:
289                 build_dir = os.path.join('build', appid)
290
291             # Set up vcs interface and make sure we have the latest code...
292             vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
293
294             for thisbuild in app['builds']:
295
296                 if thisbuild['disable']:
297                     logging.info("...skipping version %s - %s" % (
298                         thisbuild['version'], thisbuild.get('disable', thisbuild['commit'][1:])))
299                 else:
300                     logging.info("...scanning version " + thisbuild['version'])
301
302                     # Prepare the source code...
303                     root_dir, _ = common.prepare_source(vcs, app, thisbuild,
304                                                         build_dir, srclib_dir,
305                                                         extlib_dir, False)
306
307                     # Do the scan...
308                     count = scan_source(build_dir, root_dir, thisbuild)
309                     if count > 0:
310                         logging.warn('Scanner found %d problems in %s (%s)' % (
311                             count, appid, thisbuild['vercode']))
312                         probcount += count
313
314         except BuildException as be:
315             logging.warn("Could not scan app %s due to BuildException: %s" % (
316                 appid, be))
317             probcount += 1
318         except VCSException as vcse:
319             logging.warn("VCS error while scanning app %s: %s" % (appid, vcse))
320             probcount += 1
321         except Exception:
322             logging.warn("Could not scan app %s due to unknown error: %s" % (
323                 appid, traceback.format_exc()))
324             probcount += 1
325
326     logging.info("Finished:")
327     print "%d problems found" % probcount
328
329 if __name__ == "__main__":
330     main()