Test Failed
Push — master ( 738e3d...d883b3 )
by Matěj
01:17 queued 13s
created

DirectoryLoader.process_directory_tree()   A

Complexity

Conditions 2

Size

Total Lines 5
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 4.048

Importance

Changes 0
Metric Value
cc 2
eloc 5
nop 3
dl 0
loc 5
ccs 1
cts 5
cp 0.2
crap 4.048
rs 10
c 0
b 0
f 0
1 2
from __future__ import absolute_import
2 2
from __future__ import print_function
3
4 2
import os
5 2
import os.path
6 2
from collections import defaultdict
7 2
import datetime
8 2
import re
9 2
import sys
10
11 2
import yaml
12
13 2
from .constants import XCCDF_PLATFORM_TO_CPE
14 2
from .constants import PRODUCT_TO_CPE_MAPPING
15 2
from .constants import STIG_PLATFORM_ID_MAP
16 2
from .constants import XCCDF_REFINABLE_PROPERTIES
17 2
from .rules import get_rule_dir_id, get_rule_dir_yaml, is_rule_dir
18 2
from .rule_yaml import parse_prodtype
19
20 2
from .checks import is_cce_format_valid, is_cce_value_valid
21 2
from .yaml import DocumentationNotComplete, open_and_expand, open_and_macro_expand
22 2
from .utils import required_key, mkdir_p
23
24 2
from .xml import ElementTree as ET
25 2
from .shims import unicode_func
26
27
28 2
def add_sub_element(parent, tag, data):
29
    """
30
    Creates a new child element under parent with tag tag, and sets
31
    data as the content under the tag. In particular, data is a string
32
    to be parsed as an XML tree, allowing sub-elements of children to be
33
    added.
34
35
    If data should not be parsed as an XML tree, either escape the contents
36
    before passing into this function, or use ElementTree.SubElement().
37
38
    Returns the newly created subelement of type tag.
39
    """
40
    # This is used because our YAML data contain XML and XHTML elements
41
    # ET.SubElement() escapes the < > characters by &lt; and &gt;
42
    # and therefore it does not add child elements
43
    # we need to do a hack instead
44
    # TODO: Remove this function after we move to Markdown everywhere in SSG
45
    ustr = unicode_func("<{0}>{1}</{0}>").format(tag, data)
46
47
    try:
48
        element = ET.fromstring(ustr.encode("utf-8"))
49
    except Exception:
50
        msg = ("Error adding subelement to an element '{0}' from string: '{1}'"
51
               .format(parent.tag, ustr))
52
        raise RuntimeError(msg)
53
54
    parent.append(element)
55
    return element
56
57
58 2
def add_warning_elements(element, warnings):
59
    # The use of [{dict}, {dict}] in warnings is to handle the following
60
    # scenario where multiple warnings have the same category which is
61
    # valid in SCAP and our content:
62
    #
63
    # warnings:
64
    #     - general: Some general warning
65
    #     - general: Some other general warning
66
    #     - general: |-
67
    #         Some really long multiline general warning
68
    #
69
    # Each of the {dict} should have only one key/value pair.
70
    for warning_dict in warnings:
71
        warning = add_sub_element(element, "warning", list(warning_dict.values())[0])
72
        warning.set("category", list(warning_dict.keys())[0])
73
74
75 2
class Profile(object):
76
    """Represents XCCDF profile
77
    """
78
79 2
    def __init__(self, id_):
80 2
        self.id_ = id_
81 2
        self.title = ""
82 2
        self.description = ""
83 2
        self.extends = None
84 2
        self.selected = []
85 2
        self.unselected = []
86 2
        self.variables = dict()
87 2
        self.refine_rules = defaultdict(list)
88
89 2
    @staticmethod
90 2
    def from_yaml(yaml_file, env_yaml=None):
91 2
        yaml_contents = open_and_expand(yaml_file, env_yaml)
92 2
        if yaml_contents is None:
93
            return None
94
95 2
        basename, _ = os.path.splitext(os.path.basename(yaml_file))
96
97 2
        profile = Profile(basename)
98 2
        profile.title = required_key(yaml_contents, "title")
99 2
        del yaml_contents["title"]
100 2
        profile.description = required_key(yaml_contents, "description")
101 2
        del yaml_contents["description"]
102 2
        profile.extends = yaml_contents.pop("extends", None)
103 2
        selection_entries = required_key(yaml_contents, "selections")
104 2
        if selection_entries:
105 2
            profile._parse_selections(selection_entries)
106 2
        del yaml_contents["selections"]
107
108 2
        if yaml_contents:
109
            raise RuntimeError("Unparsed YAML data in '%s'.\n\n%s"
110
                               % (yaml_file, yaml_contents))
111
112 2
        return profile
113
114 2
    def dump_yaml(self, file_name, documentation_complete=True):
115
        to_dump = {}
116
        to_dump["documentation_complete"] = documentation_complete
117
        to_dump["title"] = self.title
118
        to_dump["description"] = self.description
119
        if self.extends is not None:
120
            to_dump["extends"] = self.extends
121
122
        selections = []
123
        for item in self.selected:
124
            selections.append(item)
125
        for item in self.unselected:
126
            selections.append("!"+item)
127
        for varname in self.variables.keys():
128
            selections.append(varname+"="+self.variables.get(varname))
129
        to_dump["selections"] = selections
130
        with open(file_name, "w+") as f:
131
            yaml.dump(to_dump, f, indent=4)
132
133 2
    def _parse_selections(self, entries):
134 2
        for item in entries:
135 2
            if "." in item:
136
                rule, refinement = item.split(".", 1)
137
                property_, value = refinement.split("=", 1)
138
                if property_ not in XCCDF_REFINABLE_PROPERTIES:
139
                    msg = ("Property '{property_}' cannot be refined. "
140
                           "Rule properties that can be refined are {refinables}. "
141
                           "Fix refinement '{rule_id}.{property_}={value}' in profile '{profile}'."
142
                           .format(property_=property_, refinables=XCCDF_REFINABLE_PROPERTIES,
143
                                   rule_id=rule, value=value, profile=self.id_)
144
                           )
145
                    raise ValueError(msg)
146
                self.refine_rules[rule].append((property_, value))
147 2
            elif "=" in item:
148 2
                varname, value = item.split("=", 1)
149 2
                self.variables[varname] = value
150 2
            elif item.startswith("!"):
151
                self.unselected.append(item[1:])
152
            else:
153 2
                self.selected.append(item)
154
155 2
    def to_xml_element(self):
156
        element = ET.Element('Profile')
157
        element.set("id", self.id_)
158
        if self.extends:
159
            element.set("extends", self.extends)
160
        title = add_sub_element(element, "title", self.title)
161
        title.set("override", "true")
162
        desc = add_sub_element(element, "description", self.description)
163
        desc.set("override", "true")
164
165
        for selection in self.selected:
166
            select = ET.Element("select")
167
            select.set("idref", selection)
168
            select.set("selected", "true")
169
            element.append(select)
170
171
        for selection in self.unselected:
172
            unselect = ET.Element("select")
173
            unselect.set("idref", selection)
174
            unselect.set("selected", "false")
175
            element.append(unselect)
176
177
        for value_id, selector in self.variables.items():
178
            refine_value = ET.Element("refine-value")
179
            refine_value.set("idref", value_id)
180
            refine_value.set("selector", selector)
181
            element.append(refine_value)
182
183
        for refined_rule, refinement_list in self.refine_rules.items():
184
            refine_rule = ET.Element("refine-rule")
185
            refine_rule.set("idref", refined_rule)
186
            for refinement in refinement_list:
187
                refine_rule.set(refinement[0], refinement[1])
188
            element.append(refine_rule)
189
190
        return element
191
192 2
    def get_rule_selectors(self):
193 2
        return list(self.selected + self.unselected)
194
195 2
    def get_variable_selectors(self):
196 2
        return self.variables
197
198 2
    def validate_refine_rules(self, rules):
199
        existing_rule_ids = [r.id_ for r in rules]
200
        for refine_rule, refinement_list in self.refine_rules.items():
201
            # Take first refinement to ilustrate where the error is
202
            # all refinements in list are invalid, so it doesn't really matter
203
            a_refinement = refinement_list[0]
204
205
            if refine_rule not in existing_rule_ids:
206
                msg = (
207
                    "You are trying to refine a rule that doesn't exist. "
208
                    "Rule '{rule_id}' was not found in the benchmark. "
209
                    "Please check all rule refinements for rule: '{rule_id}', for example: "
210
                    "- {rule_id}.{property_}={value}' in profile {profile_id}."
211
                    .format(rule_id=refine_rule, profile_id=self.id_,
212
                            property_=a_refinement[0], value=a_refinement[1])
213
                    )
214
                raise ValueError(msg)
215
216
            if refine_rule not in self.get_rule_selectors():
217
                msg = ("- {rule_id}.{property_}={value}' in profile '{profile_id}' is refining "
218
                       "a rule that is not selected by it. The refinement will not have any "
219
                       "noticeable effect. Either select the rule or remove the rule refinement."
220
                       .format(rule_id=refine_rule, property_=a_refinement[0],
221
                               value=a_refinement[1], profile_id=self.id_)
222
                       )
223
                raise ValueError(msg)
224
225 2
    def validate_variables(self, variables):
226
        variables_by_id = dict()
227
        for var in variables:
228
            variables_by_id[var.id_] = var
229
230
        for var_id, our_val in self.variables.items():
231
            if var_id not in variables_by_id:
232
                all_vars_list = [" - %s" % v for v in variables_by_id.keys()]
233
                msg = (
234
                    "Value '{var_id}' in profile '{profile_name}' is not known. "
235
                    "We know only variables:\n{var_names}"
236
                    .format(
237
                        var_id=var_id, profile_name=self.id_,
238
                        var_names="\n".join(sorted(all_vars_list)))
239
                )
240
                raise ValueError(msg)
241
242
            allowed_selectors = [str(s) for s in variables_by_id[var_id].options.keys()]
243
            if our_val not in allowed_selectors:
244
                msg = (
245
                    "Value '{var_id}' in profile '{profile_name}' "
246
                    "uses the selector '{our_val}'. "
247
                    "This is not possible, as only selectors {all_selectors} are available. "
248
                    "Either change the selector used in the profile, or "
249
                    "add the selector-value pair to the variable definition."
250
                    .format(
251
                        var_id=var_id, profile_name=self.id_, our_val=our_val,
252
                        all_selectors=allowed_selectors,
253
                    )
254
                )
255
                raise ValueError(msg)
256
257 2
    def validate_rules(self, rules, groups):
258
        existing_rule_ids = [r.id_ for r in rules]
259
        rule_selectors = self.get_rule_selectors()
260
        for id_ in rule_selectors:
261
            if id_ in groups:
262
                msg = (
263
                    "You have selected a group '{group_id}' instead of a "
264
                    "rule. Groups have no effect in the profile and are not "
265
                    "allowed to be selected. Please remove '{group_id}' "
266
                    "from profile '{profile_id}' before proceeding."
267
                    .format(group_id=id_, profile_id=self.id_)
268
                )
269
                raise ValueError(msg)
270
            if id_ not in existing_rule_ids:
271
                msg = (
272
                    "Rule '{rule_id}' was not found in the benchmark. Please "
273
                    "remove rule '{rule_id}' from profile '{profile_id}' "
274
                    "before proceeding."
275
                    .format(rule_id=id_, profile_id=self.id_)
276
                )
277
                raise ValueError(msg)
278
279 2
    def __sub__(self, other):
280
        profile = Profile(self.id_)
281
        profile.title = self.title
282
        profile.description = self.description
283
        profile.extends = self.extends
284
        profile.selected = list(set(self.selected) - set(other.selected))
285
        profile.selected.sort()
286
        profile.unselected = list(set(self.unselected) - set(other.unselected))
287
        profile.variables = dict ((k, v) for (k, v) in self.variables.items()
288
                             if k not in other.variables or v != other.variables[k])
289
        return profile
290
291
292 2
class Value(object):
293
    """Represents XCCDF Value
294
    """
295
296 2
    def __init__(self, id_):
297 2
        self.id_ = id_
298 2
        self.title = ""
299 2
        self.description = ""
300 2
        self.type_ = "string"
301 2
        self.operator = "equals"
302 2
        self.interactive = False
303 2
        self.options = {}
304 2
        self.warnings = []
305
306 2
    @staticmethod
307 2
    def from_yaml(yaml_file, env_yaml=None):
308 2
        yaml_contents = open_and_expand(yaml_file, env_yaml)
309 2
        if yaml_contents is None:
310
            return None
311
312 2
        value_id, _ = os.path.splitext(os.path.basename(yaml_file))
313 2
        value = Value(value_id)
314 2
        value.title = required_key(yaml_contents, "title")
315 2
        del yaml_contents["title"]
316 2
        value.description = required_key(yaml_contents, "description")
317 2
        del yaml_contents["description"]
318 2
        value.type_ = required_key(yaml_contents, "type")
319 2
        del yaml_contents["type"]
320 2
        value.operator = yaml_contents.pop("operator", "equals")
321 2
        possible_operators = ["equals", "not equal", "greater than",
322
                              "less than", "greater than or equal",
323
                              "less than or equal", "pattern match"]
324
325 2
        if value.operator not in possible_operators:
326
            raise ValueError(
327
                "Found an invalid operator value '%s' in '%s'. "
328
                "Expected one of: %s"
329
                % (value.operator, yaml_file, ", ".join(possible_operators))
330
            )
331
332 2
        value.interactive = \
333
            yaml_contents.pop("interactive", "false").lower() == "true"
334
335 2
        value.options = required_key(yaml_contents, "options")
336 2
        del yaml_contents["options"]
337 2
        value.warnings = yaml_contents.pop("warnings", [])
338
339 2
        for warning_list in value.warnings:
340
            if len(warning_list) != 1:
341
                raise ValueError("Only one key/value pair should exist for each dictionary")
342
343 2
        if yaml_contents:
344
            raise RuntimeError("Unparsed YAML data in '%s'.\n\n%s"
345
                               % (yaml_file, yaml_contents))
346
347 2
        return value
348
349 2
    def to_xml_element(self):
350
        value = ET.Element('Value')
351
        value.set('id', self.id_)
352
        value.set('type', self.type_)
353
        if self.operator != "equals":  # equals is the default
354
            value.set('operator', self.operator)
355
        if self.interactive:  # False is the default
356
            value.set('interactive', 'true')
357
        title = ET.SubElement(value, 'title')
358
        title.text = self.title
359
        add_sub_element(value, 'description', self.description)
360
        add_warning_elements(value, self.warnings)
361
362
        for selector, option in self.options.items():
363
            # do not confuse Value with big V with value with small v
364
            # value is child element of Value
365
            value_small = ET.SubElement(value, 'value')
366
            # by XCCDF spec, default value is value without selector
367
            if selector != "default":
368
                value_small.set('selector', str(selector))
369
            value_small.text = str(option)
370
371
        return value
372
373 2
    def to_file(self, file_name):
374
        root = self.to_xml_element()
375
        tree = ET.ElementTree(root)
376
        tree.write(file_name)
377
378
379 2
class Benchmark(object):
380
    """Represents XCCDF Benchmark
381
    """
382 2
    def __init__(self, id_):
383
        self.id_ = id_
384
        self.title = ""
385
        self.status = ""
386
        self.description = ""
387
        self.notice_id = ""
388
        self.notice_description = ""
389
        self.front_matter = ""
390
        self.rear_matter = ""
391
        self.cpes = []
392
        self.version = "0.1"
393
        self.profiles = []
394
        self.values = {}
395
        self.bash_remediation_fns_group = None
396
        self.groups = {}
397
        self.rules = {}
398
399
        # This is required for OCIL clauses
400
        conditional_clause = Value("conditional_clause")
401
        conditional_clause.title = "A conditional clause for check statements."
402
        conditional_clause.description = conditional_clause.title
403
        conditional_clause.type_ = "string"
404
        conditional_clause.options = {"": "This is a placeholder"}
405
406
        self.add_value(conditional_clause)
407
408 2
    @staticmethod
409 2
    def from_yaml(yaml_file, id_, product_yaml=None):
410
        yaml_contents = open_and_macro_expand(yaml_file, product_yaml)
411
        if yaml_contents is None:
412
            return None
413
414
        benchmark = Benchmark(id_)
415
        benchmark.title = required_key(yaml_contents, "title")
416
        del yaml_contents["title"]
417
        benchmark.status = required_key(yaml_contents, "status")
418
        del yaml_contents["status"]
419
        benchmark.description = required_key(yaml_contents, "description")
420
        del yaml_contents["description"]
421
        notice_contents = required_key(yaml_contents, "notice")
422
        benchmark.notice_id = required_key(notice_contents, "id")
423
        del notice_contents["id"]
424
        benchmark.notice_description = required_key(notice_contents,
425
                                                    "description")
426
        del notice_contents["description"]
427
        if not notice_contents:
428
            del yaml_contents["notice"]
429
430
        benchmark.front_matter = required_key(yaml_contents,
431
                                              "front-matter")
432
        del yaml_contents["front-matter"]
433
        benchmark.rear_matter = required_key(yaml_contents,
434
                                             "rear-matter")
435
        del yaml_contents["rear-matter"]
436
        benchmark.version = str(required_key(yaml_contents, "version"))
437
        del yaml_contents["version"]
438
439
        if yaml_contents:
440
            raise RuntimeError("Unparsed YAML data in '%s'.\n\n%s"
441
                               % (yaml_file, yaml_contents))
442
443
        if product_yaml:
444
            benchmark.cpes = PRODUCT_TO_CPE_MAPPING[product_yaml["product"]]
445
446
        return benchmark
447
448 2
    def add_profiles_from_dir(self, action, dir_, env_yaml):
449
        for dir_item in os.listdir(dir_):
450
            dir_item_path = os.path.join(dir_, dir_item)
451
            if not os.path.isfile(dir_item_path):
452
                continue
453
454
            _, ext = os.path.splitext(os.path.basename(dir_item_path))
455
            if ext != '.profile':
456
                sys.stderr.write(
457
                    "Encountered file '%s' while looking for profiles, "
458
                    "extension '%s' is unknown. Skipping..\n"
459
                    % (dir_item, ext)
460
                )
461
                continue
462
463
            try:
464
                new_profile = Profile.from_yaml(dir_item_path, env_yaml)
465
            except DocumentationNotComplete:
466
                continue
467
            if new_profile is None:
468
                continue
469
470
            self.profiles.append(new_profile)
471
            if action == "list-inputs":
472
                print(dir_item_path)
473
474 2
    def add_bash_remediation_fns_from_file(self, action, file_):
475
        if action == "list-inputs":
476
            print(file_)
477
        else:
478
            tree = ET.parse(file_)
479
            self.bash_remediation_fns_group = tree.getroot()
480
481 2
    def to_xml_element(self):
482
        root = ET.Element('Benchmark')
483
        root.set('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance')
484
        root.set('xmlns:xhtml', 'http://www.w3.org/1999/xhtml')
485
        root.set('xmlns:dc', 'http://purl.org/dc/elements/1.1/')
486
        root.set('id', 'product-name')
487
        root.set('xsi:schemaLocation',
488
                 'http://checklists.nist.gov/xccdf/1.1 xccdf-1.1.4.xsd')
489
        root.set('style', 'SCAP_1.1')
490
        root.set('resolved', 'false')
491
        root.set('xml:lang', 'en-US')
492
        status = ET.SubElement(root, 'status')
493
        status.set('date', datetime.date.today().strftime("%Y-%m-%d"))
494
        status.text = self.status
495
        add_sub_element(root, "title", self.title)
496
        add_sub_element(root, "description", self.description)
497
        notice = add_sub_element(root, "notice", self.notice_description)
498
        notice.set('id', self.notice_id)
499
        add_sub_element(root, "front-matter", self.front_matter)
500
        add_sub_element(root, "rear-matter", self.rear_matter)
501
502
        for idref in self.cpes:
503
            plat = ET.SubElement(root, "platform")
504
            plat.set("idref", idref)
505
506
        version = ET.SubElement(root, 'version')
507
        version.text = self.version
508
        ET.SubElement(root, "metadata")
509
510
        for profile in self.profiles:
511
            root.append(profile.to_xml_element())
512
513
        for value in self.values.values():
514
            root.append(value.to_xml_element())
515
        if self.bash_remediation_fns_group is not None:
516
            root.append(self.bash_remediation_fns_group)
517
518
        groups_in_bench = list(self.groups.keys())
519
        # Make system group the first, followed by services group
520
        group_priority_order = ["system", "services"]
521
        for group_id in group_priority_order:
522
            group = self.groups.get(group_id)
523
            # Products using application benchmark don't have system or services group
524
            if group is not None:
525
                root.append(group.to_xml_element())
526
                groups_in_bench.remove(group_id)
527
        # Add any remaining top level groups
528
        for group_id in groups_in_bench:
529
            root.append(self.groups.get(group_id).to_xml_element())
530
531
        for rule in self.rules.values():
532
            root.append(rule.to_xml_element())
533
534
        return root
535
536 2
    def to_file(self, file_name):
537
        root = self.to_xml_element()
538
        tree = ET.ElementTree(root)
539
        tree.write(file_name)
540
541 2
    def add_value(self, value):
542
        if value is None:
543
            return
544
        self.values[value.id_] = value
545
546 2
    def add_group(self, group):
547
        if group is None:
548
            return
549
        self.groups[group.id_] = group
550
551 2
    def add_rule(self, rule):
552
        if rule is None:
553
            return
554
        self.rules[rule.id_] = rule
555
556 2
    def to_xccdf(self):
557
        """We can easily extend this script to generate a valid XCCDF instead
558
        of SSG SHORTHAND.
559
        """
560
        raise NotImplementedError
561
562 2
    def __str__(self):
563
        return self.id_
564
565
566 2
class Group(object):
567
    """Represents XCCDF Group
568
    """
569 2
    ATTRIBUTES_TO_PASS_ON = (
570
        "platform",
571
    )
572
573 2
    def __init__(self, id_):
574
        self.id_ = id_
575
        self.prodtype = "all"
576
        self.title = ""
577
        self.description = ""
578
        self.warnings = []
579
        self.values = {}
580
        self.groups = {}
581
        self.rules = {}
582
        self.platform = None
583
584 2
    @staticmethod
585 2
    def from_yaml(yaml_file, env_yaml=None):
586
        yaml_contents = open_and_macro_expand(yaml_file, env_yaml)
587
        if yaml_contents is None:
588
            return None
589
590
        group_id = os.path.basename(os.path.dirname(yaml_file))
591
        group = Group(group_id)
592
        group.prodtype = yaml_contents.pop("prodtype", "all")
593
        group.title = required_key(yaml_contents, "title")
594
        del yaml_contents["title"]
595
        group.description = required_key(yaml_contents, "description")
596
        del yaml_contents["description"]
597
        group.warnings = yaml_contents.pop("warnings", [])
598
        group.platform = yaml_contents.pop("platform", None)
599
600
        for warning_list in group.warnings:
601
            if len(warning_list) != 1:
602
                raise ValueError("Only one key/value pair should exist for each dictionary")
603
604
        if yaml_contents:
605
            raise RuntimeError("Unparsed YAML data in '%s'.\n\n%s"
606
                               % (yaml_file, yaml_contents))
607
        group.validate_prodtype(yaml_file)
608
        return group
609
610 2
    def validate_prodtype(self, yaml_file):
611
        for ptype in self.prodtype.split(","):
612
            if ptype.strip() != ptype:
613
                msg = (
614
                    "Comma-separated '{prodtype}' prodtype "
615
                    "in {yaml_file} contains whitespace."
616
                    .format(prodtype=self.prodtype, yaml_file=yaml_file))
617
                raise ValueError(msg)
618
619 2
    def to_xml_element(self):
620
        group = ET.Element('Group')
621
        group.set('id', self.id_)
622
        if self.prodtype != "all":
623
            group.set("prodtype", self.prodtype)
624
        title = ET.SubElement(group, 'title')
625
        title.text = self.title
626
        add_sub_element(group, 'description', self.description)
627
        add_warning_elements(group, self.warnings)
628
629
        if self.platform:
630
            platform_el = ET.SubElement(group, "platform")
631
            try:
632
                platform_cpe = XCCDF_PLATFORM_TO_CPE[self.platform]
633
            except KeyError:
634
                raise ValueError("Unsupported platform '%s' in rule '%s'." % (self.platform, self.id_))
635
            platform_el.set("idref", platform_cpe)
636
637
        for _value in self.values.values():
638
            group.append(_value.to_xml_element())
639
640
        # Rules that install or remove packages affect remediation
641
        # of other rules.
642
        # When packages installed/removed rules come first:
643
        # The Rules are ordered in more logical way, and
644
        # remediation order is natural, first the package is installed, then configured.
645
        rules_in_group = list(self.rules.keys())
646
        regex = re.compile(r'(package_.*_(installed|removed))|(service_.*_(enabled|disabled))$')
647
        priority_rules = list(filter(regex.match, rules_in_group))
648
        priority_order = ["installed", "removed", "enabled", "disabled"]
649
        # Add priority rules in priority order, first all packages installed, then removed,
650
        # followed by services enabled, then disabled
651
        for priority_type in priority_order:
652
            for priority_rule_id in priority_rules:
653
                if priority_type in priority_rule_id:
654
                    group.append(self.rules.get(priority_rule_id).to_xml_element())
655
                    rules_in_group.remove(priority_rule_id)
656
        # Add remaining rules in the group
657
        for rule_id in rules_in_group:
658
            group.append(self.rules.get(rule_id).to_xml_element())
659
660
        # Add the sub groups after any current level group rules.
661
        # As package installed/removed and service enabled/disabled rules are usuallly in
662
        # top level group, this ensures groups that further configure a package or service
663
        # are after rules that install or remove it.
664
        for _group in self.groups.values():
665
            group.append(_group.to_xml_element())
666
667
        return group
668
669 2
    def to_file(self, file_name):
670
        root = self.to_xml_element()
671
        tree = ET.ElementTree(root)
672
        tree.write(file_name)
673
674 2
    def add_value(self, value):
675
        if value is None:
676
            return
677
        self.values[value.id_] = value
678
679 2
    def add_group(self, group):
680
        if group is None:
681
            return
682
        if self.platform and not group.platform:
683
            group.platform = self.platform
684
        self.groups[group.id_] = group
685
        self._pass_our_properties_on_to(group)
686
687 2
    def _pass_our_properties_on_to(self, obj):
688
        for attr in self.ATTRIBUTES_TO_PASS_ON:
689
            if hasattr(obj, attr) and getattr(obj, attr) is None:
690
                setattr(obj, attr, getattr(self, attr))
691
692 2
    def add_rule(self, rule):
693
        if rule is None:
694
            return
695
        if self.platform and not rule.platform:
696
            rule.platform = self.platform
697
        self.rules[rule.id_] = rule
698
        self._pass_our_properties_on_to(rule)
699
700 2
    def __str__(self):
701
        return self.id_
702
703
704 2
class Rule(object):
705
    """Represents XCCDF Rule
706
    """
707 2
    YAML_KEYS_DEFAULTS = {
708
        "prodtype": lambda: "all",
709
        "title": lambda: RuntimeError("Missing key 'title'"),
710
        "description": lambda: RuntimeError("Missing key 'description'"),
711
        "rationale": lambda: RuntimeError("Missing key 'rationale'"),
712
        "severity": lambda: RuntimeError("Missing key 'severity'"),
713
        "references": lambda: dict(),
714
        "identifiers": lambda: dict(),
715
        "ocil_clause": lambda: None,
716
        "ocil": lambda: None,
717
        "oval_external_content": lambda: None,
718
        "warnings": lambda: list(),
719
        "platform": lambda: None,
720
        "template": lambda: None,
721
    }
722
723 2
    def __init__(self, id_):
724 2
        self.id_ = id_
725 2
        self.prodtype = "all"
726 2
        self.title = ""
727 2
        self.description = ""
728 2
        self.rationale = ""
729 2
        self.severity = "unknown"
730 2
        self.references = {}
731 2
        self.identifiers = {}
732 2
        self.ocil_clause = None
733 2
        self.ocil = None
734 2
        self.oval_external_content = None
735 2
        self.warnings = []
736 2
        self.platform = None
737 2
        self.template = None
738
739 2
    @staticmethod
740 2
    def from_yaml(yaml_file, env_yaml=None):
741 2
        yaml_file = os.path.normpath(yaml_file)
742
743 2
        yaml_contents = open_and_macro_expand(yaml_file, env_yaml)
744 2
        if yaml_contents is None:
745
            return None
746
747 2
        rule_id, ext = os.path.splitext(os.path.basename(yaml_file))
748 2
        if rule_id == "rule" and ext == ".yml":
749 2
            rule_id = get_rule_dir_id(yaml_file)
750
751 2
        rule = Rule(rule_id)
752
753 2
        try:
754 2
            rule._set_attributes_from_dict(yaml_contents)
755
        except RuntimeError as exc:
756
            msg = ("Error processing '{fname}': {err}"
757
                   .format(fname=yaml_file, err=str(exc)))
758
            raise RuntimeError(msg)
759
760 2
        for warning_list in rule.warnings:
761
            if len(warning_list) != 1:
762
                raise ValueError("Only one key/value pair should exist for each dictionary")
763
764 2
        if yaml_contents:
765
            raise RuntimeError("Unparsed YAML data in '%s'.\n\n%s"
766
                               % (yaml_file, yaml_contents))
767
768 2
        rule.validate_prodtype(yaml_file)
769 2
        rule.validate_identifiers(yaml_file)
770 2
        rule.validate_references(yaml_file)
771 2
        return rule
772
773 2
    def _make_stigid_product_specific(self, product):
774 2
        stig_id = self.references.get("stigid", None)
775 2
        if not stig_id:
776 2
            return
777
        if "," in stig_id:
778
            raise ValueError("Rules can not have multiple STIG IDs.")
779
        stig_platform_id = STIG_PLATFORM_ID_MAP.get(product, product.upper())
780
        product_specific_stig_id = "{platform_id}-{stig_id}".format(
781
            platform_id=stig_platform_id, stig_id=stig_id)
782
        self.references["stigid"] = product_specific_stig_id
783
784 2
    def normalize(self, product):
785 2
        try:
786 2
            self.make_refs_and_identifiers_product_specific(product)
787 2
            self.make_template_product_specific(product)
788 2
        except Exception as exc:
789 2
            msg = (
790
                "Error normalizing '{rule}': {msg}"
791
                .format(rule=self.id_, msg=str(exc))
792
            )
793 2
            raise RuntimeError(msg)
794
795 2
    def _get_product_only_references(self):
796 2
        PRODUCT_REFERENCES = ("stigid",)
797
798 2
        product_references = dict()
799
800 2
        for ref in PRODUCT_REFERENCES:
801 2
            start = "{0}@".format(ref)
802 2
            for gref, gval in self.references.items():
803
                if ref == gref or gref.startswith(start):
804
                    product_references[gref] = gval
805 2
        return product_references
806
807 2
    def make_template_product_specific(self, product):
808 2
        product_suffix = "@{0}".format(product)
809
810 2
        if not self.template:
811
            return
812
813 2
        not_specific_vars = self.template.get("vars", dict())
814 2
        specific_vars = self._make_items_product_specific(
815
            not_specific_vars, product_suffix, True)
816 2
        self.template["vars"] = specific_vars
817
818 2
        not_specific_backends = self.template.get("backends", dict())
819 2
        specific_backends = self._make_items_product_specific(
820
            not_specific_backends, product_suffix, True)
821 2
        self.template["backends"] = specific_backends
822
823 2
    def make_refs_and_identifiers_product_specific(self, product):
824 2
        product_suffix = "@{0}".format(product)
825
826 2
        product_references = self._get_product_only_references()
827 2
        general_references = self.references.copy()
828 2
        for todel in product_references:
829
            general_references.pop(todel)
830
831 2
        to_set = dict(
832
            identifiers=(self.identifiers, False),
833
            general_references=(general_references, True),
834
            product_references=(product_references, False),
835
        )
836 2
        for name, (dic, allow_overwrites) in to_set.items():
837 2
            try:
838 2
                new_items = self._make_items_product_specific(
839
                    dic, product_suffix, allow_overwrites)
840 2
            except ValueError as exc:
841 2
                msg = (
842
                    "Error processing {what} for rule '{rid}': {msg}"
843
                    .format(what=name, rid=self.id_, msg=str(exc))
844
                )
845 2
                raise ValueError(msg)
846 2
            dic.clear()
847 2
            dic.update(new_items)
848
849 2
        self.references = general_references
850 2
        self.references.update(product_references)
851
852 2
        self._make_stigid_product_specific(product)
853
854 2
    def _make_items_product_specific(self, items_dict, product_suffix, allow_overwrites=False):
855 2
        new_items = dict()
856 2
        for full_label, value in items_dict.items():
857 2
            if "@" not in full_label and full_label not in new_items:
858 2
                new_items[full_label] = value
859 2
                continue
860
861 2
            if not full_label.endswith(product_suffix):
862 2
                continue
863
864 2
            label = full_label.split("@")[0]
865 2
            if label in items_dict and not allow_overwrites and value != items_dict[label]:
866 2
                msg = (
867
                    "There is a product-qualified '{item_q}' item, "
868
                    "but also an unqualified '{item_u}' item "
869
                    "and those two differ in value - "
870
                    "'{value_q}' vs '{value_u}' respectively."
871
                    .format(item_q=full_label, item_u=label,
872
                            value_q=value, value_u=items_dict[label])
873
                )
874 2
                raise ValueError(msg)
875 2
            new_items[label] = value
876 2
        return new_items
877
878 2
    def _set_attributes_from_dict(self, yaml_contents):
879 2
        for key, default_getter in self.YAML_KEYS_DEFAULTS.items():
880 2
            if key not in yaml_contents:
881 2
                value = default_getter()
882 2
                if isinstance(value, Exception):
883
                    raise value
884
            else:
885 2
                value = yaml_contents.pop(key)
886
887 2
            setattr(self, key, value)
888
889 2
    def to_contents_dict(self):
890
        """
891
        Returns a dictionary that is the same schema as the dict obtained when loading rule YAML.
892
        """
893
894 2
        yaml_contents = dict()
895 2
        for key in Rule.YAML_KEYS_DEFAULTS:
896 2
            yaml_contents[key] = getattr(self, key)
897
898 2
        return yaml_contents
899
900 2
    def validate_identifiers(self, yaml_file):
901 2
        if self.identifiers is None:
902
            raise ValueError("Empty identifier section in file %s" % yaml_file)
903
904
        # Validate all identifiers are non-empty:
905 2
        for ident_type, ident_val in self.identifiers.items():
906 2
            if not isinstance(ident_type, str) or not isinstance(ident_val, str):
907
                raise ValueError("Identifiers and values must be strings: %s in file %s"
908
                                 % (ident_type, yaml_file))
909 2
            if ident_val.strip() == "":
910
                raise ValueError("Identifiers must not be empty: %s in file %s"
911
                                 % (ident_type, yaml_file))
912 2
            if ident_type[0:3] == 'cce':
913 2
                if not is_cce_format_valid("CCE-" + ident_val):
914
                    raise ValueError("CCE Identifier format must be valid: invalid format '%s' for CEE '%s'"
915
                                     " in file '%s'" % (ident_val, ident_type, yaml_file))
916 2
                if not is_cce_value_valid("CCE-" + ident_val):
917
                    raise ValueError("CCE Identifier value is not a valid checksum: invalid value '%s' for CEE '%s'"
918
                                     " in file '%s'" % (ident_val, ident_type, yaml_file))
919
920 2
    def validate_references(self, yaml_file):
921 2
        if self.references is None:
922
            raise ValueError("Empty references section in file %s" % yaml_file)
923
924 2
        for ref_type, ref_val in self.references.items():
925 2
            if not isinstance(ref_type, str) or not isinstance(ref_val, str):
926
                raise ValueError("References and values must be strings: %s in file %s"
927
                                 % (ref_type, yaml_file))
928 2
            if ref_val.strip() == "":
929
                raise ValueError("References must not be empty: %s in file %s"
930
                                 % (ref_type, yaml_file))
931
932 2
        for ref_type, ref_val in self.references.items():
933 2
            for ref in ref_val.split(","):
934 2
                if ref.strip() != ref:
935
                    msg = (
936
                        "Comma-separated '{ref_type}' reference "
937
                        "in {yaml_file} contains whitespace."
938
                        .format(ref_type=ref_type, yaml_file=yaml_file))
939
                    raise ValueError(msg)
940
941 2
    def validate_prodtype(self, yaml_file):
942 2
        for ptype in self.prodtype.split(","):
943 2
            if ptype.strip() != ptype:
944
                msg = (
945
                    "Comma-separated '{prodtype}' prodtype "
946
                    "in {yaml_file} contains whitespace."
947
                    .format(prodtype=self.prodtype, yaml_file=yaml_file))
948
                raise ValueError(msg)
949
950 2
    def to_xml_element(self):
951
        rule = ET.Element('Rule')
952
        rule.set('id', self.id_)
953
        if self.prodtype != "all":
954
            rule.set("prodtype", self.prodtype)
955
        rule.set('severity', self.severity)
956
        add_sub_element(rule, 'title', self.title)
957
        add_sub_element(rule, 'description', self.description)
958
        add_sub_element(rule, 'rationale', self.rationale)
959
960
        main_ident = ET.Element('ident')
961
        for ident_type, ident_val in self.identifiers.items():
962
            # This is not true if items were normalized
963
            if '@' in ident_type:
964
                # the ident is applicable only on some product
965
                # format : 'policy@product', eg. 'stigid@product'
966
                # for them, we create a separate <ref> element
967
                policy, product = ident_type.split('@')
968
                ident = ET.SubElement(rule, 'ident')
969
                ident.set(policy, ident_val)
970
                ident.set('prodtype', product)
971
            else:
972
                main_ident.set(ident_type, ident_val)
973
974
        if main_ident.attrib:
975
            rule.append(main_ident)
976
977
        main_ref = ET.Element('ref')
978
        for ref_type, ref_val in self.references.items():
979
            # This is not true if items were normalized
980
            if '@' in ref_type:
981
                # the reference is applicable only on some product
982
                # format : 'policy@product', eg. 'stigid@product'
983
                # for them, we create a separate <ref> element
984
                policy, product = ref_type.split('@')
985
                ref = ET.SubElement(rule, 'ref')
986
                ref.set(policy, ref_val)
987
                ref.set('prodtype', product)
988
            else:
989
                main_ref.set(ref_type, ref_val)
990
991
        if main_ref.attrib:
992
            rule.append(main_ref)
993
994
        if self.oval_external_content:
995
            check = ET.SubElement(rule, 'check')
996
            check.set("system", "http://oval.mitre.org/XMLSchema/oval-definitions-5")
997
            external_content = ET.SubElement(check, "check-content-ref")
998
            external_content.set("href", self.oval_external_content)
999
        else:
1000
            # TODO: This is pretty much a hack, oval ID will be the same as rule ID
1001
            #       and we don't want the developers to have to keep them in sync.
1002
            #       Therefore let's just add an OVAL ref of that ID.
1003
            oval_ref = ET.SubElement(rule, "oval")
1004
            oval_ref.set("id", self.id_)
1005
1006
        if self.ocil or self.ocil_clause:
1007
            ocil = add_sub_element(rule, 'ocil', self.ocil if self.ocil else "")
1008
            if self.ocil_clause:
1009
                ocil.set("clause", self.ocil_clause)
1010
1011
        add_warning_elements(rule, self.warnings)
1012
1013
        if self.platform:
1014
            platform_el = ET.SubElement(rule, "platform")
1015
            try:
1016
                platform_cpe = XCCDF_PLATFORM_TO_CPE[self.platform]
1017
            except KeyError:
1018
                raise ValueError("Unsupported platform '%s' in rule '%s'." % (self.platform, self.id_))
1019
            platform_el.set("idref", platform_cpe)
1020
1021
        return rule
1022
1023 2
    def to_file(self, file_name):
1024
        root = self.to_xml_element()
1025
        tree = ET.ElementTree(root)
1026
        tree.write(file_name)
1027
1028
1029 2
class DirectoryLoader(object):
1030 2
    def __init__(self, profiles_dir, bash_remediation_fns, env_yaml):
1031
        self.benchmark_file = None
1032
        self.group_file = None
1033
        self.loaded_group = None
1034
        self.rule_files = []
1035
        self.value_files = []
1036
        self.subdirectories = []
1037
1038
        self.all_values = set()
1039
        self.all_rules = set()
1040
        self.all_groups = set()
1041
1042
        self.profiles_dir = profiles_dir
1043
        self.bash_remediation_fns = bash_remediation_fns
1044
        self.env_yaml = env_yaml
1045
        self.product = env_yaml["product"]
1046
1047
        self.parent_group = None
1048
1049 2
    def _collect_items_to_load(self, guide_directory):
1050
        for dir_item in os.listdir(guide_directory):
1051
            dir_item_path = os.path.join(guide_directory, dir_item)
1052
            _, extension = os.path.splitext(dir_item)
1053
1054
            if extension == '.var':
1055
                self.value_files.append(dir_item_path)
1056
            elif dir_item == "benchmark.yml":
1057
                if self.benchmark_file:
1058
                    raise ValueError("Multiple benchmarks in one directory")
1059
                self.benchmark_file = dir_item_path
1060
            elif dir_item == "group.yml":
1061
                if self.group_file:
1062
                    raise ValueError("Multiple groups in one directory")
1063
                self.group_file = dir_item_path
1064
            elif extension == '.rule':
1065
                self.rule_files.append(dir_item_path)
1066
            elif is_rule_dir(dir_item_path):
1067
                self.rule_files.append(get_rule_dir_yaml(dir_item_path))
1068
            elif dir_item != "tests":
1069
                if os.path.isdir(dir_item_path):
1070
                    self.subdirectories.append(dir_item_path)
1071
                else:
1072
                    sys.stderr.write(
1073
                        "Encountered file '%s' while recursing, extension '%s' "
1074
                        "is unknown. Skipping..\n"
1075
                        % (dir_item, extension)
1076
                    )
1077
1078 2
    def load_benchmark_or_group(self, guide_directory):
1079
        """
1080
        Loads a given benchmark or group from the specified benchmark_file or
1081
        group_file, in the context of guide_directory, action, profiles_dir,
1082
        env_yaml, and bash_remediation_fns.
1083
1084
        Returns the loaded group or benchmark.
1085
        """
1086
        group = None
1087
        if self.group_file and self.benchmark_file:
1088
            raise ValueError("A .benchmark file and a .group file were found in "
1089
                             "the same directory '%s'" % (guide_directory))
1090
1091
        # we treat benchmark as a special form of group in the following code
1092
        if self.benchmark_file:
1093
            group = Benchmark.from_yaml(
1094
                self.benchmark_file, 'product-name', self.env_yaml
1095
            )
1096
            if self.profiles_dir:
1097
                group.add_profiles_from_dir(self.action, self.profiles_dir, self.env_yaml)
1098
            group.add_bash_remediation_fns_from_file(self.action, self.bash_remediation_fns)
1099
1100
        if self.group_file:
1101
            group = Group.from_yaml(self.group_file, self.env_yaml)
1102
            self.all_groups.add(group.id_)
1103
1104
        return group
1105
1106 2
    def _load_group_process_and_recurse(self, guide_directory):
1107
        self.loaded_group = self.load_benchmark_or_group(guide_directory)
1108
1109
        if self.loaded_group:
1110
            if self.parent_group:
1111
                self.parent_group.add_group(self.loaded_group)
1112
1113
            self._process_values()
1114
            self._recurse_into_subdirs()
1115
            self._process_rules()
1116
1117 2
    def process_directory_tree(self, start_dir, extra_group_dirs=None):
1118
        self._collect_items_to_load(start_dir)
1119
        if extra_group_dirs is not None:
1120
            self.subdirectories += extra_group_dirs
1121
        self._load_group_process_and_recurse(start_dir)
1122
1123 2
    def _recurse_into_subdirs(self):
1124
        for subdir in self.subdirectories:
1125
            loader = self._get_new_loader()
1126
            loader.parent_group = self.loaded_group
1127
            loader.process_directory_tree(subdir)
1128
            self.all_values.update(loader.all_values)
1129
            self.all_rules.update(loader.all_rules)
1130
            self.all_groups.update(loader.all_groups)
1131
1132 2
    def _get_new_loader(self):
1133
        raise NotImplementedError()
1134
1135 2
    def _process_values(self):
1136
        raise NotImplementedError()
1137
1138 2
    def _process_rules(self):
1139
        raise NotImplementedError()
1140
1141
1142 2
class BuildLoader(DirectoryLoader):
1143 2
    def __init__(self, profiles_dir, bash_remediation_fns, env_yaml, resolved_rules_dir=None):
1144
        super(BuildLoader, self).__init__(profiles_dir, bash_remediation_fns, env_yaml)
1145
1146
        self.action = "build"
1147
1148
        self.resolved_rules_dir = resolved_rules_dir
1149
        if resolved_rules_dir and not os.path.isdir(resolved_rules_dir):
1150
            os.mkdir(resolved_rules_dir)
1151
1152 2
    def _process_values(self):
1153
        for value_yaml in self.value_files:
1154
            value = Value.from_yaml(value_yaml, self.env_yaml)
1155
            self.all_values.add(value)
1156
            self.loaded_group.add_value(value)
1157
1158 2
    def _process_rules(self):
1159
        for rule_yaml in self.rule_files:
1160
            rule = Rule.from_yaml(rule_yaml, self.env_yaml)
1161
            prodtypes = parse_prodtype(rule.prodtype)
1162
            if "all" not in prodtypes and self.product not in prodtypes:
1163
                continue
1164
            self.all_rules.add(rule)
1165
            self.loaded_group.add_rule(rule)
1166
            if self.resolved_rules_dir:
1167
                output_for_rule = os.path.join(
1168
                    self.resolved_rules_dir, "{id_}.yml".format(id_=rule.id_))
1169
                mkdir_p(self.resolved_rules_dir)
1170
                with open(output_for_rule, "w") as f:
1171
                    rule.normalize(self.env_yaml["product"])
1172
                    yaml.dump(rule.to_contents_dict(), f)
1173
1174 2
    def _get_new_loader(self):
1175
        return BuildLoader(
1176
            self.profiles_dir, self.bash_remediation_fns, self.env_yaml, self.resolved_rules_dir)
1177
1178 2
    def export_group_to_file(self, filename):
1179
        return self.loaded_group.to_file(filename)
1180
1181
1182 2
class ListInputsLoader(DirectoryLoader):
1183 2
    def __init__(self, profiles_dir, bash_remediation_fns, env_yaml):
1184
        super(ListInputsLoader, self).__init__(profiles_dir, bash_remediation_fns, env_yaml)
1185
1186
        self.action = "list-inputs"
1187
1188 2
    def _process_values(self):
1189
        for value_yaml in self.value_files:
1190
            print(value_yaml)
1191
1192 2
    def _process_rules(self):
1193
        for rule_yaml in self.rule_files:
1194
            print(rule_yaml)
1195
1196 2
    def _get_new_loader(self):
1197
        return ListInputsLoader(
1198
            self.profiles_dir, self.bash_remediation_fns, self.env_yaml)
1199
1200 2
    def load_benchmark_or_group(self, guide_directory):
1201
        result = super(ListInputsLoader, self).load_benchmark_or_group(guide_directory)
1202
1203
        if self.benchmark_file:
1204
            print(self.benchmark_file)
1205
1206
        if self.group_file:
1207
            print(self.group_file)
1208
1209
        return result
1210