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