~junaidali/charms/trusty/plumgrid-gateway/analyst_opsvm

« back to all changes in this revision

Viewing changes to hooks/charmhelpers/contrib/hardening/audits/file.py

  • Committer: bbaqar at plumgrid
  • Date: 2016-04-25 09:21:09 UTC
  • mfrom: (26.1.2 plumgrid-gateway)
  • Revision ID: bbaqar@plumgrid.com-20160425092109-kweey25bx97pmj80
Merge: Liberty/Mitaka support

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright 2016 Canonical Limited.
 
2
#
 
3
# This file is part of charm-helpers.
 
4
#
 
5
# charm-helpers is free software: you can redistribute it and/or modify
 
6
# it under the terms of the GNU Lesser General Public License version 3 as
 
7
# published by the Free Software Foundation.
 
8
#
 
9
# charm-helpers is distributed in the hope that it will be useful,
 
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
12
# GNU Lesser General Public License for more details.
 
13
#
 
14
# You should have received a copy of the GNU Lesser General Public License
 
15
# along with charm-helpers.  If not, see <http://www.gnu.org/licenses/>.
 
16
 
 
17
import grp
 
18
import os
 
19
import pwd
 
20
import re
 
21
 
 
22
from subprocess import (
 
23
    CalledProcessError,
 
24
    check_output,
 
25
    check_call,
 
26
)
 
27
from traceback import format_exc
 
28
from six import string_types
 
29
from stat import (
 
30
    S_ISGID,
 
31
    S_ISUID
 
32
)
 
33
 
 
34
from charmhelpers.core.hookenv import (
 
35
    log,
 
36
    DEBUG,
 
37
    INFO,
 
38
    WARNING,
 
39
    ERROR,
 
40
)
 
41
from charmhelpers.core import unitdata
 
42
from charmhelpers.core.host import file_hash
 
43
from charmhelpers.contrib.hardening.audits import BaseAudit
 
44
from charmhelpers.contrib.hardening.templating import (
 
45
    get_template_path,
 
46
    render_and_write,
 
47
)
 
48
from charmhelpers.contrib.hardening import utils
 
49
 
 
50
 
 
51
class BaseFileAudit(BaseAudit):
 
52
    """Base class for file audits.
 
53
 
 
54
    Provides api stubs for compliance check flow that must be used by any class
 
55
    that implemented this one.
 
56
    """
 
57
 
 
58
    def __init__(self, paths, always_comply=False, *args, **kwargs):
 
59
        """
 
60
        :param paths: string path of list of paths of files we want to apply
 
61
                      compliance checks are criteria to.
 
62
        :param always_comply: if true compliance criteria is always applied
 
63
                              else compliance is skipped for non-existent
 
64
                              paths.
 
65
        """
 
66
        super(BaseFileAudit, self).__init__(*args, **kwargs)
 
67
        self.always_comply = always_comply
 
68
        if isinstance(paths, string_types) or not hasattr(paths, '__iter__'):
 
69
            self.paths = [paths]
 
70
        else:
 
71
            self.paths = paths
 
72
 
 
73
    def ensure_compliance(self):
 
74
        """Ensure that the all registered files comply to registered criteria.
 
75
        """
 
76
        for p in self.paths:
 
77
            if os.path.exists(p):
 
78
                if self.is_compliant(p):
 
79
                    continue
 
80
 
 
81
                log('File %s is not in compliance.' % p, level=INFO)
 
82
            else:
 
83
                if not self.always_comply:
 
84
                    log("Non-existent path '%s' - skipping compliance check"
 
85
                        % (p), level=INFO)
 
86
                    continue
 
87
 
 
88
            if self._take_action():
 
89
                log("Applying compliance criteria to '%s'" % (p), level=INFO)
 
90
                self.comply(p)
 
91
 
 
92
    def is_compliant(self, path):
 
93
        """Audits the path to see if it is compliance.
 
94
 
 
95
        :param path: the path to the file that should be checked.
 
96
        """
 
97
        raise NotImplementedError
 
98
 
 
99
    def comply(self, path):
 
100
        """Enforces the compliance of a path.
 
101
 
 
102
        :param path: the path to the file that should be enforced.
 
103
        """
 
104
        raise NotImplementedError
 
105
 
 
106
    @classmethod
 
107
    def _get_stat(cls, path):
 
108
        """Returns the Posix st_stat information for the specified file path.
 
109
 
 
110
        :param path: the path to get the st_stat information for.
 
111
        :returns: an st_stat object for the path or None if the path doesn't
 
112
                  exist.
 
113
        """
 
114
        return os.stat(path)
 
115
 
 
116
 
 
117
class FilePermissionAudit(BaseFileAudit):
 
118
    """Implements an audit for file permissions and ownership for a user.
 
119
 
 
120
    This class implements functionality that ensures that a specific user/group
 
121
    will own the file(s) specified and that the permissions specified are
 
122
    applied properly to the file.
 
123
    """
 
124
    def __init__(self, paths, user, group=None, mode=0o600, **kwargs):
 
125
        self.user = user
 
126
        self.group = group
 
127
        self.mode = mode
 
128
        super(FilePermissionAudit, self).__init__(paths, user, group, mode,
 
129
                                                  **kwargs)
 
130
 
 
131
    @property
 
132
    def user(self):
 
133
        return self._user
 
134
 
 
135
    @user.setter
 
136
    def user(self, name):
 
137
        try:
 
138
            user = pwd.getpwnam(name)
 
139
        except KeyError:
 
140
            log('Unknown user %s' % name, level=ERROR)
 
141
            user = None
 
142
        self._user = user
 
143
 
 
144
    @property
 
145
    def group(self):
 
146
        return self._group
 
147
 
 
148
    @group.setter
 
149
    def group(self, name):
 
150
        try:
 
151
            group = None
 
152
            if name:
 
153
                group = grp.getgrnam(name)
 
154
            else:
 
155
                group = grp.getgrgid(self.user.pw_gid)
 
156
        except KeyError:
 
157
            log('Unknown group %s' % name, level=ERROR)
 
158
        self._group = group
 
159
 
 
160
    def is_compliant(self, path):
 
161
        """Checks if the path is in compliance.
 
162
 
 
163
        Used to determine if the path specified meets the necessary
 
164
        requirements to be in compliance with the check itself.
 
165
 
 
166
        :param path: the file path to check
 
167
        :returns: True if the path is compliant, False otherwise.
 
168
        """
 
169
        stat = self._get_stat(path)
 
170
        user = self.user
 
171
        group = self.group
 
172
 
 
173
        compliant = True
 
174
        if stat.st_uid != user.pw_uid or stat.st_gid != group.gr_gid:
 
175
            log('File %s is not owned by %s:%s.' % (path, user.pw_name,
 
176
                                                    group.gr_name),
 
177
                level=INFO)
 
178
            compliant = False
 
179
 
 
180
        # POSIX refers to the st_mode bits as corresponding to both the
 
181
        # file type and file permission bits, where the least significant 12
 
182
        # bits (o7777) are the suid (11), sgid (10), sticky bits (9), and the
 
183
        # file permission bits (8-0)
 
184
        perms = stat.st_mode & 0o7777
 
185
        if perms != self.mode:
 
186
            log('File %s has incorrect permissions, currently set to %s' %
 
187
                (path, oct(stat.st_mode & 0o7777)), level=INFO)
 
188
            compliant = False
 
189
 
 
190
        return compliant
 
191
 
 
192
    def comply(self, path):
 
193
        """Issues a chown and chmod to the file paths specified."""
 
194
        utils.ensure_permissions(path, self.user.pw_name, self.group.gr_name,
 
195
                                 self.mode)
 
196
 
 
197
 
 
198
class DirectoryPermissionAudit(FilePermissionAudit):
 
199
    """Performs a permission check for the  specified directory path."""
 
200
 
 
201
    def __init__(self, paths, user, group=None, mode=0o600,
 
202
                 recursive=True, **kwargs):
 
203
        super(DirectoryPermissionAudit, self).__init__(paths, user, group,
 
204
                                                       mode, **kwargs)
 
205
        self.recursive = recursive
 
206
 
 
207
    def is_compliant(self, path):
 
208
        """Checks if the directory is compliant.
 
209
 
 
210
        Used to determine if the path specified and all of its children
 
211
        directories are in compliance with the check itself.
 
212
 
 
213
        :param path: the directory path to check
 
214
        :returns: True if the directory tree is compliant, otherwise False.
 
215
        """
 
216
        if not os.path.isdir(path):
 
217
            log('Path specified %s is not a directory.' % path, level=ERROR)
 
218
            raise ValueError("%s is not a directory." % path)
 
219
 
 
220
        if not self.recursive:
 
221
            return super(DirectoryPermissionAudit, self).is_compliant(path)
 
222
 
 
223
        compliant = True
 
224
        for root, dirs, _ in os.walk(path):
 
225
            if len(dirs) > 0:
 
226
                continue
 
227
 
 
228
            if not super(DirectoryPermissionAudit, self).is_compliant(root):
 
229
                compliant = False
 
230
                continue
 
231
 
 
232
        return compliant
 
233
 
 
234
    def comply(self, path):
 
235
        for root, dirs, _ in os.walk(path):
 
236
            if len(dirs) > 0:
 
237
                super(DirectoryPermissionAudit, self).comply(root)
 
238
 
 
239
 
 
240
class ReadOnly(BaseFileAudit):
 
241
    """Audits that files and folders are read only."""
 
242
    def __init__(self, paths, *args, **kwargs):
 
243
        super(ReadOnly, self).__init__(paths=paths, *args, **kwargs)
 
244
 
 
245
    def is_compliant(self, path):
 
246
        try:
 
247
            output = check_output(['find', path, '-perm', '-go+w',
 
248
                                   '-type', 'f']).strip()
 
249
 
 
250
            # The find above will find any files which have permission sets
 
251
            # which allow too broad of write access. As such, the path is
 
252
            # compliant if there is no output.
 
253
            if output:
 
254
                return False
 
255
 
 
256
            return True
 
257
        except CalledProcessError as e:
 
258
            log('Error occurred checking finding writable files for %s. '
 
259
                'Error information is: command %s failed with returncode '
 
260
                '%d and output %s.\n%s' % (path, e.cmd, e.returncode, e.output,
 
261
                                           format_exc(e)), level=ERROR)
 
262
            return False
 
263
 
 
264
    def comply(self, path):
 
265
        try:
 
266
            check_output(['chmod', 'go-w', '-R', path])
 
267
        except CalledProcessError as e:
 
268
            log('Error occurred removing writeable permissions for %s. '
 
269
                'Error information is: command %s failed with returncode '
 
270
                '%d and output %s.\n%s' % (path, e.cmd, e.returncode, e.output,
 
271
                                           format_exc(e)), level=ERROR)
 
272
 
 
273
 
 
274
class NoReadWriteForOther(BaseFileAudit):
 
275
    """Ensures that the files found under the base path are readable or
 
276
    writable by anyone other than the owner or the group.
 
277
    """
 
278
    def __init__(self, paths):
 
279
        super(NoReadWriteForOther, self).__init__(paths)
 
280
 
 
281
    def is_compliant(self, path):
 
282
        try:
 
283
            cmd = ['find', path, '-perm', '-o+r', '-type', 'f', '-o',
 
284
                   '-perm', '-o+w', '-type', 'f']
 
285
            output = check_output(cmd).strip()
 
286
 
 
287
            # The find above here will find any files which have read or
 
288
            # write permissions for other, meaning there is too broad of access
 
289
            # to read/write the file. As such, the path is compliant if there's
 
290
            # no output.
 
291
            if output:
 
292
                return False
 
293
 
 
294
            return True
 
295
        except CalledProcessError as e:
 
296
            log('Error occurred while finding files which are readable or '
 
297
                'writable to the world in %s. '
 
298
                'Command output is: %s.' % (path, e.output), level=ERROR)
 
299
 
 
300
    def comply(self, path):
 
301
        try:
 
302
            check_output(['chmod', '-R', 'o-rw', path])
 
303
        except CalledProcessError as e:
 
304
            log('Error occurred attempting to change modes of files under '
 
305
                'path %s. Output of command is: %s' % (path, e.output))
 
306
 
 
307
 
 
308
class NoSUIDSGIDAudit(BaseFileAudit):
 
309
    """Audits that specified files do not have SUID/SGID bits set."""
 
310
    def __init__(self, paths, *args, **kwargs):
 
311
        super(NoSUIDSGIDAudit, self).__init__(paths=paths, *args, **kwargs)
 
312
 
 
313
    def is_compliant(self, path):
 
314
        stat = self._get_stat(path)
 
315
        if (stat.st_mode & (S_ISGID | S_ISUID)) != 0:
 
316
            return False
 
317
 
 
318
        return True
 
319
 
 
320
    def comply(self, path):
 
321
        try:
 
322
            log('Removing suid/sgid from %s.' % path, level=DEBUG)
 
323
            check_output(['chmod', '-s', path])
 
324
        except CalledProcessError as e:
 
325
            log('Error occurred removing suid/sgid from %s.'
 
326
                'Error information is: command %s failed with returncode '
 
327
                '%d and output %s.\n%s' % (path, e.cmd, e.returncode, e.output,
 
328
                                           format_exc(e)), level=ERROR)
 
329
 
 
330
 
 
331
class TemplatedFile(BaseFileAudit):
 
332
    """The TemplatedFileAudit audits the contents of a templated file.
 
333
 
 
334
    This audit renders a file from a template, sets the appropriate file
 
335
    permissions, then generates a hashsum with which to check the content
 
336
    changed.
 
337
    """
 
338
    def __init__(self, path, context, template_dir, mode, user='root',
 
339
                 group='root', service_actions=None, **kwargs):
 
340
        self.context = context
 
341
        self.user = user
 
342
        self.group = group
 
343
        self.mode = mode
 
344
        self.template_dir = template_dir
 
345
        self.service_actions = service_actions
 
346
        super(TemplatedFile, self).__init__(paths=path, always_comply=True,
 
347
                                            **kwargs)
 
348
 
 
349
    def is_compliant(self, path):
 
350
        """Determines if the templated file is compliant.
 
351
 
 
352
        A templated file is only compliant if it has not changed (as
 
353
        determined by its sha256 hashsum) AND its file permissions are set
 
354
        appropriately.
 
355
 
 
356
        :param path: the path to check compliance.
 
357
        """
 
358
        same_templates = self.templates_match(path)
 
359
        same_content = self.contents_match(path)
 
360
        same_permissions = self.permissions_match(path)
 
361
 
 
362
        if same_content and same_permissions and same_templates:
 
363
            return True
 
364
 
 
365
        return False
 
366
 
 
367
    def run_service_actions(self):
 
368
        """Run any actions on services requested."""
 
369
        if not self.service_actions:
 
370
            return
 
371
 
 
372
        for svc_action in self.service_actions:
 
373
            name = svc_action['service']
 
374
            actions = svc_action['actions']
 
375
            log("Running service '%s' actions '%s'" % (name, actions),
 
376
                level=DEBUG)
 
377
            for action in actions:
 
378
                cmd = ['service', name, action]
 
379
                try:
 
380
                    check_call(cmd)
 
381
                except CalledProcessError as exc:
 
382
                    log("Service name='%s' action='%s' failed - %s" %
 
383
                        (name, action, exc), level=WARNING)
 
384
 
 
385
    def comply(self, path):
 
386
        """Ensures the contents and the permissions of the file.
 
387
 
 
388
        :param path: the path to correct
 
389
        """
 
390
        dirname = os.path.dirname(path)
 
391
        if not os.path.exists(dirname):
 
392
            os.makedirs(dirname)
 
393
 
 
394
        self.pre_write()
 
395
        render_and_write(self.template_dir, path, self.context())
 
396
        utils.ensure_permissions(path, self.user, self.group, self.mode)
 
397
        self.run_service_actions()
 
398
        self.save_checksum(path)
 
399
        self.post_write()
 
400
 
 
401
    def pre_write(self):
 
402
        """Invoked prior to writing the template."""
 
403
        pass
 
404
 
 
405
    def post_write(self):
 
406
        """Invoked after writing the template."""
 
407
        pass
 
408
 
 
409
    def templates_match(self, path):
 
410
        """Determines if the template files are the same.
 
411
 
 
412
        The template file equality is determined by the hashsum of the
 
413
        template files themselves. If there is no hashsum, then the content
 
414
        cannot be sure to be the same so treat it as if they changed.
 
415
        Otherwise, return whether or not the hashsums are the same.
 
416
 
 
417
        :param path: the path to check
 
418
        :returns: boolean
 
419
        """
 
420
        template_path = get_template_path(self.template_dir, path)
 
421
        key = 'hardening:template:%s' % template_path
 
422
        template_checksum = file_hash(template_path)
 
423
        kv = unitdata.kv()
 
424
        stored_tmplt_checksum = kv.get(key)
 
425
        if not stored_tmplt_checksum:
 
426
            kv.set(key, template_checksum)
 
427
            kv.flush()
 
428
            log('Saved template checksum for %s.' % template_path,
 
429
                level=DEBUG)
 
430
            # Since we don't have a template checksum, then assume it doesn't
 
431
            # match and return that the template is different.
 
432
            return False
 
433
        elif stored_tmplt_checksum != template_checksum:
 
434
            kv.set(key, template_checksum)
 
435
            kv.flush()
 
436
            log('Updated template checksum for %s.' % template_path,
 
437
                level=DEBUG)
 
438
            return False
 
439
 
 
440
        # Here the template hasn't changed based upon the calculated
 
441
        # checksum of the template and what was previously stored.
 
442
        return True
 
443
 
 
444
    def contents_match(self, path):
 
445
        """Determines if the file content is the same.
 
446
 
 
447
        This is determined by comparing hashsum of the file contents and
 
448
        the saved hashsum. If there is no hashsum, then the content cannot
 
449
        be sure to be the same so treat them as if they are not the same.
 
450
        Otherwise, return True if the hashsums are the same, False if they
 
451
        are not the same.
 
452
 
 
453
        :param path: the file to check.
 
454
        """
 
455
        checksum = file_hash(path)
 
456
 
 
457
        kv = unitdata.kv()
 
458
        stored_checksum = kv.get('hardening:%s' % path)
 
459
        if not stored_checksum:
 
460
            # If the checksum hasn't been generated, return False to ensure
 
461
            # the file is written and the checksum stored.
 
462
            log('Checksum for %s has not been calculated.' % path, level=DEBUG)
 
463
            return False
 
464
        elif stored_checksum != checksum:
 
465
            log('Checksum mismatch for %s.' % path, level=DEBUG)
 
466
            return False
 
467
 
 
468
        return True
 
469
 
 
470
    def permissions_match(self, path):
 
471
        """Determines if the file owner and permissions match.
 
472
 
 
473
        :param path: the path to check.
 
474
        """
 
475
        audit = FilePermissionAudit(path, self.user, self.group, self.mode)
 
476
        return audit.is_compliant(path)
 
477
 
 
478
    def save_checksum(self, path):
 
479
        """Calculates and saves the checksum for the path specified.
 
480
 
 
481
        :param path: the path of the file to save the checksum.
 
482
        """
 
483
        checksum = file_hash(path)
 
484
        kv = unitdata.kv()
 
485
        kv.set('hardening:%s' % path, checksum)
 
486
        kv.flush()
 
487
 
 
488
 
 
489
class DeletedFile(BaseFileAudit):
 
490
    """Audit to ensure that a file is deleted."""
 
491
    def __init__(self, paths):
 
492
        super(DeletedFile, self).__init__(paths)
 
493
 
 
494
    def is_compliant(self, path):
 
495
        return not os.path.exists(path)
 
496
 
 
497
    def comply(self, path):
 
498
        os.remove(path)
 
499
 
 
500
 
 
501
class FileContentAudit(BaseFileAudit):
 
502
    """Audit the contents of a file."""
 
503
    def __init__(self, paths, cases, **kwargs):
 
504
        # Cases we expect to pass
 
505
        self.pass_cases = cases.get('pass', [])
 
506
        # Cases we expect to fail
 
507
        self.fail_cases = cases.get('fail', [])
 
508
        super(FileContentAudit, self).__init__(paths, **kwargs)
 
509
 
 
510
    def is_compliant(self, path):
 
511
        """
 
512
        Given a set of content matching cases i.e. tuple(regex, bool) where
 
513
        bool value denotes whether or not regex is expected to match, check that
 
514
        all cases match as expected with the contents of the file. Cases can be
 
515
        expected to pass of fail.
 
516
 
 
517
        :param path: Path of file to check.
 
518
        :returns: Boolean value representing whether or not all cases are
 
519
                  found to be compliant.
 
520
        """
 
521
        log("Auditing contents of file '%s'" % (path), level=DEBUG)
 
522
        with open(path, 'r') as fd:
 
523
            contents = fd.read()
 
524
 
 
525
        matches = 0
 
526
        for pattern in self.pass_cases:
 
527
            key = re.compile(pattern, flags=re.MULTILINE)
 
528
            results = re.search(key, contents)
 
529
            if results:
 
530
                matches += 1
 
531
            else:
 
532
                log("Pattern '%s' was expected to pass but instead it failed"
 
533
                    % (pattern), level=WARNING)
 
534
 
 
535
        for pattern in self.fail_cases:
 
536
            key = re.compile(pattern, flags=re.MULTILINE)
 
537
            results = re.search(key, contents)
 
538
            if not results:
 
539
                matches += 1
 
540
            else:
 
541
                log("Pattern '%s' was expected to fail but instead it passed"
 
542
                    % (pattern), level=WARNING)
 
543
 
 
544
        total = len(self.pass_cases) + len(self.fail_cases)
 
545
        log("Checked %s cases and %s passed" % (total, matches), level=DEBUG)
 
546
        return matches == total
 
547
 
 
548
    def comply(self, *args, **kwargs):
 
549
        """NOOP since we just issue warnings. This is to avoid the
 
550
        NotImplememtedError.
 
551
        """
 
552
        log("Not applying any compliance criteria, only checks.", level=INFO)