From 592d2df7fd1ecba9250e77ec998de31a74b1a7d9 Mon Sep 17 00:00:00 2001 From: Evgeny Kolesnikov Date: Tue, 6 Aug 2024 14:00:04 +0200 Subject: [PATCH] Enable autotailor to process multi-profile JSON Tailorings The script will now accept multiple profiles in JSON Tailorings and also will use command-line options to update existing profiles or will create a new profile in the XCCDF tailoring. --- tests/utils/autotailor_integration_test.sh | 19 +- tests/utils/custom.json | 10 ++ tests/utils/test_autotailor.py | 50 +++--- utils/autotailor | 200 ++++++++++++--------- utils/autotailor.8 | 8 +- 5 files changed, 173 insertions(+), 114 deletions(-) diff --git a/tests/utils/autotailor_integration_test.sh b/tests/utils/autotailor_integration_test.sh index e0681783e7..a535591ca5 100755 --- a/tests/utils/autotailor_integration_test.sh +++ b/tests/utils/autotailor_integration_test.sh @@ -70,7 +70,6 @@ assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R4"]/result[text()="notselected"]' assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R4" and @severity="high"]' - # select additional rule R4 and change its role to "unchecked" python3 $autotailor --id-namespace "com.example.www" --select R4 --rule-role R4=unchecked $ds $original_profile > $tailoring $OSCAP xccdf eval --profile P1_customized --progress --tailoring-file $tailoring --results $result $ds @@ -83,7 +82,6 @@ assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R4"]/result[text()="notchecked"]' assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R4" and @role="unchecked"]' - # select additional rule R3; the customized profile will have a special profile ID customized_profile="xccdf_com.pink.elephant_profile_pineapple" python3 $autotailor --new-profile-id $customized_profile --id-namespace "com.example.www" --select R3 $ds $original_profile > $tailoring @@ -113,7 +111,7 @@ assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R3"]/result[text()="notselected"]' assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R4"]/result[text()="notselected"]' -# use JSON tailoring +# use JSON tailoring (P1) python3 $autotailor $ds --id-namespace "com.example.www" --json-tailoring $json_tailoring > $tailoring $OSCAP xccdf eval --profile JSON_P1 --progress --tailoring-file $tailoring --results $result $ds assert_exists 1 '/Benchmark/TestResult/set-value[@idref="xccdf_com.example.www_value_V1" and text()="New Value"]' @@ -124,3 +122,18 @@ assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R3" and @severity="unknown"]' assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R4"]/result[text()="notselected"]' assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R4" and @role="unchecked"]' + +# use JSON tailoring (P11) +python3 $autotailor --id-namespace "com.example.www" --json-tailoring $json_tailoring $ds > $tailoring +$OSCAP xccdf eval --profile JSON_P11 --progress --tailoring-file $tailoring --results $result $ds +assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R3"]/result[text()="pass"]' + +# use JSON tailoring (P11) with command-line override +python3 $autotailor --id-namespace "com.example.www" --json-tailoring $json_tailoring --tailored-profile-id=JSON_P11 --unselect R3 $ds > $tailoring +$OSCAP xccdf eval --profile JSON_P11 --progress --tailoring-file $tailoring --results $result $ds +assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R3"]/result[text()="notselected"]' + +# use JSON tailoring (P11) with a new profile from the command line +python3 $autotailor --id-namespace "com.example.www" --json-tailoring $json_tailoring --tailored-profile-id=CMDL_P --select R3 $ds $original_profile > $tailoring +$OSCAP xccdf eval --profile CMDL_P --progress --tailoring-file $tailoring --results $result $ds +assert_exists 1 '/Benchmark/TestResult/rule-result[@idref="xccdf_com.example.www_rule_R3"]/result[text()="pass"]' \ No newline at end of file diff --git a/tests/utils/custom.json b/tests/utils/custom.json index 40aba53196..88134caa78 100644 --- a/tests/utils/custom.json +++ b/tests/utils/custom.json @@ -30,6 +30,16 @@ "option_id": "some" } } + }, + { + "id": "JSON_P11", + "title": "JSON Tailored Profile P11", + "base_profile_id": "P1", + "rules": { + "R3": { + "evaluate": true + } + } } ] } diff --git a/tests/utils/test_autotailor.py b/tests/utils/test_autotailor.py index 202d5b9281..f00bcf7498 100644 --- a/tests/utils/test_autotailor.py +++ b/tests/utils/test_autotailor.py @@ -29,43 +29,43 @@ def test_is_valid_xccdf_id(): def test_full_id(): - t = autotailor.Tailoring() - assert t._full_rule_id("accounts_tmout") == \ + p = autotailor.Profile() + assert p._full_rule_id("accounts_tmout") == \ "xccdf_org.ssgproject.content_rule_accounts_tmout" - assert t._full_rule_id( + assert p._full_rule_id( "xccdf_org.ssgproject.content_rule_accounts_tmout") == \ "xccdf_org.ssgproject.content_rule_accounts_tmout" - assert t._full_profile_id("stig") == \ + assert p._full_profile_id("stig") == \ "xccdf_org.ssgproject.content_profile_stig" - assert t._full_profile_id( + assert p._full_profile_id( "xccdf_org.ssgproject.content_profile_stig") == \ "xccdf_org.ssgproject.content_profile_stig" - assert t._full_var_id("var_crypto_policy") == \ + assert p._full_var_id("var_crypto_policy") == \ "xccdf_org.ssgproject.content_value_var_crypto_policy" - assert t._full_var_id( + assert p._full_var_id( "xccdf_org.ssgproject.content_value_var_crypto_policy") == \ "xccdf_org.ssgproject.content_value_var_crypto_policy" def test_customized_profile_id(): - t = autotailor.Tailoring() - t.extends = "stig" - assert t.profile_id == "stig_customized" - t.profile_id = "my_cool_profile" - assert t.profile_id == "my_cool_profile" + p = autotailor.Profile() + p.extends = "stig" + assert p.profile_id == "stig_customized" + p.profile_id = "my_cool_profile" + assert p.profile_id == "my_cool_profile" def test_refine_rule(): - t = autotailor.Tailoring() + p = autotailor.Profile() with pytest.raises(ValueError) as e: - t.refine_rule("selinux_state", "severity", "high") + p.refine_rule("selinux_state", "severity", "high") assert str(e.value) == "Rule id 'selinux_state' is invalid!" with pytest.raises(ValueError) as e: - t.refine_rule( + p.refine_rule( "xccdf_org.ssgproject.content_rule_accounts_tmout", "foo", "bar") assert str(e.value) == "Unsupported refine-rule attribute foo" with pytest.raises(ValueError) as e: - t.refine_rule( + p.refine_rule( "xccdf_org.ssgproject.content_rule_accounts_tmout", "role", "mnau") assert str(e.value) == ( @@ -73,7 +73,7 @@ def test_refine_rule(): "_tmout' to 'mnau'. Allowed role values are: \"full\", \"unscored\", " "\"unchecked\".") with pytest.raises(ValueError) as e: - t.refine_rule( + p.refine_rule( "xccdf_org.ssgproject.content_rule_accounts_tmout", "severity", "mnau") assert str(e.value) == ( @@ -81,16 +81,16 @@ def test_refine_rule(): "accounts_tmout' to 'mnau'. Allowed severity values are: \"unknown\", " "\"info\", \"low\", \"medium\", \"high\".") fav = "xccdf_org.ssgproject.content_rule_accounts_tmout" - t.refine_rule(fav, "severity", "high") - assert t.rule_refinements(fav, "severity") == "high" - t.refine_rule(fav, "role", "full") - assert t.rule_refinements(fav, "severity") == "high" - assert t.rule_refinements(fav, "role") == "full" + p.refine_rule(fav, "severity", "high") + assert p.rule_refinements(fav, "severity") == "high" + p.refine_rule(fav, "role", "full") + assert p.rule_refinements(fav, "severity") == "high" + assert p.rule_refinements(fav, "role") == "full" with pytest.raises(ValueError) as e: - t.refine_rule(fav, "severity", "low") + p.refine_rule(fav, "severity", "low") assert str(e.value) == ( "Can't refine severity of rule 'xccdf_org.ssgproject.content_rule_" "accounts_tmout' to 'low'. This rule severity is already refined to " "'high'.") - assert t.rule_refinements(fav, "severity") == "high" - assert t.rule_refinements(fav, "role") == "full" + assert p.rule_refinements(fav, "severity") == "high" + assert p.rule_refinements(fav, "role") == "full" diff --git a/utils/autotailor b/utils/autotailor index 4bd751dd37..d5802755c6 100755 --- a/utils/autotailor +++ b/utils/autotailor @@ -52,21 +52,18 @@ def is_valid_xccdf_id(string): string) is not None -class Tailoring: +class Profile: def __init__(self): - self.id = "xccdf_auto_tailoring_default" self.reverse_dns = DEFAULT_REVERSE_DNS - self.version = 1 self._profile_id = None self.extends = "" - self.original_ds_filename = "" self.profile_title = "" - self.value_changes = [] - self.rules_to_select = [] - self.rules_to_unselect = [] - self.groups_to_select = [] - self.groups_to_unselect = [] + self.value_changes = set() + self.rules_to_select = set() + self.rules_to_unselect = set() + self.groups_to_select = set() + self.groups_to_unselect = set() self._rule_refinements = collections.defaultdict(dict) self._value_refinements = collections.defaultdict(dict) @@ -75,7 +72,7 @@ class Tailoring: if self._profile_id is not None: return self._profile_id else: - return self.extends + "_customized" + return self.extends + DEFAULT_PROFILE_SUFFIX @profile_id.setter def profile_id(self, new_profile_id): @@ -100,7 +97,7 @@ class Tailoring: if not is_valid_xccdf_id(rule_id): msg = f"Rule id '{rule_id}' is invalid!" raise ValueError(msg) - enumeration = Tailoring._find_rule_enumeration(attribute) + enumeration = Profile._find_rule_enumeration(attribute) if value in enumeration: return allowed = ", ".join(map(quote, enumeration)) @@ -138,15 +135,18 @@ class Tailoring: raise ValueError(msg) def refine_rule(self, rule_id, attribute, value): - Tailoring._validate_rule_refinement_params(rule_id, attribute, value) + Profile._validate_rule_refinement_params(rule_id, attribute, value) self._prevent_duplicate_rule_refinement(attribute, rule_id, value) self._rule_refinements[rule_id][attribute] = value def refine_value(self, value_id, attribute, value): - Tailoring._validate_value_refinement_params(value_id, attribute, value) + Profile._validate_value_refinement_params(value_id, attribute, value) self._prevent_duplicate_value_refinement(attribute, value_id, value) self._value_refinements[value_id][attribute] = value + def add_value_change(self, varname, value): + self.value_changes.add((varname, value)) + def change_rule_attribute(self, rule_id, attribute, value): full_rule_id = self._full_rule_id(rule_id) self.refine_rule(full_rule_id, attribute, value) @@ -193,9 +193,6 @@ class Tailoring: def _full_group_id(self, string): return self._full_id(string, "group") - def add_value_change(self, varname, value): - self.value_changes.append((varname, value)) - def _add_group_select_operations(self, container_element): for group_id in self.groups_to_select: change = ET.SubElement(container_element, "{%s}select" % NS) @@ -238,50 +235,14 @@ class Tailoring: for attr, val in refinements.items(): ref_value_el.set(attr, val) - def to_xml(self, location=None): - root = ET.Element("{%s}Tailoring" % NS) - root.set("id", self.id) - - benchmark = ET.SubElement(root, "{%s}benchmark" % NS) - datastream_uri = pathlib.Path( - self.original_ds_filename).absolute().as_uri() - benchmark.set("href", datastream_uri) - - version = ET.SubElement(root, "{%s}version" % NS) - version.set("time", datetime.datetime.now().isoformat()) - version.text = str(self.version) - - profile = ET.SubElement(root, "{%s}Profile" % NS) - profile.set("id", self._full_profile_id(self.profile_id)) - profile.set("extends", self._full_profile_id(self.extends)) - - # Title has to be there due to the schema definition. - title = ET.SubElement(profile, "{%s}title" % NS) - if self.profile_title: - title.set("override", "true") - else: - title.set("override", "false") - title.text = self.profile_title - - self._add_group_select_operations(profile) - self._add_rule_select_operations(profile) - self._add_value_overrides(profile) - self.rule_refinements_to_xml(profile) - self.value_refinements_to_xml(profile) - - root_str = ET.tostring(root) - pretty_xml = xml.dom.minidom.parseString(root_str).toprettyxml() - with open(location, "w") if location != "-" else sys.stdout as f: - f.write(pretty_xml) - def _import_groups_from_tailoring(self, tailoring): if "groups" in tailoring: for group_id, props in tailoring["groups"].items(): if "evaluate" in props: if props["evaluate"]: - self.groups_to_select.append(group_id) + self.groups_to_select.add(group_id) else: - self.groups_to_unselect.append(group_id) + self.groups_to_unselect.add(group_id) def _import_variables_from_tailoring(self, tailoring): if "variables" in tailoring: @@ -296,32 +257,99 @@ class Tailoring: for rule_id, props in tailoring["rules"].items(): if "evaluate" in props: if props["evaluate"]: - self.rules_to_select.append(rule_id) + self.rules_to_select.add(rule_id) else: - self.rules_to_unselect.append(rule_id) + self.rules_to_unselect.add(rule_id) for attr in ATTRIBUTES: if attr in props: self.change_rule_attribute(rule_id, attr, props[attr]) - def import_json_tailoring(self, json_tailoring): - with open(json_tailoring, "r") as jf: - all_tailorings = json.load(jf) + def to_xml(self, root): + profile = ET.SubElement(root, "{%s}Profile" % NS) + profile.set("id", self._full_profile_id(self.profile_id)) + profile.set("extends", self._full_profile_id(self.extends)) - if 'profiles' in all_tailorings and all_tailorings['profiles']: - if len(all_tailorings['profiles']) > 1: - raise ValueError("The autotailor tool currently does not support multi-profile JSON tailoring.") - tailoring = all_tailorings['profiles'][0] + # Title has to be there due to the schema definition. + title = ET.SubElement(profile, "{%s}title" % NS) + if self.profile_title: + title.set("override", "true") else: - raise ValueError("JSON Tailoring does not define any profiles.") + title.set("override", "false") + title.text = self.profile_title + + self._add_group_select_operations(profile) + self._add_rule_select_operations(profile) + self._add_value_overrides(profile) + self.rule_refinements_to_xml(profile) + self.value_refinements_to_xml(profile) + + def import_json_tailoring_profile(self, profile_dict): + self.extends = profile_dict["base_profile_id"] + + self.profile_id = profile_dict.get("id", self.profile_id) + self.profile_title = profile_dict.get("title", self.profile_title) + + self._import_groups_from_tailoring(profile_dict) + self._import_rules_from_tailoring(profile_dict) + self._import_variables_from_tailoring(profile_dict) + + +class Tailoring: + def __init__(self): + self.reverse_dns = DEFAULT_REVERSE_DNS + self.id = "xccdf_auto_tailoring_default" + self.version = 1 + self.original_ds_filename = "" - self.extends = tailoring["base_profile_id"] + self.profiles = [] + + def get_or_create_tailored_profile_with_id(self, profile_id): + if profile_id is not None: + for profile in self.profiles: + if profile.profile_id == profile_id: + return profile + profile = Profile() + if profile_id is not None: + profile.profile_id = profile_id + profile.reverse_dns = self.reverse_dns + self.profiles.append(profile) + return profile + + def to_xml(self, root): + root.set("id", self.id) + + benchmark = ET.SubElement(root, "{%s}benchmark" % NS) + datastream_uri = pathlib.Path( + self.original_ds_filename).absolute().as_uri() + benchmark.set("href", datastream_uri) - self.profile_id = tailoring.get("id", self.profile_id) - self.profile_title = tailoring.get("title", self.profile_title) + version = ET.SubElement(root, "{%s}version" % NS) + version.set("time", datetime.datetime.now().isoformat()) + version.text = str(self.version) - self._import_groups_from_tailoring(tailoring) - self._import_rules_from_tailoring(tailoring) - self._import_variables_from_tailoring(tailoring) + for profile in self.profiles: + profile.to_xml(root) + + def as_xml_string(self, location=None): + root = ET.Element("{%s}Tailoring" % NS) + self.to_xml(root) + root_str = ET.tostring(root) + pretty_xml = xml.dom.minidom.parseString(root_str).toprettyxml() + with open(location, "w") if location != "-" else sys.stdout as f: + f.write(pretty_xml) + + def import_json_tailoring(self, json_tailoring): + with open(json_tailoring, "r") as jf: + tailoring_dict = json.load(jf) + + if 'profiles' in tailoring_dict and tailoring_dict['profiles']: + for profile_dict in tailoring_dict['profiles']: + profile = Profile() + profile.reverse_dns = self.reverse_dns + profile.import_json_tailoring_profile(profile_dict) + self.profiles.append(profile) + else: + raise ValueError("JSON Tailoring does not define any profiles.") def get_parser(): @@ -339,7 +367,9 @@ def get_parser(): parser.add_argument( "-j", "--json-tailoring", metavar="JSON_TAILORING_FILENAME", default="", help="JSON Tailoring (https://github.com/ComplianceAsCode/schemas/blob/main/tailoring/schema.json) " - "filename.") + "filename. When a JSON tailoring is accompanied with additional adjustments from the command-line" + "options either a new profile would be added to the resulting XCCDF tailoring file or the profile" + "with --tailored-profile-id identifier, if present, will be updated accordingly.") parser.add_argument( "--title", default="", help="Title of the new profile.") @@ -390,7 +420,7 @@ def get_parser(): help="Specify what rules to unselect. " "The argument works the same way as the --select argument.") parser.add_argument( - "-p", "--new-profile-id", + "-p", "--tailored-profile-id", "--new-profile-id", help="Specify the ID of the tailored profile. " "The ID of the new profile can be either its full ID, or the suffix, " "in which case the 'xccdf__profile_' prefix will be " @@ -419,19 +449,19 @@ if __name__ == "__main__": if args.json_tailoring: t.import_json_tailoring(args.json_tailoring) - if args.profile: - t.extends = args.profile - if args.new_profile_id: - t.profile_id = args.new_profile_id - if args.title: - t.profile_title = args.title + if args.profile or (args.json_tailoring and args.tailored_profile_id): + p = t.get_or_create_tailored_profile_with_id(args.tailored_profile_id) + p.extends = args.profile + if args.title: + p.profile_title = args.title - t.rules_to_select.extend(args.select) - t.rules_to_unselect.extend(args.unselect) + p.rules_to_select.update(args.select) + p.rules_to_unselect.update(args.unselect) + p.rules_to_select.difference_update(p.rules_to_unselect) - t.change_values(args.var_value) - t.change_selectors(args.var_select) - t.change_roles(args.rule_role) - t.change_severities(args.rule_severity) + p.change_values(args.var_value) + p.change_selectors(args.var_select) + p.change_roles(args.rule_role) + p.change_severities(args.rule_severity) - t.to_xml(args.output) + t.as_xml_string(args.output) diff --git a/utils/autotailor.8 b/utils/autotailor.8 index 4e13ad0e9c..e313123f03 100644 --- a/utils/autotailor.8 +++ b/utils/autotailor.8 @@ -57,16 +57,22 @@ Specify the rule to select. The rule ID can be either full, or just the suffix, Specify the rule to unselect. The argument works the same way as the --select argument. .RE .TP -\fB-p NEW_PROFILE_ID, --new-profile-id NEW_PROFILE_ID\fR +\fB-p TAILORED_PROFILE_ID, --tailored-profile-id TAILORED_PROFILE_ID\fR .RS Specify the ID of the tailored profile. The ID of the new profile can be either its full ID, or the suffix, in which case the 'xccdf__profile_' prefix will be prepended internally. If left out, the new ID will be obtained by appending '_customized' to the tailored profile ID. .RE .TP +\fB--new-profile-id NEW_PROFILE_ID\fR +.RS +Synonym of --tailored-profile-id. +.RE +.TP \fB--json-tailoring JSON_TAILORING_FILE\fR .RS Import tailoring from a JSON file (https://github.com/ComplianceAsCode/schemas/tree/main/tailoring). This option makes BASE_PROFILE_ID positional argument optional. However, data passed in the command line options takes precedence over JSON contents, including the BASE_PROFILE_ID argument. +When a JSON tailoring is accompanied with additional adjustments from the command-line options either a new profile would be added to the resulting XCCDF tailoring file or the profile with TAILORED_PROFILE_ID identifier, if present, will be updated accordingly. .RE .SH USAGE