Skip to content

Commit 5efafb1

Browse files
authored
feat: add --markdown-tree paginated output for static site generators (#700)
* feat: add --markdown-tree paginated output for static site generators Adds mscp guidance --markdown-tree <baseline> (also included in --all), which renders a baseline as a directory tree ready to drop into any CommonMark-based static site generator: Docusaurus, Starlight, MkDocs, VitePress, and similar. Output shape: build/<baseline>/markdown_tree/ index.md # overview: foreword, scope, authors 02-<section-slug>/ index.md # section description 01-<rule-slug>.md # one page per rule ... Files are NN- prefixed for stable ordering (no sidebar config needed). index.md follows the category-index convention recognised by Docusaurus and Starlight. Frontmatter is minimal (title only) so it works across generators without modification. Content reuses markdown/rule.md.jinja via a markdown_tree context flag, selecting heading-based Check/Remediation sections and a GFM pipe table for references. The single-file --markdown output path is unchanged. Manual-rule notes use > **Note:** blockquotes that render everywhere. Tests: 41 pytest tests covering mdx_escape, create_slug, render_references_md, _frontmatter, and an integration test against a real baseline asserting NN- ordering, balanced fences, no raw braces outside fenced blocks, and valid frontmatter. Validated: 104/104 (cis_lvl1) and 168/168 (disa_stig) pages compile under @mdx-js/mdx v3. Note: --markdown-tree has no short flag to avoid collision with -m/--markdown. * fix: resolve flake8 findings flagged by dev_2.0 CI The concatenated __all__ entry "PLATFORM_MAPvalidate_yaml_file" made pyflakes miss both names as exports, so it reported their imports as unused (F401). Splitting it fixes both. Also import CONFIG_PATH so its existing __all__ entry resolves, drop unused BaseModel/config imports, and clean blank-line whitespace in scap.py. * chore: add pre-commit config mirroring CI linters
1 parent 48db380 commit 5efafb1

13 files changed

Lines changed: 1016 additions & 25 deletions

File tree

.pre-commit-config.yaml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
# Mirrors the CI in .github/workflows/lint.yaml and spellcheck.yml so the same
3+
# checks run locally before a commit lands.
4+
# Run `prek install` (or `pre-commit install`)
5+
# Update with `prek autoupdate` (or `pre-commit autoupdate`)
6+
#
7+
# CI is still the source of truth, these hooks reuse the same config files
8+
# (.github/linters/*, .github/cspell/cspell.json) so that local and CI results match.
9+
10+
default_install_hook_types: [pre-commit]
11+
12+
repos:
13+
- repo: https://github.com/pycqa/flake8
14+
rev: 7.3.0
15+
hooks:
16+
- id: flake8
17+
args: ['--config', '.github/linters/tox.ini']
18+
additional_dependencies: ['flake8-bugbear==25.11.29']
19+
files: ^src/mscp/.*\.py$
20+
21+
- repo: https://github.com/adrienverge/yamllint
22+
rev: v1.38.0
23+
hooks:
24+
- id: yamllint
25+
args: ['--config-file', '.github/linters/.yamllint.yaml', '--strict']
26+
27+
- repo: https://github.com/DavidAnson/markdownlint-cli2
28+
rev: v0.22.1
29+
hooks:
30+
- id: markdownlint-cli2
31+
args: ['--config', '.github/linters/.markdownlint.yaml']
32+
33+
# Spelling: cspell, same config as the `spellcheck` CI job.
34+
# Disabled for now (run with `--hook-stage manual`)
35+
- repo: https://github.com/streetsidesoftware/cspell-cli
36+
rev: v10.0.1
37+
hooks:
38+
- id: cspell
39+
stages: [manual]
40+
args:
41+
[
42+
'--no-progress',
43+
'--no-summary',
44+
'--config',
45+
'.github/cspell/cspell.json',
46+
]

CHANGELOG.adoc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ endif::[]
1717

1818
This document provides a high-level view of the changes to the macOS Security Compliance Project.
1919

20+
== [Unreleased]
21+
22+
=== Scripts
23+
* Add `--markdown-tree` flag to `mscp guidance` - generates a paginated Markdown directory tree (one page per rule, `NN-` ordered filenames, `index.md` per section) ready to use with Docusaurus, Starlight, MkDocs, VitePress, or any CommonMark-based static site generator. Drop the output directory into a docs folder - no post-processing required. Also included in `--all`.
24+
25+
=== Bug Fixes
26+
* Fix crash in `adoc/rule.adoc.jinja` when `rule.references.hhs` is absent - rules predating the HICP framework do not carry this key.
27+
2028
== [mSCP 2.x] - 2025-02-28
2129
IMPORTANT: This release is a major update and includes breaking changes. Please review the documentation before upgrading.
2230

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,5 @@ packages = ["src/mscp"]
7474

7575
[tool.pytest.ini_options]
7676
testpaths = ["tests"]
77+
pythonpath = ["src"]
78+
addopts = "--import-mode=importlib"

src/mscp/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,13 @@ def parse_cli() -> None:
407407
help="generate documentation in markdown format",
408408
action="store_true",
409409
)
410+
guidance_parser.add_argument(
411+
"--markdown-tree",
412+
help="""R|generate documentation as a paginated Markdown directory tree
413+
(one page per rule, NN- ordered filenames, index.md per section)
414+
ready to use with Docusaurus, Starlight, MkDocs, or VitePress""",
415+
action="store_true",
416+
)
410417
guidance_parser.add_argument(
411418
"-p",
412419
"--profiles",

src/mscp/common_utils/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
(`conditional_inject_spinner`).
1414
"""
1515

16-
from .config import config, set_custom_dir, ensure_custom_dirs, search_paths
16+
from .config import (
17+
config,
18+
CONFIG_PATH,
19+
set_custom_dir,
20+
ensure_custom_dirs,
21+
search_paths,
22+
)
1723
from .constants import SCHEMA_PATH, APPLE_OS, NIX_OS, PLATFORM_MAP
1824
from .customization import collect_overrides
1925
from .file_handling import (
@@ -77,7 +83,8 @@
7783
"SCHEMA_PATH",
7884
"APPLE_OS",
7985
"NIX_OS",
80-
"PLATFORM_MAPvalidate_yaml_file",
86+
"PLATFORM_MAP",
87+
"validate_yaml_file",
8188
"logger",
8289
"get_supported_languages",
8390
"collect_overrides",

src/mscp/common_utils/validate_rules.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ def validate_yaml_file(args: argparse.Namespace) -> None:
110110
if error_found:
111111
sys.exit(1)
112112

113-
print(f"✅ All YAML files passed validation.")
114-
logger.success(f"✅ All YAML files passed validation.")
113+
print("✅ All YAML files passed validation.")
114+
logger.success("✅ All YAML files passed validation.")
115115

116116

117117
def validate_rule_folder_structure(path_str: str) -> Path:

src/mscp/data/templates/documents/markdown/rule.md.jinja

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
{% set check_tags = ["permanent", "inherent", "n_a", "not_applicable"] %}
1+
{% set check_tags = ["permanent", "inherent", "n_a", "not_applicable"] %}
2+
{% set additional_info = rule | get_nested(["platforms", rule.os_type, "enforcement_info", "fix", "additional_info"]) %}
3+
{% set check_shell = rule | get_nested(["platforms", rule.os_type, "enforcement_info", "check", "shell"]) %}
4+
{% set fix_shell = rule | get_nested(["platforms", rule.os_type, "enforcement_info", "fix", "shell"]) %}
5+
{% if not markdown_tree | default(false) %}
26

37
### {{ rule.title }}
48

9+
{% endif %}
510
{% if rule.tags is defined or rule.tags is not none and "supplemental" in rule.tags %}
611
{{ rule.discussion | include_replace | asciidoc_to_markdown }}
712
{% else %}
@@ -15,6 +20,10 @@
1520
{% if not rule.tags | select('in', check_tags) | list %}
1621
{% if rule.tags is not defined or rule.tags is none or "supplemental" not in rule.tags %}
1722
{% if rule.os_type.lower() in NIX_OS %}
23+
{% if markdown_tree | default(false) %}
24+
## {% trans %}Check{% endtrans %}
25+
26+
{% endif %}
1827
{% trans %}To check the state of the system, run the following command(s){% endtrans %}:
1928

2029
```bash
@@ -28,6 +37,87 @@
2837
{% trans %}If the result is not{% endtrans %} _{{ rule.result_value }}_, {% trans %}this is a finding{% endtrans %}.
2938
{% endif %}
3039

40+
{% if markdown_tree | default(false) %}
41+
## {% trans %}Remediation Description{% endtrans %}
42+
43+
{% if rule.fix is not none %}
44+
{% trans %}Perform the following to configure the system to meet the requirements{% endtrans %}:
45+
{% endif %}
46+
47+
{% if rule.mobileconfig_info is none and rule.fix is not none or fix_shell %}
48+
{% trans %}Run the following command(s){% endtrans %}:
49+
50+
```bash
51+
{{ rule.fix | asciidoc_to_markdown if rule.fix is not none else fix_shell }}
52+
```
53+
54+
{% elif additional_info is not none %}
55+
{{ additional_info | asciidoc_to_markdown }}
56+
{% elif rule.mobileconfig_info is not none and fix_shell %}
57+
{% if fix_shell %}
58+
```bash
59+
{{ fix_shell -}}
60+
```
61+
{% endif %}
62+
63+
{% if rule.mobileconfig_info %}
64+
```xml
65+
{{ rule.mobileconfig_info | mobileconfig_payloads_to_xml -}}
66+
```
67+
{% endif %}
68+
{% else %}
69+
{% if rule.os_name == "macos" %}
70+
{{ rule.fix | asciidoc_to_markdown }}
71+
{% else %}
72+
{% trans %}Deploy a configuration profile containing the following payload.{% endtrans %}
73+
74+
```xml
75+
{{ rule.mobileconfig_info | mobileconfig_payloads_to_xml -}}
76+
```
77+
{% endif %}
78+
{% endif %}
79+
80+
## {% trans %}References{% endtrans %}
81+
82+
| | |
83+
|---|---|
84+
| **ID** | `{{ rule.rule_id }}` |
85+
{% if rule.severity is not none %}
86+
| **Severity** | {{ rule.severity }} |
87+
{% endif %}
88+
| **800-53r5** | {{ rule.references.nist.nist_800_53r5 | group_ulify if rule.references.nist.nist_800_53r5 is not none }} |
89+
{% if "800-171" in baseline.title | upper or show_all_tags %}
90+
| **800-171r3** | {{ rule.references.nist.nist_800_171r3 | render_rules if rule.references.nist.nist_800_171r3 is not none }} |
91+
{% endif %}
92+
{% if "STIG" in baseline.title | upper or show_all_tags %}
93+
| **DISA STIG(s)** | {{ rule.references.disa.disa_stig | render_rules if rule.references.disa.disa_stig is not none }} |
94+
{% if rule.references.disa.sfr is not none %}
95+
| **SFR** | {{ rule.references.disa.sfr | render_rules if rule.references.disa.sfr is not none }} |
96+
{% endif %}
97+
{% endif %}
98+
{% if "CIS" in baseline.title | upper or show_all_tags %}
99+
| **CIS Benchmark** | {{ rule.references.cis.benchmark | render_rules if rule.references.cis.benchmark is not none }} |
100+
| **CIS Controls V8** | {{ rule.references.cis.controls_v8 | render_rules if rule.references.cis.controls_v8 is not none }} |
101+
{% endif %}
102+
{% if "INDIGO" in baseline.title | upper or show_all_tags %}
103+
| **indigo** | {{ rule.references.bsi.indigo | render_rules if rule.references.bsi.indigo is not none }} |
104+
{% endif %}
105+
{% if "CMMC" in baseline.title | upper or show_all_tags %}
106+
| **CMMC** | {{ rule.references.disa.cmmc | render_rules if rule.references.disa.cmmc is not none }} |
107+
{% endif %}
108+
{% if "HICP_LP" in baseline.title | upper or show_all_tags %}
109+
| **HICP** | {{ rule.references.hhs.hicp | render_rules if rule.references.hhs.hicp is not none }} |
110+
{% endif %}
111+
{% if rule.references.nist.cce is not none %}
112+
| **CCE** | {{ rule.references.nist.cce | render_rules }} |
113+
{% endif %}
114+
{% if "references" in rule.customized %}
115+
| **Custom References** | {{ rule.references.custom_refs.references | render_references if rule.references.custom_refs is not none }} |
116+
{% endif %}
117+
{% if show_all_tags %}
118+
| **TAGS** | {{ rule.tags | render_rules }} |
119+
{% endif %}
120+
{% else %}
31121
<table class="remediation">
32122
<tr>
33123
<td>
@@ -78,6 +168,7 @@
78168
</table>
79169
{% endif %}
80170

171+
{% if not markdown_tree | default(false) %}
81172
<table class="outer-table" border="1">
82173
<tr>
83174
<td> ID </td>
@@ -164,3 +255,5 @@
164255
</tr>
165256
</table>
166257
{% endif %}
258+
{% endif %}
259+
{% endif %}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% include "foreword.jinja" %}
2+
3+
{% include "scope.jinja" %}
4+
5+
{% include "authors.jinja" %}
6+
7+
{% include "acronyms.jinja" %}

src/mscp/generate/guidance.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from ..generate.guidance_support import (
3838
generate_ddm,
3939
generate_documents,
40+
generate_markdown_tree,
4041
generate_excel,
4142
generate_profiles,
4243
generate_script,
@@ -155,8 +156,9 @@ def generate_guidance(sp: Yaspin, args: argparse.Namespace) -> None:
155156
args (argparse.Namespace): Parsed CLI arguments. Expected attributes:
156157
``baseline``, ``os_name``, ``language``, ``dark``, ``hash``,
157158
``reference``, ``logo``, ``audit_name``, ``profiles``, ``ddm``,
158-
``script``, ``xlsx``, ``gary``, ``markdown``, ``manifest``,
159-
``all``, ``consolidated_profile``, ``granular_profiles``.
159+
``script``, ``xlsx``, ``gary``, ``markdown``, ``markdown_tree``,
160+
``manifest``, ``all``, ``consolidated_profile``,
161+
``granular_profiles``.
160162
"""
161163
# Transparently migrate legacy (pre-2.0) baselines before deriving any
162164
# paths. Updating args.baseline here means all subsequent path derivations
@@ -329,6 +331,18 @@ def generate_guidance(sp: Yaspin, args: argparse.Namespace) -> None:
329331
language=args.language,
330332
)
331333

334+
if args.markdown_tree:
335+
logger.info("Generating paginated Markdown tree")
336+
sp.text = "Generating Markdown tree"
337+
time.sleep(1)
338+
generate_markdown_tree(
339+
build_path,
340+
baseline,
341+
current_version_data,
342+
show_all_tags,
343+
language=args.language,
344+
)
345+
332346
if args.manifest:
333347
logger.info("Generating JSON manifest")
334348
sp.text = "Generating JSON manifest"
@@ -380,23 +394,36 @@ def generate_guidance(sp: Yaspin, args: argparse.Namespace) -> None:
380394
time.sleep(1)
381395
generate_excel(spreadsheet_output_file, baseline)
382396

383-
logger.info("Generating markdown documents")
384-
sp.text = "Generating markdown"
385-
time.sleep(1)
386-
generate_documents(
387-
sp,
388-
md_output_file,
389-
baseline,
390-
b64logo,
391-
pdf_theme,
392-
html_css,
393-
logo_path,
394-
baseline.platform["os"],
395-
current_version_data,
396-
show_all_tags,
397-
output_format="markdown",
398-
language=args.language,
399-
)
397+
if not args.markdown:
398+
logger.info("Generating markdown documents")
399+
sp.text = "Generating markdown"
400+
time.sleep(1)
401+
generate_documents(
402+
sp,
403+
md_output_file,
404+
baseline,
405+
b64logo,
406+
pdf_theme,
407+
html_css,
408+
logo_path,
409+
baseline.platform["os"],
410+
current_version_data,
411+
show_all_tags,
412+
output_format="markdown",
413+
language=args.language,
414+
)
415+
416+
if not args.markdown_tree:
417+
logger.info("Generating paginated Markdown tree")
418+
sp.text = "Generating Markdown tree"
419+
time.sleep(1)
420+
generate_markdown_tree(
421+
build_path,
422+
baseline,
423+
current_version_data,
424+
show_all_tags,
425+
language=args.language,
426+
)
400427

401428
logger.info("Generating JSON manifest")
402429
sp.text = "Generating JSON manifest"

src/mscp/generate/guidance_support/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"""Guidance artifact sub-generators used by `generate_guidance`.
33
44
Re-exports: `generate_ddm` (DDM JSON/ZIP artifacts), `generate_documents`
5-
(AsciiDoc / PDF / HTML / Markdown), `generate_excel` (Excel workbook),
5+
(AsciiDoc / PDF / HTML / Markdown), `generate_markdown_tree` (paginated
6+
Markdown tree for static site generators), `generate_excel` (Excel workbook),
67
`generate_profiles` (configuration profiles), `generate_script` and
78
`generate_restore_script` (compliance shell scripts), and
89
`generate_manifest` (JSON manifest).
@@ -11,6 +12,7 @@
1112
__all__ = [
1213
"generate_ddm",
1314
"generate_documents",
15+
"generate_markdown_tree",
1416
"generate_excel",
1517
"generate_profiles",
1618
"generate_script",
@@ -21,6 +23,7 @@
2123

2224
from .ddm import generate_ddm
2325
from .documents import generate_documents
26+
from .markdown_tree import generate_markdown_tree
2427
from .excel import generate_excel
2528
from .profiles import generate_profiles
2629
from .script import generate_script, generate_restore_script

0 commit comments

Comments
 (0)