1
|
|
|
import datetime |
2
|
|
|
import platform |
3
|
|
|
import subprocess |
4
|
|
|
import re |
5
|
|
|
import codecs |
6
|
|
|
import jinja2 |
7
|
|
|
import os.path |
8
|
|
|
import yaml |
9
|
|
|
|
10
|
|
|
try: |
11
|
|
|
from yaml import CSafeLoader as yaml_SafeLoader |
12
|
|
|
except ImportError: |
13
|
|
|
from yaml import SafeLoader as yaml_SafeLoader |
14
|
|
|
|
15
|
|
|
|
16
|
|
|
def bool_constructor(self, node): |
17
|
|
|
return self.construct_scalar(node) |
18
|
|
|
|
19
|
|
|
|
20
|
|
|
# Don't follow python bool case |
21
|
|
|
yaml_SafeLoader.add_constructor(u'tag:yaml.org,2002:bool', bool_constructor) |
22
|
|
|
|
23
|
|
|
|
24
|
|
|
try: |
25
|
|
|
from xml.etree import cElementTree as ElementTree |
26
|
|
|
except ImportError: |
27
|
|
|
import cElementTree as ElementTree |
28
|
|
|
|
29
|
|
|
|
30
|
|
|
JINJA_MACROS_DEFINITIONS = os.path.join(os.path.dirname(__file__), "macros.jinja") |
31
|
|
|
|
32
|
|
|
xml_version = """<?xml version="1.0" encoding="UTF-8"?>""" |
33
|
|
|
|
34
|
|
|
datastream_namespace = "http://scap.nist.gov/schema/scap/source/1.2" |
35
|
|
|
ocil_namespace = "http://scap.nist.gov/schema/ocil/2.0" |
36
|
|
|
oval_footer = "</oval_definitions>" |
37
|
|
|
oval_namespace = "http://oval.mitre.org/XMLSchema/oval-definitions-5" |
38
|
|
|
ocil_cs = "http://scap.nist.gov/schema/ocil/2" |
39
|
|
|
xccdf_header = xml_version + "<xccdf>" |
40
|
|
|
xccdf_footer = "</xccdf>" |
41
|
|
|
bash_system = "urn:xccdf:fix:script:sh" |
42
|
|
|
ansible_system = "urn:xccdf:fix:script:ansible" |
43
|
|
|
puppet_system = "urn:xccdf:fix:script:puppet" |
44
|
|
|
anaconda_system = "urn:redhat:anaconda:pre" |
45
|
|
|
cce_uri = "https://nvd.nist.gov/cce/index.cfm" |
46
|
|
|
stig_ns = "http://iase.disa.mil/stigs/Pages/stig-viewing-guidance.aspx" |
47
|
|
|
ssg_version_uri = \ |
48
|
|
|
"https://github.com/OpenSCAP/scap-security-guide/releases/latest" |
49
|
|
|
OSCAP_VENDOR = "org.ssgproject" |
50
|
|
|
OSCAP_DS_STRING = "xccdf_%s.content_benchmark_" % OSCAP_VENDOR |
51
|
|
|
OSCAP_GROUP = "xccdf_%s.content_group_" % OSCAP_VENDOR |
52
|
|
|
OSCAP_GROUP_PCIDSS = "xccdf_%s.content_group_pcidss-req" % OSCAP_VENDOR |
53
|
|
|
OSCAP_GROUP_VAL = "xccdf_%s.content_group_values" % OSCAP_VENDOR |
54
|
|
|
OSCAP_GROUP_NON_PCI = "xccdf_%s.content_group_non-pci-dss" % OSCAP_VENDOR |
55
|
|
|
XCCDF11_NS = "http://checklists.nist.gov/xccdf/1.1" |
56
|
|
|
XCCDF12_NS = "http://checklists.nist.gov/xccdf/1.2" |
57
|
|
|
min_ansible_version = "2.3" |
58
|
|
|
ansible_version_requirement_pre_task_name = \ |
59
|
|
|
"Verify Ansible meets SCAP-Security-Guide version requirements." |
60
|
|
|
|
61
|
|
|
oval_header = ( |
62
|
|
|
""" |
63
|
|
|
<oval_definitions |
64
|
|
|
xmlns="{0}" |
65
|
|
|
xmlns:oval="http://oval.mitre.org/XMLSchema/oval-common-5" |
66
|
|
|
xmlns:ind="{0}#independent" |
67
|
|
|
xmlns:unix="{0}#unix" |
68
|
|
|
xmlns:linux="{0}#linux" |
69
|
|
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
70
|
|
|
xsi:schemaLocation="http://oval.mitre.org/XMLSchema/oval-common-5 oval-common-schema.xsd |
71
|
|
|
{0} oval-definitions-schema.xsd |
72
|
|
|
{0}#independent independent-definitions-schema.xsd |
73
|
|
|
{0}#unix unix-definitions-schema.xsd |
74
|
|
|
{0}#linux linux-definitions-schema.xsd">""" |
75
|
|
|
.format(oval_namespace)) |
76
|
|
|
|
77
|
|
|
|
78
|
|
|
timestamp = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S") |
79
|
|
|
|
80
|
|
|
|
81
|
|
|
PKG_MANAGER_TO_SYSTEM = { |
82
|
|
|
"yum": "rpm", |
83
|
|
|
"zypper": "rpm", |
84
|
|
|
"dnf": "rpm", |
85
|
|
|
"apt_get": "dpkg", |
86
|
|
|
} |
87
|
|
|
|
88
|
|
|
|
89
|
|
|
class SSGError(RuntimeError): |
90
|
|
|
pass |
91
|
|
|
|
92
|
|
|
|
93
|
|
|
def oval_generated_header(product_name, schema_version, ssg_version): |
94
|
|
|
return xml_version + oval_header + \ |
95
|
|
|
""" |
96
|
|
|
<generator> |
97
|
|
|
<oval:product_name>%s from SCAP Security Guide</oval:product_name> |
98
|
|
|
<oval:product_version>ssg: %s, python: %s</oval:product_version> |
99
|
|
|
<oval:schema_version>%s</oval:schema_version> |
100
|
|
|
<oval:timestamp>%s</oval:timestamp> |
101
|
|
|
</generator>""" % (product_name, ssg_version, platform.python_version(), |
102
|
|
|
schema_version, timestamp) |
103
|
|
|
|
104
|
|
|
|
105
|
|
|
def subprocess_check_output(*popenargs, **kwargs): |
106
|
|
|
# Backport of subprocess.check_output taken from |
107
|
|
|
# https://gist.github.com/edufelipe/1027906 |
108
|
|
|
# |
109
|
|
|
# Originally from Python 2.7 stdlib under PSF, compatible with BSD-3 |
110
|
|
|
# Copyright (c) 2003-2005 by Peter Astrand <[email protected]> |
111
|
|
|
# Changes by Eduardo Felipe |
112
|
|
|
|
113
|
|
|
process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs) |
114
|
|
|
output, unused_err = process.communicate() |
115
|
|
|
retcode = process.poll() |
116
|
|
|
if retcode: |
117
|
|
|
cmd = kwargs.get("args") |
118
|
|
|
if cmd is None: |
119
|
|
|
cmd = popenargs[0] |
120
|
|
|
error = subprocess.CalledProcessError(retcode, cmd) |
121
|
|
|
error.output = output |
122
|
|
|
raise error |
123
|
|
|
return output |
124
|
|
|
|
125
|
|
|
|
126
|
|
|
if hasattr(subprocess, "check_output"): |
127
|
|
|
# if available we just use the real function |
128
|
|
|
subprocess_check_output = subprocess.check_output |
129
|
|
|
|
130
|
|
|
|
131
|
|
|
def get_check_content_ref_if_exists_and_not_remote(check): |
132
|
|
|
""" |
133
|
|
|
Given an OVAL check element, examine the ``xccdf_ns:check-content-ref`` |
134
|
|
|
|
135
|
|
|
If it exists and it isn't remote, pass it as the return value. |
136
|
|
|
Otherwise, return None. |
137
|
|
|
|
138
|
|
|
..see-also:: check_content_href_is_remote |
139
|
|
|
""" |
140
|
|
|
checkcontentref = check.find("./{%s}check-content-ref" % XCCDF11_NS) |
141
|
|
|
if checkcontentref is None: |
142
|
|
|
return None |
143
|
|
|
if check_content_href_is_remote(checkcontentref): |
144
|
|
|
return None |
145
|
|
|
else: |
146
|
|
|
return checkcontentref |
147
|
|
|
|
148
|
|
|
|
149
|
|
|
def check_content_href_is_remote(check_content_ref): |
150
|
|
|
""" |
151
|
|
|
Given an OVAL check-content-ref element, examine the 'href' attribute. |
152
|
|
|
|
153
|
|
|
If it starts with 'http://' or 'https://', return True, otherwise return False. |
154
|
|
|
|
155
|
|
|
Raises RuntimeError if the ``href`` element doesn't exist. |
156
|
|
|
""" |
157
|
|
|
hrefattr = check_content_ref.get("href") |
158
|
|
|
if hrefattr is None: |
159
|
|
|
# @href attribute of <check-content-ref> is required by XCCDF standard |
160
|
|
|
msg = "Invalid OVAL <check-content-ref> detected - missing the 'href' attribute!" |
161
|
|
|
raise RuntimeError(msg) |
162
|
|
|
|
163
|
|
|
return hrefattr.startswith("http://") or hrefattr.startswith("https://") |
164
|
|
|
|
165
|
|
|
|
166
|
|
|
def parse_xml_file(filename): |
167
|
|
|
""" |
168
|
|
|
Given a filename, return the corresponding ElementTree |
169
|
|
|
""" |
170
|
|
|
with open(filename, 'r') as xml_file: |
171
|
|
|
filestring = xml_file.read() |
172
|
|
|
tree = ElementTree.fromstring(filestring) |
173
|
|
|
return tree |
174
|
|
|
|
175
|
|
|
|
176
|
|
|
def cce_is_valid(cceid): |
177
|
|
|
""" |
178
|
|
|
IF CCE ID IS IN VALID FORM (either 'CCE-XXXX-X' or 'CCE-XXXXX-X' |
179
|
|
|
where each X is a digit, and the final X is a check-digit) |
180
|
|
|
based on Requirement A17: |
181
|
|
|
|
182
|
|
|
http://people.redhat.com/swells/nist-scap-validation/scap-val-requirements-1.2.html |
183
|
|
|
""" |
184
|
|
|
match = re.search(r'CCE-\d{4,5}-\d', cceid) |
185
|
|
|
return match is not None |
186
|
|
|
|
187
|
|
|
|
188
|
|
|
def map_elements_to_their_ids(tree, xpath_expr): |
189
|
|
|
""" |
190
|
|
|
Given an ElementTree and an XPath expression, |
191
|
|
|
iterate through matching elements and create 1:1 id->element mapping. |
192
|
|
|
|
193
|
|
|
Raises AssertionError if a matching element doesn't have the ``id`` attribute. |
194
|
|
|
|
195
|
|
|
Returns mapping as a dictionary |
196
|
|
|
""" |
197
|
|
|
aggregated = {} |
198
|
|
|
for element in tree.findall(xpath_expr): |
199
|
|
|
element_id = element.get("id") |
200
|
|
|
assert element_id is not None |
201
|
|
|
aggregated[element_id] = element |
202
|
|
|
return aggregated |
203
|
|
|
|
204
|
|
|
|
205
|
|
|
class AbsolutePathFileSystemLoader(jinja2.BaseLoader): |
206
|
|
|
"""Loads templates from the file system. This loader insists on absolute |
207
|
|
|
paths and fails if a relative path is provided. |
208
|
|
|
|
209
|
|
|
>>> loader = AbsolutePathFileSystemLoader() |
210
|
|
|
|
211
|
|
|
Per default the template encoding is ``'utf-8'`` which can be changed |
212
|
|
|
by setting the `encoding` parameter to something else. |
213
|
|
|
""" |
214
|
|
|
|
215
|
|
|
def __init__(self, encoding='utf-8'): |
216
|
|
|
self.encoding = encoding |
217
|
|
|
|
218
|
|
|
def get_source(self, environment, template): |
219
|
|
|
if not os.path.isabs(template): |
220
|
|
|
raise jinja2.TemplateNotFound(template) |
221
|
|
|
|
222
|
|
|
f = jinja2.utils.open_if_exists(template) |
223
|
|
|
if f is None: |
224
|
|
|
raise jinja2.TemplateNotFound(template) |
225
|
|
|
try: |
226
|
|
|
contents = f.read().decode(self.encoding) |
227
|
|
|
finally: |
228
|
|
|
f.close() |
229
|
|
|
|
230
|
|
|
mtime = os.path.getmtime(template) |
231
|
|
|
|
232
|
|
|
def uptodate(): |
233
|
|
|
try: |
234
|
|
|
return os.path.getmtime(template) == mtime |
235
|
|
|
except OSError: |
236
|
|
|
return False |
237
|
|
|
return contents, template, uptodate |
238
|
|
|
|
239
|
|
|
|
240
|
|
|
def get_jinja_environment(): |
241
|
|
|
if get_jinja_environment.env is None: |
242
|
|
|
# TODO: Choose better syntax? |
243
|
|
|
get_jinja_environment.env = jinja2.Environment( |
244
|
|
|
block_start_string="{{%", |
245
|
|
|
block_end_string="%}}", |
246
|
|
|
variable_start_string="{{{", |
247
|
|
|
variable_end_string="}}}", |
248
|
|
|
comment_start_string="{{#", |
249
|
|
|
comment_end_string="#}}", |
250
|
|
|
loader=AbsolutePathFileSystemLoader() |
251
|
|
|
) |
252
|
|
|
|
253
|
|
|
return get_jinja_environment.env |
254
|
|
|
|
255
|
|
|
|
256
|
|
|
get_jinja_environment.env = None |
257
|
|
|
|
258
|
|
|
|
259
|
|
|
def process_file_with_jinja(filepath, substitutions_dict): |
260
|
|
|
template = get_jinja_environment().get_template(filepath) |
261
|
|
|
return template.render(substitutions_dict) |
262
|
|
|
|
263
|
|
|
|
264
|
|
|
def _open_yaml(stream): |
265
|
|
|
""" |
266
|
|
|
Open given file-like object and parse it as YAML |
267
|
|
|
Return None if it contains "documentation_complete" key set to "false". |
268
|
|
|
""" |
269
|
|
|
yaml_contents = yaml.load(stream, Loader=yaml_SafeLoader) |
270
|
|
|
|
271
|
|
|
if yaml_contents.pop("documentation_complete", "true") == "false": |
272
|
|
|
return None |
273
|
|
|
|
274
|
|
|
return yaml_contents |
275
|
|
|
|
276
|
|
|
|
277
|
|
|
def open_and_expand_yaml(yaml_file, substitutions_dict=None): |
278
|
|
|
""" |
279
|
|
|
Process the file as a template, using substitutions_dict to perform expansion. |
280
|
|
|
Then, process the expansion result as a YAML content. |
281
|
|
|
|
282
|
|
|
See also: _open_yaml |
283
|
|
|
""" |
284
|
|
|
if substitutions_dict is None: |
285
|
|
|
substitutions_dict = dict() |
286
|
|
|
|
287
|
|
|
expanded_template = process_file_with_jinja(yaml_file, substitutions_dict) |
288
|
|
|
yaml_contents = _open_yaml(expanded_template) |
289
|
|
|
return yaml_contents |
290
|
|
|
|
291
|
|
|
|
292
|
|
|
def _extract_substitutions_dict_from_template(filename): |
293
|
|
|
template = get_jinja_environment().get_template(filename) |
294
|
|
|
all_symbols = template.make_module().__dict__ |
295
|
|
|
symbols_to_export = dict() |
296
|
|
|
for name, symbol in all_symbols.items(): |
297
|
|
|
if name.startswith("_"): |
298
|
|
|
continue |
299
|
|
|
symbols_to_export[name] = symbol |
300
|
|
|
return symbols_to_export |
301
|
|
|
|
302
|
|
|
|
303
|
|
|
def rename_items(original_dict, renames): |
304
|
|
|
renamed_macros = dict() |
305
|
|
|
for rename_from, rename_to in renames.items(): |
306
|
|
|
if rename_from in original_dict: |
307
|
|
|
renamed_macros[rename_to] = original_dict[rename_from] |
308
|
|
|
return renamed_macros |
309
|
|
|
|
310
|
|
|
|
311
|
|
|
def get_implied_properties(existing_properties): |
312
|
|
|
result = dict() |
313
|
|
|
if ("pkg_manager" in existing_properties |
314
|
|
|
and "pkg_system" not in existing_properties): |
315
|
|
|
result["pkg_system"] = PKG_MANAGER_TO_SYSTEM[existing_properties["pkg_manager"]] |
316
|
|
|
return result |
317
|
|
|
|
318
|
|
|
|
319
|
|
|
def _save_rename(result, stem, prefix): |
320
|
|
|
result["{0}_{1}".format(prefix, stem)] = stem |
321
|
|
|
|
322
|
|
|
|
323
|
|
|
def _identify_special_macro_mapping(existing_properties): |
324
|
|
|
result = dict() |
325
|
|
|
|
326
|
|
|
pkg_manager = existing_properties.get("pkg_manager") |
327
|
|
|
if pkg_manager is not None: |
328
|
|
|
_save_rename(result, "describe_package_install", pkg_manager) |
329
|
|
|
_save_rename(result, "describe_package_remove", pkg_manager) |
330
|
|
|
|
331
|
|
|
pkg_system = existing_properties.get("pkg_system") |
332
|
|
|
if pkg_system is not None: |
333
|
|
|
_save_rename(result, "ocil_package", pkg_system) |
334
|
|
|
_save_rename(result, "complete_ocil_entry_package", pkg_system) |
335
|
|
|
|
336
|
|
|
init_system = existing_properties.get("init_system") |
337
|
|
|
if init_system is not None: |
338
|
|
|
_save_rename(result, "describe_service_enable", init_system) |
339
|
|
|
_save_rename(result, "describe_service_disable", init_system) |
340
|
|
|
_save_rename(result, "ocil_service_enabled", init_system) |
341
|
|
|
_save_rename(result, "ocil_service_disabled", init_system) |
342
|
|
|
_save_rename(result, "describe_socket_enable", init_system) |
343
|
|
|
_save_rename(result, "describe_socket_disable", init_system) |
344
|
|
|
_save_rename(result, "complete_ocil_entry_socket_and_service_disabled", init_system) |
345
|
|
|
|
346
|
|
|
return result |
347
|
|
|
|
348
|
|
|
|
349
|
|
|
def open_and_macro_expand_yaml(yaml_file, substitutions_dict=None): |
350
|
|
|
""" |
351
|
|
|
Do the same as open_and_expand_yaml, but load definitions of macros |
352
|
|
|
so they can be expanded in the template. |
353
|
|
|
""" |
354
|
|
|
if substitutions_dict is None: |
355
|
|
|
substitutions_dict = dict() |
356
|
|
|
|
357
|
|
|
try: |
358
|
|
|
macro_definitions = _extract_substitutions_dict_from_template(JINJA_MACROS_DEFINITIONS) |
359
|
|
|
except Exception as exc: |
360
|
|
|
msg = ("Error extracting macro definitions from {0}: {1}" |
361
|
|
|
.format(JINJA_MACROS_DEFINITIONS, str(exc))) |
362
|
|
|
raise RuntimeError(msg) |
363
|
|
|
mapping = _identify_special_macro_mapping(substitutions_dict) |
364
|
|
|
special_macros = rename_items(macro_definitions, mapping) |
365
|
|
|
substitutions_dict.update(macro_definitions) |
366
|
|
|
substitutions_dict.update(special_macros) |
367
|
|
|
return open_and_expand_yaml(yaml_file, substitutions_dict) |
368
|
|
|
|
369
|
|
|
|
370
|
|
|
def open_yaml(yaml_file): |
371
|
|
|
""" |
372
|
|
|
Open given file-like object and parse it as YAML |
373
|
|
|
without performing any kind of template processing |
374
|
|
|
|
375
|
|
|
See also: _open_yaml |
376
|
|
|
""" |
377
|
|
|
with codecs.open(yaml_file, "r", "utf8") as stream: |
378
|
|
|
yaml_contents = _open_yaml(stream) |
379
|
|
|
return yaml_contents |
380
|
|
|
|
381
|
|
|
|
382
|
|
|
def open_environment_yamls(build_config_yaml, product_yaml): |
383
|
|
|
contents = open_yaml(build_config_yaml) |
384
|
|
|
contents.update(open_yaml(product_yaml)) |
385
|
|
|
contents.update(get_implied_properties(contents)) |
386
|
|
|
return contents |
387
|
|
|
|
388
|
|
|
|
389
|
|
|
def required_yaml_key(yaml_contents, key): |
390
|
|
|
if key in yaml_contents: |
391
|
|
|
return yaml_contents[key] |
392
|
|
|
|
393
|
|
|
raise ValueError("%s is required but was not found in:\n%s" % |
394
|
|
|
(key, repr(yaml_contents))) |
395
|
|
|
|