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