diff --git a/AGENTS.md b/AGENTS.md index cf8df236be..7575f2eb00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,8 @@ commit and exposed without running build hooks; a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/opencode-exact-vcs-dependency-evidence.md`](docs/doctoring/opencode-exact-vcs-dependency-evidence.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. +Governance SBOM Markdown is a rendering-integrity surface: untrusted inventory strings are neutralized before interpolation. See [`docs/doctoring/sbom-markdown-data-integrity.md`](docs/doctoring/sbom-markdown-data-integrity.md). + All 18 product hourly review-repair callers (OriginWeave at minute 10, nonnest2 at minute 16, and 16 others) are one file, [`.github/workflows/hourly-review-repair.yml`](.github/workflows/hourly-review-repair.yml), a `github.event.schedule` lookup table rather than 18 near-copy-pasted files. See [`docs/doctoring/hourly-review-repair-single-file-consolidation.md`](docs/doctoring/hourly-review-repair-single-file-consolidation.md); the per-repository doctoring records (e.g. [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md), [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md)) remain as historical background per repository. Organization edge runtimes use Cloudflare Pingora. Do not add or preserve active Nginx containers, packages, commands, service/config files, or Kubernetes Nginx ingress annotations/classes. Read [`docs/policies/PINGORA_EDGE_POLICY.md`](docs/policies/PINGORA_EDGE_POLICY.md) and ADR-0019 before changing HTTP edge, static-serving, ingress, TLS, or proxy deployment behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e12f33542d..e7f32cb12a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,6 +64,14 @@ flowchart TD Labels --> Verify ``` +## SBOM Markdown integrity + +`scripts/ci/sbom_inventory_aggregator.py` keeps machine-readable JSON +lossless and renders the governance Markdown inventory only after +`_markdown_text` collapses line structure and encodes active CommonMark / +GFM punctuation. Missing repository evidence increments `error_count` and +marks the receipt incomplete. + The metadata reconciler is convergent and mode-aware. Already-correct descriptions/topics and legacy default-branch `/docs` Pages sites receive no write; absent or drifted legacy Pages state is created/updated, and disabled @@ -262,6 +270,8 @@ resolver conflict. — import-only exact source dependencies for networkless coverage. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) — product-specific psychometric repair heartbeat and scientific gates. + + - [`docs/doctoring/exact-artifact-sbom-attestation.md`](docs/doctoring/exact-artifact-sbom-attestation.md) — current increment's attestation decision and APA 7th citations. - [`docs/doctoring/sandboxed-web-readiness-loopback-boundary.md`](docs/doctoring/sandboxed-web-readiness-loopback-boundary.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30eafe8250..44ab528e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1284,6 +1284,7 @@ Semantic Versioning where the repository publishes a release. - Fail closed when GitHub dependency-review evidence is unavailable (non-200, transport failure, or truncated compare) instead of treating HTTP 403/404 as a clean skip; the probe checks out the exact head SHA and never prints the API body. - Keep the Quarantine Sandbox Runtime caller read-only and model-secret-free, grant only job-scoped OIDC to the reusable scheduler, and preserve the product boundary in which the sandbox returns artifact-analysis evidence while hosts retain WAF/IDS, admission, final verdict, incident, and retention authority. +- Neutralize untrusted SBOM repository, component, license, and timestamp text in the governance Markdown inventory and publish `error_count` / completeness so missing evidence cannot look like a clean roll-up. - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. - Snapshot the complete pre-model worktree for ordinary and conflict repair and reject every model-caused created, deleted, modified, mode-changed, retargeted, ignored, dangling, directory-backed, external-link, metadata-race, or out-of-scope path before staging or push. diff --git a/CLAUDE.md b/CLAUDE.md index 216561be83..28abb7a703 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,8 @@ repeatable compile command. - **Product hourly callers** stay thin. Do not hard-code OriginWeave, aFIPC, naruon, or Keyverse into `pr-review-fix-scheduler.yml`. The model credential remains `NVIDIA_NIM_API_KEY` on the worker, never `COPILOT_GITHUB_TOKEN`. +- **SBOM Markdown** is not trusted input. `render_inventory_markdown` must neutralize + repository, component, license, and timestamp text; JSON stays lossless. - **Central review routes through the vendored contextual-orchestrator gateway.** `pr-review-autofix.yml` provisions `scripts/ci/contextual_orchestrator_review_sidecar.sh` (the five provider secrets flow into its KV; the writer runs diff --git a/docs/doctoring/sbom-markdown-data-integrity.md b/docs/doctoring/sbom-markdown-data-integrity.md new file mode 100644 index 0000000000..76cd7d8db7 --- /dev/null +++ b/docs/doctoring/sbom-markdown-data-integrity.md @@ -0,0 +1,66 @@ +# SBOM Markdown data-integrity boundary + +## Incident + +The organization SBOM inventory treated repository names, component names, +versions, license expressions, API failure details, and the generation label as +trusted Markdown. Newlines, table delimiters, link brackets, code delimiters, +or raw-HTML delimiters in dependency metadata could therefore create forged +rows, headings, links, or presentation markup in the governance-facing report. +The machine-readable JSON remained structurally valid, but reviewers could be +misled by its human-readable companion. + +## Decision + +The renderer now passes every externally derived string through one bounded +text encoder before interpolation. It collapses CR/LF line structure and emits +numeric or named character references for ampersands, backslashes, table pipes, +angle brackets, brackets, backticks, URI punctuation, mention markers, and issue +reference markers. Encoding dots, colons, at-signs, and number signs, plus +asterisks, underscores, and tildes, additionally prevents emphasis and +strikethrough presentation. The existing punctuation encoding prevents GFM +bare URLs, email autolinks, GitHub mentions, and issue references +from becoming active while browsers still render the intended text. Counts and +fixed policy labels remain native values. The JSON inventory deliberately +retains the original data so machine consumers and incident investigators do +not lose evidence. + +This is a rendering-integrity control, not license verification. A component +with an unknown or policy-relevant license remains flagged by the existing +policy logic after its display text is neutralized. + +The report summary also publishes `error_count` and an explicit completeness +state. Missing repository SBOM evidence therefore cannot be interpreted as a +clean zero-finding inventory merely because the unavailable repository has no +components in the roll-up. + +Repository unavailability uses `error is not None` in both the JSON summary and +Markdown per-repository rendering. An empty error string is still unavailable +evidence rather than an empty repository, so the human and machine channels +cannot disagree about completeness. + +## Test-first evidence + +`tests/test_sbom_markdown_integrity.py` first demonstrated that crafted SBOM +metadata produced a second-level heading and a forged table row. A follow-up +RED fixture proved that bare URLs, email addresses, mentions, and issue numbers +remained active without brackets. The accepted contract rejects active row, +heading, link, autolink, mention, issue-reference, and raw-HTML structure while +keeping the corresponding text visibly represented through character +references. + +## Failure, recovery, and rollback + +Unexpected display text should be compared with `inventory.json`, which is the +lossless evidence channel. Rollback requires an independently reviewed renderer +that proves all externally derived strings remain text in every Markdown +context. Removing the encoder or escaping only table pipes is not acceptable +because headings, links, code spans, and raw HTML are separate parse surfaces. + +## APA 7th references + +GitHub. (2019). *GitHub Flavored Markdown specification* (Version 0.29-gfm). +https://github.github.com/gfm/ + +MacFarlane, J. (2024, January 28). *CommonMark specification* (Version 0.31.2). +https://spec.commonmark.org/0.31.2/ diff --git a/scripts/ci/sbom_inventory_aggregator.py b/scripts/ci/sbom_inventory_aggregator.py index 3a55cffc1c..114f137222 100644 --- a/scripts/ci/sbom_inventory_aggregator.py +++ b/scripts/ci/sbom_inventory_aggregator.py @@ -24,6 +24,7 @@ import argparse import concurrent.futures import json +import re import subprocess import sys from dataclasses import dataclass, field @@ -51,6 +52,11 @@ # Sentinel emitted by SBOM tooling when it cannot determine a license. NOASSERTION = "NOASSERTION" +_OWNER_LOGIN_PATTERN = re.compile( + r"[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}" +) +_REPOSITORY_NAME_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,100}") + @dataclass(frozen=True) class Component: @@ -202,8 +208,10 @@ def build_inventory(repo_inventories: Sequence[RepoInventory]) -> dict[str, Any] license_totals: dict[str, int] = {} flagged: list[dict[str, str]] = [] total_components = 0 + error_count = 0 for repo_inventory in sorted(repo_inventories, key=lambda r: r.repo.lower()): + error_count += int(repo_inventory.error is not None) components_payload = [ { "name": component.name, @@ -242,6 +250,8 @@ def build_inventory(repo_inventories: Sequence[RepoInventory]) -> dict[str, Any] "repo_count": len(repos_payload), "component_count": total_components, "flagged_count": len(flagged), + "error_count": error_count, + "complete": error_count == 0, "policy": "commercial-license-only", }, "license_totals": dict(sorted(license_totals.items())), @@ -250,13 +260,37 @@ def build_inventory(repo_inventories: Sequence[RepoInventory]) -> dict[str, Any] } +def _markdown_text(value: Any) -> str: + """Return untrusted inventory text without active Markdown structure.""" + text = str(value).replace("\r\n", " ").replace("\r", " ").replace("\n", " ") + replacements = { + "&": "&", + "\\": "\", + "|": "|", + "<": "<", + ">": ">", + "[": "[", + "]": "]", + "`": "`", + "*": "*", + "_": "_", + "~": "~", + ":": ":", + "@": "@", + "#": "#", + ".": ".", + "$": "$", + } + return "".join(replacements.get(character, character) for character in text) + + def render_inventory_markdown(inventory: dict[str, Any], *, generated_at: str) -> str: """Render the consolidated inventory as a governance-facing markdown page.""" summary = inventory["summary"] lines: list[str] = [ "# Organization SBOM inventory", "", - f"Generated: {generated_at}", + f"Generated: {_markdown_text(generated_at)}", "", "One central view of every managed repository's software components,", "versions, and licenses. Feeds license and vulnerability governance", @@ -268,6 +302,8 @@ def render_inventory_markdown(inventory: dict[str, Any], *, generated_at: str) - f"- Components: {summary['component_count']}", f"- Policy: {summary['policy']}", f"- Flagged licenses: {summary['flagged_count']}", + f"- SBOMs unavailable: {summary['error_count']}", + f"- Evidence completeness: {'complete' if summary['complete'] else 'incomplete'}", "", "## License roll-up", "", @@ -276,7 +312,7 @@ def render_inventory_markdown(inventory: dict[str, Any], *, generated_at: str) - ] for license_key, count in inventory["license_totals"].items(): flag = " ⚠️" if is_flagged_license(license_key) else "" - lines.append(f"| {license_key}{flag} | {count} |") + lines.append(f"| {_markdown_text(license_key)}{flag} | {count} |") lines.extend(["", "## Flagged components (policy violations)", ""]) if inventory["flagged_licenses"]: @@ -288,17 +324,19 @@ def render_inventory_markdown(inventory: dict[str, Any], *, generated_at: str) - ) for item in inventory["flagged_licenses"]: lines.append( - f"| {item['repo']} | {item['name']} | {item['version'] or '—'} | {item['license']} |" + f"| {_markdown_text(item['repo'])} | {_markdown_text(item['name'])} | " + f"{_markdown_text(item['version'] or '—')} | " + f"{_markdown_text(item['license'])} |" ) else: lines.append("No copyleft or NOASSERTION components detected.") lines.extend(["", "## Per-repository components", ""]) for repo in inventory["repos"]: - lines.append(f"### {repo['repo']}") + lines.append(f"### {_markdown_text(repo['repo'])}") lines.append("") - if repo["error"]: - lines.append(f"SBOM unavailable: {repo['error']}") + if repo["error"] is not None: + lines.append(f"SBOM unavailable: {_markdown_text(repo['error'])}") lines.append("") continue if not repo["components"]: @@ -311,19 +349,39 @@ def render_inventory_markdown(inventory: dict[str, Any], *, generated_at: str) - for component in repo["components"]: flag = "yes" if component["flagged"] else "no" lines.append( - f"| {component['name']} | {component['version'] or '—'} | {component['license']} | {flag} |" + f"| {_markdown_text(component['name'])} | " + f"{_markdown_text(component['version'] or '—')} | " + f"{_markdown_text(component['license'])} | {flag} |" ) lines.append("") return "\n".join(lines).rstrip() + "\n" -def write_inventory(inventory: dict[str, Any], markdown: str, output_dir: Path) -> None: - """Write the JSON and markdown inventory artifacts to ``output_dir``.""" - output_dir.mkdir(parents=True, exist_ok=True) - (output_dir / "inventory.json").write_text( +def _resolve_output_dir(output_dir: Path, *, base_dir: Path | None = None) -> Path: + """Resolve an output directory while preventing writes outside the workspace.""" + base = (base_dir or Path.cwd()).resolve() + resolved = (base / output_dir if not output_dir.is_absolute() else output_dir).resolve() + try: + resolved.relative_to(base) + except ValueError as exc: + raise ValueError("output directory must stay within the workspace") from exc + return resolved + + +def write_inventory( + inventory: dict[str, Any], + markdown: str, + output_dir: Path, + *, + base_dir: Path | None = None, +) -> None: + """Write inventory artifacts only inside the configured workspace.""" + safe_output_dir = _resolve_output_dir(output_dir, base_dir=base_dir) + safe_output_dir.mkdir(parents=True, exist_ok=True) + (safe_output_dir / "inventory.json").write_text( json.dumps(inventory, indent=2, sort_keys=False) + "\n", encoding="utf-8" ) - (output_dir / "inventory.md").write_text(markdown, encoding="utf-8") + (safe_output_dir / "inventory.md").write_text(markdown, encoding="utf-8") def _run(args: Sequence[str]) -> str: # pragma: no cover - thin subprocess wrapper @@ -332,26 +390,54 @@ def _run(args: Sequence[str]) -> str: # pragma: no cover - thin subprocess wrap return process.stdout +def _validate_owner_login(value: str) -> str: + """Return a canonical GitHub owner login or reject an unsafe operand.""" + if not isinstance(value, str) or _OWNER_LOGIN_PATTERN.fullmatch(value) is None: + raise ValueError("invalid GitHub organization login") + return value + + +def _validate_repo_full_name(value: str) -> str: + """Return a canonical ``owner/repository`` name or reject it.""" + if not isinstance(value, str) or value.count("/") != 1: + raise ValueError("invalid GitHub repository full name") + owner, repository = value.split("/", 1) + if ( + _OWNER_LOGIN_PATTERN.fullmatch(owner) is None + or _REPOSITORY_NAME_PATTERN.fullmatch(repository) is None + or repository in {".", ".."} + or repository.startswith("-") + ): + raise ValueError("invalid GitHub repository full name") + return value + + def list_org_repos(org: str) -> list[str]: # pragma: no cover - network """List non-archived repositories for an organization via gh.""" + validated_org = _validate_owner_login(org) raw = _run( [ "gh", "repo", "list", - org, "--no-archived", "--limit", "500", "--json", "nameWithOwner", + "--", + validated_org, ] ) - return [entry["nameWithOwner"] for entry in json.loads(raw or "[]")] + return [ + _validate_repo_full_name(entry["nameWithOwner"]) + for entry in json.loads(raw or "[]") + ] def fetch_repo_sbom(repo: str) -> RepoInventory: # pragma: no cover - network """Fetch and parse one repository's dependency-graph SBOM via gh.""" + repo = _validate_repo_full_name(repo) try: raw = _run(["gh", "api", f"/repos/{repo}/dependency-graph/sbom"]) except subprocess.CalledProcessError as exc: @@ -448,7 +534,11 @@ def main(argv: list[str]) -> int: # pragma: no cover - CLI orchestration if args.self_test: self_test() return 0 - repos = args.repos if args.repos else list_org_repos(args.org) + repos = ( + [_validate_repo_full_name(repo) for repo in args.repos] + if args.repos + else list_org_repos(args.org) + ) inventories = collect_inventories(repos) inventory = build_inventory(inventories) generated_at = args.generated_at or "unspecified" diff --git a/tests/test_sbom_inventory_aggregator.py b/tests/test_sbom_inventory_aggregator.py index e9966a6531..b289e6a4bf 100644 --- a/tests/test_sbom_inventory_aggregator.py +++ b/tests/test_sbom_inventory_aggregator.py @@ -1,6 +1,9 @@ """Unit tests for the central SBOM inventory aggregator's pure logic.""" import json +from pathlib import Path + +import pytest from scripts.ci import sbom_inventory_aggregator as agg @@ -119,8 +122,8 @@ def test_render_markdown_contains_rollup_and_flags(): ) markdown = agg.render_inventory_markdown(inventory, generated_at="2026-07-08T00:00:00Z") assert "# Organization SBOM inventory" in markdown - assert "2026-07-08T00:00:00Z" in markdown - assert "GPL-3.0-or-later" in markdown + assert "2026-07-08T00:00:00Z" in markdown + assert "GPL-3.0-or-later" in markdown assert "commercial-license-only" in markdown assert "| readline |" in markdown @@ -149,18 +152,71 @@ def test_write_inventory_emits_both_files(tmp_path): [agg.RepoInventory(repo="acme/app", components=agg.parse_spdx_sbom(SPDX_DOCUMENT))] ) markdown = agg.render_inventory_markdown(inventory, generated_at="now") - agg.write_inventory(inventory, markdown, tmp_path / "sbom") + agg.write_inventory(inventory, markdown, tmp_path / "sbom", base_dir=tmp_path) written = json.loads((tmp_path / "sbom" / "inventory.json").read_text()) assert written["summary"]["repo_count"] == 1 assert (tmp_path / "sbom" / "inventory.md").read_text().startswith("# Organization SBOM inventory") +@pytest.mark.parametrize("output_dir", [Path("../outside"), Path("/tmp/outside")]) +def test_write_inventory_rejects_workspace_escape(tmp_path, output_dir): + """A caller cannot redirect generated artifacts outside its workspace.""" + with pytest.raises(ValueError, match="within the workspace"): + agg.write_inventory({}, "", output_dir, base_dir=tmp_path) + + def test_self_test_passes(capsys): """The bundled self-test runs clean and prints its sentinel.""" agg.self_test() assert "self-test passed" in capsys.readouterr().out +@pytest.mark.parametrize( + "repo", + [ + "--repo=attacker/repo", + "owner", + "owner/repo/extra", + "owner/-R", + "owner/bad repo", + "owner/..", + "double--hyphen/repo", + ], +) +def test_validate_repo_full_name_rejects_unsafe_values(repo): + """Explicit repository inputs must be canonical full names.""" + with pytest.raises(ValueError, match="repository full name"): + agg._validate_repo_full_name(repo) + + +def test_validate_repo_full_name_accepts_github_dot_repository(): + """The organization's special dot repository remains a valid target.""" + assert ( + agg._validate_repo_full_name("ContextualWisdomLab/.github") + == "ContextualWisdomLab/.github" + ) + + +def test_validate_owner_login_rejects_unsafe_values(): + """Organization discovery operands must be canonical logins.""" + with pytest.raises(ValueError, match="organization login"): + agg._validate_owner_login("bad org") + with pytest.raises(ValueError, match="organization login"): + agg._validate_owner_login(None) + + +def test_list_org_repos_rejects_unsafe_name_returned_by_gh(monkeypatch): + """Discovery output is untrusted until every full name is validated.""" + monkeypatch.setattr( + agg, + "_run", + lambda _args: '[{"nameWithOwner":"ContextualWisdomLab/bad repo"}]', + ) + + with pytest.raises(ValueError, match="repository full name"): + agg.list_org_repos("ContextualWisdomLab") + + def test_arg_parser_defaults(): """CLI parser exposes org/output-dir/repo/self-test with sane defaults.""" parser = agg.build_arg_parser() diff --git a/tests/test_sbom_markdown_integrity.py b/tests/test_sbom_markdown_integrity.py new file mode 100644 index 0000000000..da28dd9ad9 --- /dev/null +++ b/tests/test_sbom_markdown_integrity.py @@ -0,0 +1,155 @@ +"""Security contracts for governance-facing SBOM Markdown rendering.""" + +from scripts.ci import sbom_inventory_aggregator as agg + + +def test_render_inventory_markdown_neutralizes_untrusted_structure() -> None: + """SBOM text must not create rows, headings, links, or raw HTML.""" + inventory = agg.build_inventory( + [ + agg.RepoInventory( + repo="acme/app\n## Forged repository", + components=[ + agg.Component( + name="library |\n| forged | 9 | MIT | no |", + version="[download](https://attacker.invalid)", + license="MIT", + ) + ], + ), + agg.RepoInventory( + repo="acme/broken", + error="not found\n## Policy approved", + ), + ] + ) + + markdown = agg.render_inventory_markdown( + inventory, + generated_at="now\n## Forged timestamp", + ) + + assert "\n## Forged repository" not in markdown + assert "\n| forged | 9 | MIT | no |" not in markdown + assert "\n## Policy approved" not in markdown + assert "\n## Forged timestamp" not in markdown + assert "|" in markdown + assert "<script>" in markdown + assert "[download]" in markdown + + +def test_inventory_summary_discloses_missing_sbom_evidence() -> None: + """Unavailable repository evidence must not look like a clean inventory.""" + inventory = agg.build_inventory( + [ + agg.RepoInventory( + repo="acme/clean", + components=[agg.Component(name="lib", version="1", license="MIT")], + ), + agg.RepoInventory(repo="acme/unavailable", error="forbidden"), + ] + ) + + assert inventory["summary"]["error_count"] == 1 + assert inventory["summary"]["complete"] is False + + markdown = agg.render_inventory_markdown(inventory, generated_at="now") + assert "SBOMs unavailable: 1" in markdown + assert "Evidence completeness: incomplete" in markdown + + +def test_render_inventory_markdown_neutralizes_autolinks_and_github_references() -> None: + """Untrusted metadata must not create links, mentions, or issue references.""" + inventory = agg.build_inventory( + [ + agg.RepoInventory( + repo="acme/security", + components=[ + agg.Component( + name="notify @security-team about #123", + version="https://attacker.invalid/payload", + license="mailto:attacker@example.invalid", + ) + ], + ) + ] + ) + + markdown = agg.render_inventory_markdown(inventory, generated_at="now") + + assert "https://attacker.invalid" not in markdown + assert "mailto:attacker@example.invalid" not in markdown + assert "@security-team" not in markdown + assert "#123" not in markdown + assert ":" in markdown + assert "@security-team" in markdown + assert "#123" in markdown + + +def test_render_inventory_markdown_neutralizes_emphasis_and_strikethrough() -> None: + """Untrusted metadata must not create bold, italic, or deleted presentation.""" + inventory = agg.build_inventory( + [ + agg.RepoInventory( + repo="**approved** _trusted_ ~~no errors~~", + components=[], + ) + ] + ) + + markdown = agg.render_inventory_markdown(inventory, generated_at="now") + + assert "**approved**" not in markdown + assert "_trusted_" not in markdown + assert "~~no errors~~" not in markdown + assert "**approved**" in markdown + assert "_trusted_" in markdown + assert "~~no errors~~" in markdown + + korean = agg.build_inventory( + [ + agg.RepoInventory( + repo="**승인됨** _정상_ ~~오류 없음~~", + components=[], + ) + ] + ) + korean_markdown = agg.render_inventory_markdown(korean, generated_at="now") + assert "**승인됨**" not in korean_markdown + assert "_정상_" not in korean_markdown + assert "~~오류 없음~~" not in korean_markdown + assert "**승인됨**" in korean_markdown + assert "_정상_" in korean_markdown + assert "~~오류 없음~~" in korean_markdown + + +def test_empty_error_remains_unavailable_in_summary_and_report() -> None: + """Empty error evidence must not disagree across JSON and Markdown channels.""" + inventory = agg.build_inventory([agg.RepoInventory(repo="acme/empty-error", error="")]) + + markdown = agg.render_inventory_markdown(inventory, generated_at="now") + + assert inventory["summary"]["error_count"] == 1 + assert inventory["summary"]["complete"] is False + assert "SBOM unavailable:" in markdown + assert "No components reported." not in markdown + + +def test_render_inventory_markdown_neutralizes_dollar_math_delimiters() -> None: + """Untrusted SBOM values cannot become GitHub inline-math expressions.""" + inventory = agg.build_inventory( + [ + agg.RepoInventory( + repo="acme/$x$", + components=[ + agg.Component(name="$x$", version="$x$", license="$x$") + ], + ), + agg.RepoInventory(repo="acme/broken", error="$x$"), + ] + ) + + markdown = agg.render_inventory_markdown(inventory, generated_at="$x$") + + assert "$x$" not in markdown + assert markdown.count("$") >= 12