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