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