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