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