diff --git a/.github/workflows/gh-pages.yaml b/.github/workflows/gh-pages.yaml index e81d380e99a..16cd1bcb91d 100644 --- a/.github/workflows/gh-pages.yaml +++ b/.github/workflows/gh-pages.yaml @@ -14,7 +14,7 @@ jobs: PAGES_DIR: __pages steps: - name: Install Deps - run: dnf install -y cmake git ninja-build openscap-utils python3-pyyaml python3-jinja2 python3-pytest ansible-lint libxslt python3-pip rsync + run: dnf install -y cmake git ninja-build openscap-utils python3-pyyaml python3-jinja2 python3-pytest ansible-lint libxslt python3-pip rsync python3-lxml - name: Install deps python run: pip3 install json2html - name: Checkout diff --git a/CMakeLists.txt b/CMakeLists.txt index d6306b41086..17517468ba4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,6 +137,7 @@ find_package(PythonInterp REQUIRED) find_python_module(yaml REQUIRED) find_python_module(jinja2 REQUIRED) +find_python_module(lxml) find_python_module(pytest) find_python_module(pytest_cov) find_python_module(json2html) @@ -245,6 +246,8 @@ message(STATUS "python myst-parser module (optional): ${PY_MYST_PARSER}") message(STATUS "python openpyxl module (optional): ${PY_OPENPYXL}") message(STATUS "python pandas module (optional): ${PY_PANDAS}") message(STATUS "python pcre2 module (optional): ${PY_PCRE2}") +message(STATUS "python lxml module (optional): ${PY_LXML}") + message(STATUS " ") message(STATUS "Build options:") diff --git a/build-scripts/generate_guides.py b/build-scripts/generate_guides.py new file mode 100755 index 00000000000..2428a7e7d12 --- /dev/null +++ b/build-scripts/generate_guides.py @@ -0,0 +1,158 @@ +#!/usr/bin/python3 + +import argparse +import collections +import lxml.etree as ET +import os + +from ssg.constants import OSCAP_PROFILE, PREFIX_TO_NS +import ssg.build_guides + +BenchmarkData = collections.namedtuple( + "BenchmarkData", ["title", "profiles", "product"]) +XSLT_PATH = "/usr/share/openscap/xsl/xccdf-guide.xsl" + + +def get_benchmarks(ds, product): + benchmarks = {} + benchmark_xpath = "./ds:component/xccdf-1.2:Benchmark" + for benchmark_el in ds.xpath(benchmark_xpath, namespaces=PREFIX_TO_NS): + benchmark_id = benchmark_el.get("id") + title = benchmark_el.xpath( + "./xccdf-1.2:title", namespaces=PREFIX_TO_NS)[0].text + profiles = get_profiles(benchmark_el) + benchmarks[benchmark_id] = BenchmarkData(title, profiles, product) + return benchmarks + + +def get_profiles(benchmark_el): + profiles = {} + for profile_el in benchmark_el.xpath( + "./xccdf-1.2:Profile", namespaces=PREFIX_TO_NS): + profile_id = profile_el.get("id") + profile_title = profile_el.xpath( + "./xccdf-1.2:title", namespaces=PREFIX_TO_NS)[0].text + profiles[profile_id] = profile_title + return profiles + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--data-stream", required=True, + help="Path to a SCAP source data stream, eg. 'ssg-rhel9-ds.xml'") + parser.add_argument( + "--oscap-version", required=True, + help=f"Version of OpenSCAP that owns {XSLT_PATH}, eg. 1.3.8") + parser.add_argument( + "--product", required=True, + help="Product ID, eg. rhel9") + parser.add_argument( + "--output-dir", required=True, + help="Path to the directory where to generate the output files" + ", eg. 'build/guides'") + args = parser.parse_args() + return args + + +def make_params(oscap_version, benchmark_id, profile_id): + params = { + "oscap-version": ET.XSLT.strparam(oscap_version), + "benchmark_id": ET.XSLT.strparam(benchmark_id), + "profile_id": ET.XSLT.strparam(profile_id) + } + return params + + +def make_output_file_name(profile_id, product): + short_profile_id = profile_id.replace(OSCAP_PROFILE, "") + output_file_name = "ssg-%s-guide-%s.html" % (product, short_profile_id) + return output_file_name + + +def make_output_file_path(profile_id, product, output_dir): + output_file_name = make_output_file_name(profile_id, product) + output_file_path = os.path.join(output_dir, output_file_name) + return output_file_path + + +def generate_html_guide(ds, transform, params, output_file_path): + html = transform(ds, **params) + html.write_output(output_file_path) + + +def make_index_options(benchmarks): + index_options = {} + for benchmark_id, benchmark_data in benchmarks.items(): + options = [] + for profile_id, profile_title in benchmark_data.profiles.items(): + guide_file_name = make_output_file_name( + profile_id, benchmark_data.product) + data_benchmark_id = "" if len(benchmarks) == 1 else benchmark_id + option = ( + f"") + options.append(option) + index_options[benchmark_id] = options + return index_options + + +def make_index_links(benchmarks): + index_links = [] + for benchmark_id, benchmark_data in benchmarks.items(): + for profile_id, profile_title in benchmark_data.profiles.items(): + guide_file_name = make_output_file_name( + profile_id, benchmark_data.product) + a_target = ( + f"" + f"{profile_title} in {benchmark_id}") + index_links.append(a_target) + return index_links + + +def make_index_initial_src(benchmarks): + for benchmark_data in benchmarks.values(): + for profile_id in benchmark_data.profiles: + return make_output_file_name(profile_id, benchmark_data.product) + return None + + +def generate_html_index(benchmarks, data_stream, output_dir): + benchmark_titles = {id_: data.title for id_, data in benchmarks.items()} + product = list(benchmarks.values())[0].product + input_basename = os.path.basename(data_stream) + index_links = make_index_links(benchmarks) + index_options = make_index_options(benchmarks) + index_initial_src = make_index_initial_src(benchmarks) + index_source = ssg.build_guides.build_index( + benchmark_titles, input_basename, index_links, index_options, + index_initial_src) + output_path = make_output_file_path("index", product, output_dir) + with open(output_path, "wb") as f: + f.write(index_source.encode("utf-8")) + + +def generate_html_guides(ds, benchmarks, oscap_version, output_dir): + xslt = ET.parse(XSLT_PATH) + transform = ET.XSLT(xslt) + for benchmark_id, benchmark_data in benchmarks.items(): + for profile_id in benchmark_data.profiles: + params = make_params(oscap_version, benchmark_id, profile_id) + output_file_path = make_output_file_path( + profile_id, benchmark_data.product, output_dir) + generate_html_guide(ds, transform, params, output_file_path) + + +def main(): + args = parse_args() + ds = ET.parse(args.data_stream) + benchmarks = get_benchmarks(ds, args.product) + if not os.path.exists(args.output_dir): + os.mkdir(args.output_dir) + generate_html_guides(ds, benchmarks, args.oscap_version, args.output_dir) + generate_html_index(benchmarks, args.data_stream, args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/cmake/SSGCommon.cmake b/cmake/SSGCommon.cmake index 033cca7f03b..7a90358a1d3 100644 --- a/cmake/SSGCommon.cmake +++ b/cmake/SSGCommon.cmake @@ -598,13 +598,23 @@ endmacro() # Build per-product HTML guides to see the status of various profiles and # rules in the generated XCCDF guides. macro(ssg_build_html_guides PRODUCT) - add_custom_command( - OUTPUT "${CMAKE_BINARY_DIR}/guides/ssg-${PRODUCT}-guide-index.html" - COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/guides" - COMMAND env "PYTHONPATH=$ENV{PYTHONPATH}" "${PYTHON_EXECUTABLE}" "${SSG_BUILD_SCRIPTS}/build_all_guides.py" --input "${CMAKE_BINARY_DIR}/ssg-${PRODUCT}-ds.xml" --output "${CMAKE_BINARY_DIR}/guides" build - DEPENDS generate-ssg-${PRODUCT}-ds.xml "${CMAKE_BINARY_DIR}/ssg-${PRODUCT}-ds.xml" - COMMENT "[${PRODUCT}-guides] generating HTML guides for all profiles in ssg-${PRODUCT}-ds.xml" - ) + if(PYTHON_VERSION_MAJOR GREATER 2 AND PY_LXML) + add_custom_command( + OUTPUT "${CMAKE_BINARY_DIR}/guides/ssg-${PRODUCT}-guide-index.html" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/guides" + COMMAND env "PYTHONPATH=$ENV{PYTHONPATH}" "${PYTHON_EXECUTABLE}" "${SSG_BUILD_SCRIPTS}/generate_guides.py" --data-stream "${CMAKE_BINARY_DIR}/ssg-${PRODUCT}-ds.xml" --product "${PRODUCT}" --output-dir "${CMAKE_BINARY_DIR}/guides" --oscap-version "${OSCAP_VERSION}" + DEPENDS generate-ssg-${PRODUCT}-ds.xml "${CMAKE_BINARY_DIR}/ssg-${PRODUCT}-ds.xml" + COMMENT "[${PRODUCT}-guides] generating HTML guides for all profiles in ssg-${PRODUCT}-ds.xml" + ) + else() + add_custom_command( + OUTPUT "${CMAKE_BINARY_DIR}/guides/ssg-${PRODUCT}-guide-index.html" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/guides" + COMMAND env "PYTHONPATH=$ENV{PYTHONPATH}" "${PYTHON_EXECUTABLE}" "${SSG_BUILD_SCRIPTS}/build_all_guides.py" --input "${CMAKE_BINARY_DIR}/ssg-${PRODUCT}-ds.xml" --output "${CMAKE_BINARY_DIR}/guides" build + DEPENDS generate-ssg-${PRODUCT}-ds.xml "${CMAKE_BINARY_DIR}/ssg-${PRODUCT}-ds.xml" + COMMENT "[${PRODUCT}-guides] generating HTML guides for all profiles in ssg-${PRODUCT}-ds.xml" + ) + endif() add_custom_target( generate-ssg-${PRODUCT}-guide-index.html DEPENDS "${CMAKE_BINARY_DIR}/guides/ssg-${PRODUCT}-guide-index.html" diff --git a/docs/manual/developer/07_understanding_build_system.md b/docs/manual/developer/07_understanding_build_system.md index 6f966b6599d..31664a34d1e 100644 --- a/docs/manual/developer/07_understanding_build_system.md +++ b/docs/manual/developer/07_understanding_build_system.md @@ -112,6 +112,7 @@ refer to their help text for more information and usage: base product. - `expand_jinja.py` -- helper script used by the BATS (Bash unit test framework) to expand Jinja in test scripts. +- `generate_guides.py` -- Generate HTML guides and HTML index for every profile in the built SCAP source data stream. - `generate_man_page.py` -- generates the ComplianceAsCode man page. - `generate_profile_remediations.py` -- Generate profile oriented Bash remediation scripts or profile oriented Ansible Playbooks from the built SCAP source data stream. The output is similar to the output of the `oscap xccdf generate fix` command, but the tool `generate_profile_remediations.py` generates the scripts or Playbooks for all profiles in the given SCAP source data stream at once. - `profile_tool.py` -- utility script to generate statistics about profiles