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