1
|
|
|
#!/usr/bin/python3 |
2
|
|
|
|
3
|
2 |
|
import os |
4
|
2 |
|
import re |
5
|
2 |
|
import sys |
6
|
|
|
|
7
|
2 |
|
from collections import OrderedDict |
8
|
|
|
|
9
|
2 |
|
import ssg.rules |
10
|
2 |
|
import ssg.utils |
11
|
2 |
|
import ssg.yaml |
12
|
2 |
|
import ssg.build_yaml |
13
|
2 |
|
import ssg.build_remediations |
14
|
|
|
|
15
|
2 |
|
COMMENTS_TO_PARSE = ["strategy", "complexity", "disruption"] |
16
|
|
|
|
17
|
|
|
|
18
|
2 |
|
class PlaybookBuilder(): |
19
|
2 |
|
def __init__(self, product_yaml_path, input_dir, output_dir, rules_dir, profiles_dir): |
20
|
2 |
|
self.input_dir = input_dir |
21
|
2 |
|
self.output_dir = output_dir |
22
|
2 |
|
self.rules_dir = rules_dir |
23
|
2 |
|
product_yaml = ssg.yaml.open_raw(product_yaml_path) |
24
|
2 |
|
product_dir = os.path.dirname(product_yaml_path) |
25
|
2 |
|
relative_guide_dir = ssg.utils.required_key(product_yaml, |
26
|
|
|
"benchmark_root") |
27
|
2 |
|
self.guide_dir = os.path.abspath(os.path.join(product_dir, |
28
|
|
|
relative_guide_dir)) |
29
|
2 |
|
self.profiles_dir = profiles_dir |
30
|
2 |
|
additional_content_directories = product_yaml.get("additional_content_directories", []) |
31
|
2 |
|
self.add_content_dirs = [os.path.abspath(os.path.join(product_dir, rd)) for rd in additional_content_directories] |
32
|
|
|
|
33
|
2 |
|
def choose_variable_value(self, var_id, variables, refinements): |
34
|
|
|
""" |
35
|
|
|
Determine value of variable based on profile refinements. |
36
|
|
|
""" |
37
|
2 |
|
if refinements and var_id in refinements: |
38
|
2 |
|
selector = refinements[var_id] |
39
|
|
|
else: |
40
|
|
|
selector = "default" |
41
|
2 |
|
try: |
42
|
2 |
|
options = variables[var_id] |
43
|
|
|
except KeyError: |
44
|
|
|
raise ValueError("Variable '%s' doesn't exist." % var_id) |
45
|
2 |
|
try: |
46
|
2 |
|
value = options[selector] |
47
|
|
|
except KeyError: |
48
|
|
|
if len(options.keys()) == 1: |
49
|
|
|
# We will assume that if there is just one option that it could |
50
|
|
|
# be selected as a default option. |
51
|
|
|
value = list(options.values())[0] |
52
|
|
|
elif selector == "default": |
53
|
|
|
# If no 'default' selector is present in the XCCDF value, |
54
|
|
|
# choose the first (consistent with oscap behavior) |
55
|
|
|
value = list(options.values())[0] |
56
|
|
|
else: |
57
|
|
|
raise ValueError( |
58
|
|
|
"Selector '%s' doesn't exist in variable '%s'. " |
59
|
|
|
"Available selectors: %s." % |
60
|
|
|
(selector, var_id, ", ".join(options.keys())) |
61
|
|
|
) |
62
|
2 |
|
return value |
63
|
|
|
|
64
|
2 |
|
def get_data_from_snippet(self, snippet_yaml, variables, refinements): |
65
|
|
|
""" |
66
|
|
|
Extracts and resolves tasks and variables from Ansible snippet. |
67
|
|
|
""" |
68
|
2 |
|
xccdf_var_pattern = re.compile(r"\(xccdf-var\s+(\S+)\)") |
69
|
2 |
|
tasks = [] |
70
|
2 |
|
values = dict() |
71
|
2 |
|
for item in snippet_yaml: |
72
|
2 |
|
if isinstance(item, str): |
73
|
2 |
|
match = xccdf_var_pattern.match(item) |
74
|
2 |
|
if match: |
75
|
2 |
|
var_id = match.group(1) |
76
|
2 |
|
value = self.choose_variable_value(var_id, variables, |
77
|
|
|
refinements) |
78
|
2 |
|
values[var_id] = value |
79
|
|
|
else: |
80
|
|
|
raise ValueError("Found unknown item '%s'" % item) |
81
|
|
|
else: |
82
|
2 |
|
tasks.append(item) |
83
|
2 |
|
return tasks, values |
84
|
|
|
|
85
|
2 |
|
def get_benchmark_variables(self): |
86
|
|
|
""" |
87
|
|
|
Get all variables, their selectors and values used in a given |
88
|
|
|
benchmark. Returns a dictionary where keys are variable IDs and |
89
|
|
|
values are dictionaries where keys are selectors and values are |
90
|
|
|
variable values. |
91
|
|
|
""" |
92
|
2 |
|
variables = dict() |
93
|
2 |
|
for cur_dir in [self.guide_dir] + self.add_content_dirs: |
94
|
2 |
|
variables.update(self._get_rules_variables(cur_dir)) |
95
|
2 |
|
return variables |
96
|
|
|
|
97
|
2 |
|
def _get_rules_variables(self, base_dir): |
98
|
2 |
|
for dirpath, dirnames, filenames in os.walk(base_dir): |
99
|
2 |
|
for filename in filenames: |
100
|
2 |
|
root, ext = os.path.splitext(filename) |
101
|
2 |
|
if ext == ".var": |
102
|
2 |
|
full_path = os.path.join(dirpath, filename) |
103
|
2 |
|
xccdf_value = ssg.build_yaml.Value.from_yaml(full_path) |
104
|
|
|
# Make sure that selectors and values are strings |
105
|
2 |
|
options = dict() |
106
|
2 |
|
for k, v in xccdf_value.options.items(): |
107
|
2 |
|
options[str(k)] = str(v) |
|
|
|
|
108
|
2 |
|
yield (xccdf_value.id_, options,) |
109
|
|
|
|
110
|
2 |
|
def _find_rule_title(self, rule_id): |
111
|
2 |
|
rule_path = os.path.join(self.rules_dir, rule_id + ".yml") |
112
|
2 |
|
rule_yaml = ssg.yaml.open_raw(rule_path) |
113
|
2 |
|
return rule_yaml["title"] |
114
|
|
|
|
115
|
2 |
|
def create_playbook(self, snippet_path, rule_id, variables, |
116
|
|
|
refinements, output_dir): |
117
|
|
|
""" |
118
|
|
|
Creates a Playbook from Ansible snippet for the given rule specified |
119
|
|
|
by rule ID, fills in the profile values and saves it into output_dir. |
120
|
|
|
""" |
121
|
|
|
|
122
|
2 |
|
with open(snippet_path, "r") as snippet_file: |
123
|
2 |
|
snippet_str = snippet_file.read() |
124
|
2 |
|
fix = ssg.build_remediations.split_remediation_content_and_metadata(snippet_str) |
125
|
2 |
|
snippet_yaml = ssg.yaml.ordered_load(fix.contents) |
126
|
|
|
|
127
|
2 |
|
play_tasks, play_vars = self.get_data_from_snippet( |
128
|
|
|
snippet_yaml, variables, refinements |
129
|
|
|
) |
130
|
|
|
|
131
|
2 |
|
if len(play_tasks) == 0: |
132
|
|
|
raise ValueError( |
133
|
|
|
"Ansible remediation for rule '%s' in '%s' " |
134
|
|
|
"doesn't contain any task." % |
135
|
|
|
(rule_id, snippet_path) |
136
|
|
|
) |
137
|
|
|
|
138
|
2 |
|
tags = set() |
139
|
2 |
|
for task in play_tasks: |
140
|
2 |
|
tags |= set(task.pop("tags", [])) |
141
|
|
|
|
142
|
2 |
|
play = OrderedDict() |
143
|
2 |
|
play["name"] = self._find_rule_title(rule_id) |
144
|
2 |
|
play["hosts"] = "@@HOSTS@@" |
145
|
2 |
|
play["become"] = True |
146
|
2 |
|
if len(play_vars) > 0: |
147
|
2 |
|
play["vars"] = play_vars |
148
|
2 |
|
if len(tags) > 0: |
149
|
2 |
|
play["tags"] = sorted(list(tags)) |
150
|
2 |
|
play["tasks"] = play_tasks |
151
|
|
|
|
152
|
2 |
|
playbook = [play] |
153
|
2 |
|
playbook_path = os.path.join(output_dir, rule_id + ".yml") |
154
|
2 |
|
with open(playbook_path, "w") as playbook_file: |
155
|
|
|
# write remediation metadata (complexity, strategy, etc.) first |
156
|
2 |
|
for k, v in fix.config.items(): |
157
|
2 |
|
playbook_file.write("# %s = %s\n" % (k, v)) |
158
|
2 |
|
ssg.yaml.ordered_dump( |
159
|
|
|
playbook, playbook_file, default_flow_style=False |
160
|
|
|
) |
161
|
|
|
|
162
|
2 |
|
def open_profile(self, profile_path): |
163
|
|
|
""" |
164
|
|
|
Opens and parses profile at the given profile_path. |
165
|
|
|
""" |
166
|
2 |
|
if not os.path.isfile(profile_path): |
167
|
|
|
raise RuntimeError("'%s' is not a file!\n" % profile_path) |
168
|
2 |
|
profile_id, ext = os.path.splitext(os.path.basename(profile_path)) |
169
|
2 |
|
if ext != ".profile": |
170
|
|
|
raise RuntimeError( |
171
|
|
|
"Encountered file '%s' while looking for profiles, " |
172
|
|
|
"extension '%s' is unknown. Skipping..\n" |
173
|
|
|
% (profile_path, ext) |
174
|
|
|
) |
175
|
|
|
|
176
|
2 |
|
profile = ssg.build_yaml.Profile.from_yaml(profile_path) |
177
|
2 |
|
if not profile: |
178
|
|
|
raise RuntimeError("Could not parse profile %s.\n" % profile_path) |
179
|
2 |
|
return profile |
180
|
|
|
|
181
|
2 |
|
def create_playbooks_for_all_rules_in_profile(self, profile, variables): |
182
|
|
|
""" |
183
|
|
|
Creates a Playbook for each rule selected in a profile from tasks |
184
|
|
|
extracted from snippets. Created Playbooks are parametrized by |
185
|
|
|
variables according to profile selection. Playbooks are written into |
186
|
|
|
a new subdirectory in output_dir. |
187
|
|
|
""" |
188
|
|
|
profile_rules = profile.get_rule_selectors() |
189
|
|
|
profile_refines = profile.get_variable_selectors() |
190
|
|
|
|
191
|
|
|
profile_playbooks_dir = os.path.join(self.output_dir, profile.id_) |
192
|
|
|
os.makedirs(profile_playbooks_dir) |
193
|
|
|
|
194
|
|
|
for rule_id in profile_rules: |
195
|
|
|
snippet_path = os.path.join(self.input_dir, rule_id + ".yml") |
196
|
|
|
if os.path.exists(snippet_path): |
197
|
|
|
self.create_playbook( |
198
|
|
|
snippet_path, rule_id, variables, |
199
|
|
|
profile_refines, profile_playbooks_dir |
200
|
|
|
) |
201
|
|
|
|
202
|
2 |
|
def create_playbook_for_single_rule(self, profile, rule_id, variables): |
203
|
|
|
""" |
204
|
|
|
Creates a Playbook for given rule specified by a rule_id. Created |
205
|
|
|
Playbooks are parametrized by variables according to profile selection. |
206
|
|
|
Playbooks are written into a new subdirectory in output_dir. |
207
|
|
|
""" |
208
|
2 |
|
profile_rules = profile.get_rule_selectors() |
209
|
2 |
|
profile_refines = profile.get_variable_selectors() |
210
|
2 |
|
profile_playbooks_dir = os.path.join(self.output_dir, profile.id_) |
211
|
2 |
|
os.makedirs(profile_playbooks_dir) |
212
|
2 |
|
snippet_path = os.path.join(self.input_dir, rule_id + ".yml") |
213
|
2 |
|
if rule_id in profile_rules: |
214
|
2 |
|
self.create_playbook( |
215
|
|
|
snippet_path, rule_id, variables, |
216
|
|
|
profile_refines, profile_playbooks_dir |
217
|
|
|
) |
218
|
|
|
else: |
219
|
|
|
raise ValueError("Rule '%s' isn't part of profile '%s'" % |
220
|
|
|
(rule_id, profile.id_)) |
221
|
|
|
|
222
|
2 |
|
def create_playbooks_for_all_rules(self, variables): |
223
|
|
|
profile_playbooks_dir = os.path.join(self.output_dir, "all") |
224
|
|
|
os.makedirs(profile_playbooks_dir) |
225
|
|
|
for rule in os.listdir(self.rules_dir): |
226
|
|
|
rule_id, _ = os.path.splitext(rule) |
227
|
|
|
snippet_path = os.path.join(self.input_dir, rule_id + ".yml") |
228
|
|
|
if not os.path.exists(snippet_path): |
229
|
|
|
continue |
230
|
|
|
self.create_playbook( |
231
|
|
|
snippet_path, rule_id, variables, |
232
|
|
|
None, profile_playbooks_dir |
233
|
|
|
) |
234
|
|
|
|
235
|
2 |
|
def build(self, profile_id=None, rule_id=None): |
236
|
|
|
""" |
237
|
|
|
Creates Playbooks for a specified profile. |
238
|
|
|
If profile is not given, creates playbooks for all profiles |
239
|
|
|
in the product. |
240
|
|
|
If the rule_id is not given, Playbooks are created for every rule. |
241
|
|
|
""" |
242
|
2 |
|
variables = self.get_benchmark_variables() |
243
|
2 |
|
profiles = {} |
244
|
2 |
|
for profile_file in os.listdir(self.profiles_dir): |
245
|
2 |
|
profile_path = os.path.join(self.profiles_dir, profile_file) |
246
|
2 |
|
try: |
247
|
2 |
|
profile = self.open_profile(profile_path) |
248
|
|
|
except ssg.yaml.DocumentationNotComplete as e: |
249
|
|
|
msg = "Skipping incomplete profile {0}. To include incomplete " + \ |
250
|
|
|
"profiles, build in debug mode.\n" |
251
|
|
|
sys.stderr.write(msg.format(profile_path)) |
252
|
|
|
continue |
253
|
|
|
except RuntimeError as e: |
254
|
|
|
sys.stderr.write(str(e)) |
255
|
|
|
continue |
256
|
2 |
|
profiles[profile.id_] = profile |
257
|
|
|
|
258
|
2 |
|
if profile_id: |
259
|
2 |
|
to_process = [profile_id] |
260
|
|
|
else: |
261
|
|
|
to_process = list(profiles.keys()) |
262
|
|
|
|
263
|
2 |
|
for p in to_process: |
264
|
2 |
|
profile = profiles[p] |
265
|
2 |
|
if rule_id: |
266
|
2 |
|
self.create_playbook_for_single_rule(profile, rule_id, |
267
|
|
|
variables) |
268
|
|
|
else: |
269
|
|
|
self.create_playbooks_for_all_rules_in_profile( |
270
|
|
|
profile, variables) |
271
|
|
|
|
272
|
2 |
|
if not profile_id: |
273
|
|
|
# build playbooks for virtual '(all)' profile |
274
|
|
|
# this virtual profile contains all rules in the product |
275
|
|
|
self.create_playbooks_for_all_rules(variables) |
276
|
|
|
|