#!/usr/bin/python3

from __future__ import print_function

import sys
import optparse
import os.path

"""
This script can verify consistency of references (linkage) between XCCDF and
OVAL, and also search based on other criteria such as existence of policy
references in XCCDF.

Purpose:
    This script can be used to perform various checks on the XCCDF
    and OVAL that is generated by the Makefile. This script limits its focus to
    the files in the src/output directory. This script is to be
    used as a development tool to aid in the creation of concise
    and structurally correct XCCDF and OVAL.

Intent:
    Help XCCDF and OVAL developers spot potential mistakes in the
    XCCDF and OVAL content that is generated by the Makefile.

Usage:
    ./verify_references.py --all-checks ssg-rhel9-ds.xml

    You may find this informative as well:

    ./verify_references.py -h
"""

import ssg.constants
import ssg.xml

xccdf_ns = ssg.constants.XCCDF12_NS
oval_ns = ssg.constants.oval_namespace
ocil_cs = ssg.constants.ocil_cs
sce_cs = ssg.constants.SCE_SYSTEM

# we use these strings to look for references within the XCCDF rules
nist_ref_href = "http://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-53r4.pdf"
disa_ref_href = "https://public.cyber.mil/stigs/cci/"

# default exit value - success
exit_value = 0


def parse_options():
    usage = "usage: %prog [options] xccdf_file"
    parser = optparse.OptionParser(usage=usage, version="%prog ")
    # only some options are on by default
    parser.add_option("-p", "--profile", default=False,
                      action="store", dest="profile_name",
                      help="act on Rules from this XCCDF Profile only")
    parser.add_option("--rules-with-invalid-checks", default=False,
                      action="store_true", dest="rules_with_invalid_checks",
                      help="print XCCDF Rules that reference an invalid/nonexistent check")
    parser.add_option("--rules-without-checks", default=False,
                      action="store_true", dest="rules_without_checks",
                      help="print XCCDF Rules that do not include a check")
    parser.add_option("--rules-without-severity", default=False,
                      action="store_true", dest="rules_without_severity",
                      help="print XCCDF Rules that do not include a severity")
    parser.add_option("--rules-without-nistrefs", default=False,
                      action="store_true", dest="rules_without_nistrefs",
                      help="print XCCDF Rules which do not include any NIST 800-53 references")
    parser.add_option("--rules-without-disarefs", default=False,
                      action="store_true", dest="rules_without_disarefs",
                      help="print XCCDF Rules which do not include any DISA CCI references")
    parser.add_option("--rules-with-nistrefs-outside-profile", default=False,
                      action="store_true", dest="nistrefs_not_in_profile",
                      help="print XCCDF Rules which have a NIST reference, but are not part of the Profile specified")
    parser.add_option("--rules-with-disarefs-outside-profile", default=False,
                      action="store_true", dest="disarefs_not_in_profile",
                      help="print XCCDF Rules which have a DISA CCI reference, but are not part of the Profile specified")
    parser.add_option("--ovaldefs-unused", default=False,
                      action="store_true", dest="ovaldefs_unused",
                      help="print OVAL definitions which are not used by any XCCDF Rule")
    parser.add_option("--base-dir", default=False, action="store", dest="base_dir",
                      help="path to the build directory")
    parser.add_option("--all-checks", default=False, action="store_true",
                      dest="all_checks",
                      help="perform all checks on the given XCCDF file")
    (options, args) = parser.parse_args()
    if len(args) < 1:
        parser.print_help()
        sys.exit(1)
    return (options, args)


def get_ovalfiles(checks):
    global exit_value
    # Iterate over all checks, grab the OVAL files referenced within
    ovalfiles = set()
    for check in checks:
        if check.get("system") == oval_ns:
            checkcontentref = check.find("./{%s}check-content-ref" % xccdf_ns)
            href = checkcontentref.get("href")
            # Include the file in the particular check system only if it's NOT
            # a remotely located file (to allow OVAL checks to reference http://
            # and https:// formatted URLs) or a file known as mapped to remote file
            if not is_remote_feed(href):
                ovalfiles.add(href)
        elif check.get("system") != ocil_cs and check.get("system") != sce_cs:
            print("ERROR: Non-OVAL checking system found: %s"
                  % (check.get("system")))
            exit_value = 1
    return ovalfiles


def get_profileruleids(xccdftree, profile_name):
    ruleids = []

    while profile_name:
        profile = None
        for el in xccdftree.findall(".//{%s}Profile" % xccdf_ns):
            if el.get("id") != profile_name:
                continue
            profile = el
            break

        if profile is None:
            sys.exit("Specified XCCDF Profile %s was not found.")
        for select in profile.findall(".//{%s}select" % xccdf_ns):
            ruleids.append(select.get("idref"))
        profile_name = profile.get("extends")

    return ruleids


def is_remote_feed(href):
    return href.startswith("http://") or \
            href.startswith("https://") or \
            href.startswith("security-data-oval-v2-") or \
            href.startswith("security-data-oval-com.redhat.rhsa-") or \
            href.startswith("security-oval-com.oracle") or \
            href.startswith("oval-com.ubuntu") or \
            href.startswith("pub-projects-security-oval-suse") or \
            href.startswith('security-oval-oval-definitions-bookworm') or \
            href.startswith("oval-org.almalinux")


def main():
    global exit_value
    (options, args) = parse_options()
    xccdffilename = args[0]

    # extract all of the rules within the xccdf
    xccdftree = ssg.xml.ElementTree.parse(xccdffilename)
    rules = xccdftree.findall(".//{%s}Rule" % xccdf_ns)

    # if a profile was specified, get rid of any Rules that aren't in it
    if options.profile_name:
        profile_ruleids = get_profileruleids(xccdftree, options.profile_name)
        prunedrules = rules[:]
        for rule in rules:
            if rule.get("id") not in profile_ruleids:
                prunedrules.remove(rule)
        rules = prunedrules

    # step over xccdf file, and find referenced oval files
    checks = xccdftree.findall(".//{%s}check" % xccdf_ns)
    ovalfiles = get_ovalfiles(checks)

    # this script only supports the inclusion of one OVAL file
    if len(ovalfiles) > 1:
        sys.exit("Referencing more than one OVAL file is not yet " +
                 "supported by this script.")

    # find important elements within the XCCDF and the OVAL
    ovalfile = os.path.join(os.path.dirname(xccdffilename), ovalfiles.pop())
    ovaltree = ssg.xml.ElementTree.parse(ovalfile)
    # collect all compliance checks (not inventory checks, which are
    # needed by CPE)
    ovaldefs = []
    for el in ovaltree.findall(".//{%s}definition" % oval_ns):
        if el.get("class") != "compliance":
            continue

        ovaldefs.append(el)

    ovaldef_ids = [ovaldef.get("id") for ovaldef in ovaldefs]

    oval_extenddefs = ovaltree.findall(".//{%s}extend_definition" % oval_ns)
    ovaldef_ids_extended = [oval_extenddef.get("definition_ref") for oval_extenddef in oval_extenddefs]
    ovaldef_ids_extended = list(set(ovaldef_ids_extended))

    check_content_refs = xccdftree.findall(".//{%s}check-content-ref"
                                           % xccdf_ns)
    xccdf_parent_map = dict((c, p) for p in xccdftree.iter() for c in p)
    # now we can actually do the verification work here
    if options.rules_with_invalid_checks or options.all_checks:
        for check_content_ref in check_content_refs:
            parent = xccdf_parent_map[check_content_ref]
            rule = xccdf_parent_map[parent]
            check_system = parent.get("system")
            # Skip those <check-content-ref> elements using OCIL as the checksystem
            # (since we are checking just referenced OVAL definitions)
            if check_system == ocil_cs:
                continue

            # Obtain the value of the 'href' attribute of particular
            # <check-content-ref> element
            href = check_content_ref.get("href")

            # Don't attempt to obtain refname on <check-content-ref> element
            # having its "href" attribute set either to "http://" or to
            # "https://" values (since the "name" attribute will be empty for
            # these two cases)
            # Also, skip known remote data files with CVE feeds.
            if is_remote_feed(href):
                continue

            if check_system == sce_cs:
                check_path = os.path.join(options.base_dir, href)
                if not os.path.exists(check_path):
                    msg = "ERROR: Invalid or missing SCE definition (%s) "
                    msg += "referenced by XCCDF Rule: %s"
                    msg = msg % (check_path, rule.get("id"))
                    print(msg)
                    exit_value = 1
            else:
                refname = check_content_ref.get("name")
                if refname not in ovaldef_ids:
                    print("ERROR: Invalid OVAL definition referenced by XCCDF Rule: %s"
                          % (rule.get("id")))
                    exit_value = 1

    if options.rules_without_checks or options.all_checks:
        for rule in rules:
            check = rule.find("./{%s}check" % xccdf_ns)
            if check is None:
                print("ERROR: No reference to OVAL definition in XCCDF Rule: %s"
                      % (rule.get("id")))
                exit_value = 1

    if options.rules_without_severity or options.all_checks:
        for rule in rules:
            if rule.get("severity") is None:
                print("ERROR: No severity assigned to XCCDF Rule: %s"
                      % (rule.get("id")))
                exit_value = 1

    if options.rules_without_nistrefs or options.rules_without_disarefs or options.all_checks:
        for rule in rules:
            # find all references in the current rule
            refs = rule.findall(".//{%s}reference" % xccdf_ns)
            if refs is None:
                print("ERROR: No reference assigned to XCCDF Rule: %s"
                      % (rule.get("id")))
                exit_value = 1
            else:
                # loop through the Rule's references and put their hrefs
                # in a list
                ref_href_list = [ref.get("href") for ref in refs]
                # print warning if rule does not have a NIST reference
                if (nist_ref_href not in ref_href_list) and options.rules_without_nistrefs:
                    print("ERROR: No valid NIST reference in XCCDF Rule: " +
                          rule.get("id"))
                    exit_value = 1
                # print warning if rule does not have a DISA reference
                if (disa_ref_href not in ref_href_list) and options.rules_without_disarefs:
                    print("ERROR: No valid DISA CCI reference in XCCDF Rule: " +
                          rule.get("id"))
                    exit_value = 1

    if options.disarefs_not_in_profile or options.nistrefs_not_in_profile:
        if options.profile_name is None:
            sys.exit("The options for finding Rules with a reference, "
                     "but which are not in a Profile, requires specifying a Profile.")
        allrules = xccdftree.findall(".//{%s}Rule" % xccdf_ns)
        for rule in allrules:
            # find all references in the current rule
            refs = rule.findall(".//{%s}reference" % xccdf_ns)
            ref_href_list = [ref.get("href") for ref in refs]
            # print warning if Rule is outside Profile and has a NIST reference
            if options.nistrefs_not_in_profile:
                if (nist_ref_href in ref_href_list) and (rule.get("id") not in profile_ruleids):
                    print("ERROR: XCCDF Rule found with NIST reference outside Profile %s: "
                           % options.profile_name + rule.get("id"))
                    exit_value = 1
            # print warning if Rule is outside Profile and has a DISA reference
            if options.disarefs_not_in_profile:
                if (disa_ref_href in ref_href_list) and (rule.get("id") not in profile_ruleids):
                    print("ERROR: XCCDF Rule found with DISA CCI reference outside Profile %s: "
                           % options.profile_name + rule.get("id"))
                    exit_value = 1

    if options.ovaldefs_unused or options.all_checks:
        # create a list of all of the OVAL compliance check ids that are
        # defined in the oval file
        oval_checks_list = [ovaldef.get("id") for ovaldef in ovaldefs]
        # now loop through the xccdf rules; if a rule references an oval check
        # we remove the oval check from our list
        for check_content in check_content_refs:
            # remove from the list
            if check_content.get("name") in oval_checks_list:
                oval_checks_list.remove(check_content.get("name"))
        # the list should now contain the OVAL checks that are not referenced
        # by any XCCDF rule
        oval_checks_list.sort()
        for oval_id in oval_checks_list:
            # don't print out the OVAL defs that are extended by others,
            # as they're not unused
            if oval_id not in ovaldef_ids_extended:
                print("WARNING: OVAL Check is not referenced by XCCDF: %s"
                      % (oval_id))
                # Do not treat this as error but only as a warning
                #exit_value = 1

    sys.exit(exit_value)

if __name__ == "__main__":
    main()
