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