Files
macos_security/tests/test_markdown_tree.py
Dan Brodjieski b64cdf4c59 Refactor logging, rules, and improve GitHub Actions workflows (#722)
* fix: spinner text info for baseline migration

* fix: update CURRENT_USER discovery

change method to use the loginwindow.plist instead of console

* refactor: release string and format

* chore: correct reference to changelog

Issue #718

* refactor: configure logging to catch early CLI issues

* refactor: clarification and inclusion of custom rules

* refactor: correct platform overrides and adjust adoc/md output

* refactor: alias mobileconfig attribute to match yaml

* refactor: admin functions for release and new rule

* chore: lint cleanup and remove markdownlint

* chore(rule): correct validation issues

* chore: adjust spellcheck to main

* chore: correct spelling issues

* chore: update container build process

* chore: trigger container build on push to main

* chore: gate container build on lint and smoke tests

* chore: pin GitHub Actions to commit SHAs and update Node.js to 22 LTS

Pin all unpinned action references to immutable commit SHAs to prevent
supply chain attacks. Upgrade checkout/setup-node from v3 to v4 in
spellcheck workflow and bump Node.js from 20 (deprecated) to 22 LTS
in build_documentation workflow.

* chore: improve GitHub Actions workflow hygiene

- Pin cspell to 10.0.1 instead of @latest
- Add permissions: contents: read to spellcheck, cli-tests, and generate_baselines workflows
- Add concurrency group to generate_baselines to prevent race conditions on rapid pushes
- Add workflow_dispatch to generate_baselines and spellcheck
- Remove dead push-to-main trigger from labeler (labels PRs only)
- Add paths filter to label-sync pull_request_target trigger
- Remove redundant ref from generate_baselines checkout step
- Pin Python to 3.12.1 in cli-tests for consistency
- Remove unnecessary --user flag from pip install in lint workflow

* chore[deps]: update pillow dependency

* chore[rule]: sync enforcement info

issue #592

* chore[rule]: correct CIS ODV

issue #621

* chore[rule]: correct CIS ODV

issue #621

* refactor: filter out incomplete baselines

* feat: add tag mutation API to RuleLibrary

Stores source_file on Macsecurityrule (excluded from serialization) so
RuleLibrary.add_tag/remove_tag can write back to disk after filtering.
Each unique source file is written at most once to avoid redundant I/O
when the library spans multiple platform/version entries for the same rule.

* feat: add add_benchmark/remove_benchmark to RuleLibrary

Unlike tag mutations, benchmark entries are stored per OS version inside
the platforms dict, so both methods group rules by source file and apply
all version mutations to a single canonical object before writing to avoid
clobbering version entries not covered by the first object encountered.

* fix: switch RuleLibrary mutation write path to text-level patching

rule.to_yaml() was designed for custom/derived output and is lossy when
applied to source files — it flattens per-version CCE/STIG dicts and
renames canonical YAML keys (800-53r5 → nist_800_53r5). Replaced all
mutation method write calls with targeted text-level patching helpers
that modify only the specific lines that change, leaving the rest of
each source file untouched.

* chore: update workflow actions

* fix: include multiple mobileconfig payloads if defined in rule

using suggestion from @adrian-ib, changed the mobileconfig info build-out in the manifest to include multiple payloads if they exist

Issue #723

* chore: fix linting and spellcheck findings

* chore: update build action to recent releases

* fix: correct issue with granular profiles and add date

profiles are now generated with a create date in the description

Issue #678

* chore: updates to container build

add entrypoint that will check if latest container and alert the user if a new one is available

move container support files from project root

* chore: adjust to check for container SHA match

* chore: update container update message

* chore: cleanup progress bar messaging

* refactor: custom logo processing in guidance

when a custom logo file is defined at the CLI, it will be copied to the custom/images/ folder for reference in guidance generation.
2026-07-14 10:43:11 -04:00

367 lines
13 KiB
Python

"""Tests for the --markdown-tree output mode.
Covers:
- `create_slug`: slug normalisation
- `mdx_escape`: entity-encoding outside fenced blocks / inline code spans
- `render_references_md`: GFM-table-safe cell rendering
- `_frontmatter`: YAML single-quote escaping
- Integration: generate_markdown_tree writes a valid, SSG-safe tree from a
real bundled baseline (cis_lvl1_macos_26.0).
"""
from __future__ import annotations
import json
import re
import textwrap
from pathlib import Path
import pytest
from mscp.generate.guidance_support.markdown_tree import (
_frontmatter,
create_slug,
mdx_escape,
render_references_md,
)
# ---------------------------------------------------------------------------
# create_slug
# ---------------------------------------------------------------------------
class TestCreateSlug:
def test_spaces_to_hyphens(self):
assert create_slug("System Settings") == "system-settings"
def test_lowercase(self):
assert create_slug("Auditing") == "auditing"
def test_strips_punctuation(self):
assert create_slug("Password/Policy") == "passwordpolicy"
def test_collapses_multiple_hyphens(self):
assert create_slug("foo -- bar") == "foo-bar"
def test_strips_leading_trailing_hyphens(self):
assert create_slug("-foo-") == "foo"
def test_underscore_becomes_hyphen(self):
assert create_slug("os_ssh_fips") == "os-ssh-fips"
def test_empty_string(self):
assert create_slug("") == ""
# ---------------------------------------------------------------------------
# mdx_escape
# ---------------------------------------------------------------------------
class TestMdxEscape:
def test_braces_encoded_in_prose(self):
result = mdx_escape("value is {foo}")
assert "{" in result
assert "}" in result
assert "{" not in result
def test_braces_untouched_inside_fence(self):
src = "```bash\necho {hello}\n```"
assert mdx_escape(src) == src
def test_braces_untouched_in_inline_code(self):
src = "use `{key}` here"
result = mdx_escape(src)
assert "`{key}`" in result
def test_bare_lt_encoded_in_prose(self):
result = mdx_escape("plist has <dict> and <key>")
assert "&lt;dict&gt;" in result or "&lt;" in result
# '<' in prose must be entity-encoded
assert re.search(r"<(?!br|hr|img|/)", result) is None
def test_known_html_tags_preserved(self):
result = mdx_escape("line<br />next")
assert "<br />" in result
def test_br_normalized_to_self_closing(self):
result = mdx_escape("line<br>next")
assert "<br />" in result
assert "<br>" not in result
def test_br_idempotent(self):
result = mdx_escape("line<br />next")
assert result.count("<br />") == 1
def test_fence_with_plist(self):
src = "```xml\n<dict><key>foo</key></dict>\n```"
assert mdx_escape(src) == src
def test_empty_string(self):
assert mdx_escape("") == ""
def test_none_passthrough(self):
# mdx_escape returns value unchanged when falsy
assert mdx_escape(None) is None # type: ignore[arg-type]
def test_multiple_fences(self):
src = "intro {x}\n```bash\nif {a}; then\n```\noutro {y}"
result = mdx_escape(src)
assert "&#123;x&#125;" in result # prose encoded
assert "```bash\nif {a}; then\n```" in result # fence unchanged
assert "&#123;y&#125;" in result
# ---------------------------------------------------------------------------
# render_references_md
# ---------------------------------------------------------------------------
class TestRenderReferencesMd:
def test_empty_returns_empty_string(self):
assert render_references_md([]) == ""
assert render_references_md(None) == ""
def test_single_dict(self):
result = render_references_md([{"SRG": "SRG-OS-000001"}])
assert "**SRG**" in result
assert "SRG-OS-000001" in result
def test_pipe_escaped(self):
result = render_references_md([{"Key": "a|b"}])
assert r"\|" in result
def test_list_value_joined(self):
result = render_references_md([{"IDs": ["ID-1", "ID-2"]}])
assert "ID-1" in result
assert "ID-2" in result
def test_multiple_dicts_joined_with_br(self):
refs = [{"A": "1"}, {"B": "2"}]
result = render_references_md(refs)
assert "<br />" in result
# ---------------------------------------------------------------------------
# _frontmatter
# ---------------------------------------------------------------------------
class TestFrontmatter:
def test_basic(self):
fm = _frontmatter({"title": "Hello"})
assert fm.startswith("---")
assert fm.endswith("---")
assert "title: 'Hello'" in fm
def test_single_quote_escaped(self):
fm = _frontmatter({"title": "It's a title"})
assert "title: 'It''s a title'" in fm
def test_non_string_bare(self):
fm = _frontmatter({"position": 3})
assert "position: 3" in fm
def test_slug_field(self):
fm = _frontmatter({"title": "A Rule", "slug": "a-rule"})
assert "slug: 'a-rule'" in fm
# ---------------------------------------------------------------------------
# Integration: generate_markdown_tree
# ---------------------------------------------------------------------------
def _get_baseline_path() -> Path:
"""Return a bundled baseline YAML for testing."""
root = Path(__file__).parent.parent
candidates = [
root / "src/mscp/data/baselines/macos/cis_lvl1_macos_26.0.yaml",
]
for p in candidates:
if p.exists():
return p
pytest.fail(f"Bundled baseline not found; checked: {[str(p) for p in candidates]}")
@pytest.fixture(scope="module")
def markdown_tree_output(tmp_path_factory):
"""Generate the markdown tree for cis_lvl1 into a temp dir."""
from mscp.classes import Baseline
from mscp.common_utils import get_version_data, mscp_data
from mscp.generate.guidance_support.markdown_tree import generate_markdown_tree
baseline_path = _get_baseline_path()
tmp = tmp_path_factory.mktemp("tree_output")
baseline = Baseline.from_yaml(baseline_path)
version_data = get_version_data(
baseline.platform["os"],
float(baseline.platform["version"]),
mscp_data,
)
generate_markdown_tree(
build_path=tmp,
baseline=baseline,
version_info=version_data,
show_all_tags=False,
language="en",
)
return tmp / "markdown_tree"
class TestMarkdownTreeIntegration:
def test_output_dir_exists(self, markdown_tree_output):
assert markdown_tree_output.is_dir()
def test_root_index_exists(self, markdown_tree_output):
assert (markdown_tree_output / "index.md").is_file()
def test_root_index_has_frontmatter(self, markdown_tree_output):
content = (markdown_tree_output / "index.md").read_text()
assert content.startswith("---\n")
assert "title:" in content
def test_section_dirs_exist(self, markdown_tree_output):
section_dirs = [d for d in markdown_tree_output.iterdir() if d.is_dir()]
assert len(section_dirs) > 0
def test_section_dirs_have_nn_prefix(self, markdown_tree_output):
section_dirs = [d for d in markdown_tree_output.iterdir() if d.is_dir()]
for d in section_dirs:
assert re.match(r"^\d{2}-", d.name), (
f"Section dir '{d.name}' missing NN- prefix"
)
def test_section_dirs_sorted_correctly(self, markdown_tree_output):
section_dirs = sorted(d for d in markdown_tree_output.iterdir() if d.is_dir())
prefixes = [int(d.name[:2]) for d in section_dirs]
assert prefixes == sorted(prefixes)
def test_each_section_has_index(self, markdown_tree_output):
for section_dir in markdown_tree_output.iterdir():
if section_dir.is_dir():
assert (section_dir / "index.md").is_file(), (
f"Missing index.md in {section_dir.name}"
)
def test_rule_files_have_nn_prefix(self, markdown_tree_output):
for section_dir in markdown_tree_output.iterdir():
if not section_dir.is_dir():
continue
rule_files = [
f for f in section_dir.iterdir() if f.is_file() and f.name != "index.md"
]
for rf in rule_files:
assert re.match(r"^\d{2}-", rf.name), (
f"Rule file '{rf.name}' missing NN- prefix"
)
def test_rule_files_are_md_not_mdx(self, markdown_tree_output):
for section_dir in markdown_tree_output.iterdir():
if not section_dir.is_dir():
continue
for f in section_dir.iterdir():
assert f.suffix == ".md", f"Expected .md extension, got '{f.name}'"
def test_rule_frontmatter_parses(self, markdown_tree_output):
"""All rule files must have valid YAML frontmatter with title only."""
for section_dir in markdown_tree_output.iterdir():
if not section_dir.is_dir():
continue
for rf in section_dir.iterdir():
if rf.name == "index.md":
continue
content = rf.read_text()
assert content.startswith("---\n"), f"{rf.name}: missing frontmatter"
# Extract frontmatter block
end = content.index("---\n", 4)
fm_block = content[4:end]
assert "title:" in fm_block, f"{rf.name}: missing title"
def test_no_raw_braces_outside_fences(self, markdown_tree_output):
"""No unescaped { or } should appear outside fenced blocks."""
for md_file in markdown_tree_output.rglob("*.md"):
content = md_file.read_text()
# Strip fenced code blocks
stripped = re.sub(r"```.*?```", "", content, flags=re.DOTALL)
# Strip frontmatter
if stripped.startswith("---\n"):
end = stripped.index("---\n", 4)
stripped = stripped[end + 4 :]
assert "{" not in stripped, (
f"{md_file.name}: raw '{{' found outside fenced block"
)
assert "}" not in stripped, (
f"{md_file.name}: raw '}}' found outside fenced block"
)
def test_balanced_fences(self, markdown_tree_output):
"""Fenced code blocks must be properly opened and closed."""
for md_file in markdown_tree_output.rglob("*.md"):
content = md_file.read_text()
fences = re.findall(r"^```", content, flags=re.MULTILINE)
assert len(fences) % 2 == 0, (
f"{md_file.name}: unbalanced fences ({len(fences)} markers)"
)
def test_no_category_json_files(self, markdown_tree_output):
"""No Docusaurus-specific _category_.json files should be present."""
category_files = list(markdown_tree_output.rglob("_category_.json"))
assert category_files == [], f"Found _category_.json files: {category_files}"
def test_single_file_markdown_unchanged(self, tmp_path):
"""The single-file --markdown output must not be affected by the tree changes.
Verifies that rendering without markdown_tree=True in context still
produces the HTML-table remediation block (not the heading-based one).
"""
# This is a template-level smoke test: render the rule template
# directly without markdown_tree context and confirm HTML table
# structure is present.
from jinja2 import Environment, DictLoader
# Minimal template that exercises only the remediation branch
template_src = textwrap.dedent("""\
{% set check_tags = ["permanent", "inherent", "n_a", "not_applicable"] %}
{% set additional_info = none %}
{% set check_shell = none %}
{% set fix_shell = none %}
{% if not markdown_tree | default(false) %}
### {{ rule.title }}
{% endif %}
{{ rule.discussion }}
{% if not rule.tags | select('in', check_tags) | list %}
{% if markdown_tree | default(false) %}
## Remediation Description
{% else %}
<table class="remediation">remediation</table>
{% endif %}
{% endif %}
""")
env = Environment(loader=DictLoader({"rule.md.jinja": template_src}))
env.filters["default"] = lambda v, d=None: v if v else d
rule = {
"title": "Test Rule",
"discussion": "Some text.",
"tags": ["other"],
"mechanism": "automated",
"fix": "sudo defaults write ...",
}
# Without markdown_tree - should use HTML table remediation
result_std = env.get_template("rule.md.jinja").render(rule=rule)
assert "<table" in result_std
assert "## Remediation" not in result_std
# With markdown_tree=True - should use heading-based remediation
result_tree = env.get_template("rule.md.jinja").render(
rule=rule, markdown_tree=True
)
assert "## Remediation" in result_tree
assert "<table" not in result_tree