From 7e7f890ca6d46d4f579470db9d442f4b71a71af9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:45:30 +0900 Subject: [PATCH 1/4] fix(sbom): neutralize untrusted Markdown inventory text Replay the governance SBOM rendering-integrity encoder and completeness counters onto current main without rewriting hashed review-agent workflows. --- AGENTS.md | 1 + ARCHITECTURE.md | 9 +- CHANGELOG.md | 1 + CLAUDE.md | 2 + .../doctoring/sbom-markdown-data-integrity.md | 66 +++++++++ scripts/ci/sbom_inventory_aggregator.py | 91 ++++++++++-- tests/test_sbom_inventory_aggregator.py | 52 ++++++- tests/test_sbom_markdown_integrity.py | 135 ++++++++++++++++++ 8 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 docs/doctoring/sbom-markdown-data-integrity.md create mode 100644 tests/test_sbom_markdown_integrity.py diff --git a/AGENTS.md b/AGENTS.md index bd6a96a11f..62d37ae72e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include ( Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). +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). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d2bfb4a41..4a6d9555c1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,6 +38,13 @@ only established scheduler credentials, and grants job-scoped 16. It names `ContextualWisdomLab/nonnest2` and protected `master`, maps only established scheduler credentials, and grants job-scoped `id-token: write`. The reusable engine stays product-neutral. +## 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. ## Hourly NVIDIA NIM repair gate @@ -125,4 +132,4 @@ trusted `uv` exporter is downloaded from the literal GitHub Releases URL for - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) - — product-specific psychometric repair heartbeat and scientific gates. \ No newline at end of file + — product-specific psychometric repair heartbeat and scientific gates. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2f9f24dc..d6c49d0e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Semantic Versioning where the repository publishes a release. ### Security - 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 6ec3d494c3..398b9ced19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,8 @@ repeatable compile command. - **Product hourly callers** stay thin. Do not hard-code OriginWeave, 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. - **`pull_request_target` trust boundary.** The required review workflows run the *base branch's* trusted scripts. A PR that edits the trusted review workflows can fail its own checks until the base branch catches up; a same-head manual `workflow_dispatch` Strix run may supply review evidence 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..e471f18024 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,36 @@ 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 +301,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 +311,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 +323,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,7 +348,9 @@ 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" @@ -332,26 +371,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 +515,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..cca6f47a75 100644 --- a/tests/test_sbom_inventory_aggregator.py +++ b/tests/test_sbom_inventory_aggregator.py @@ -2,6 +2,8 @@ import json +import pytest + from scripts.ci import sbom_inventory_aggregator as agg @@ -119,8 +121,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 @@ -161,6 +163,52 @@ def test_self_test_passes(capsys): 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..5406781a65 --- /dev/null +++ b/tests/test_sbom_markdown_integrity.py @@ -0,0 +1,135 @@ +"""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 From eb10d8cbd52e4a1639fbbfdd4bae5eaafa8340f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:56:48 +0900 Subject: [PATCH 2/4] fix(sbom): constrain inventory artifact writes --- scripts/ci/sbom_inventory_aggregator.py | 28 ++++++++++++++++++++----- tests/test_sbom_inventory_aggregator.py | 10 ++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/scripts/ci/sbom_inventory_aggregator.py b/scripts/ci/sbom_inventory_aggregator.py index e471f18024..a9bd81c1b3 100644 --- a/scripts/ci/sbom_inventory_aggregator.py +++ b/scripts/ci/sbom_inventory_aggregator.py @@ -356,13 +356,31 @@ def render_inventory_markdown(inventory: dict[str, Any], *, generated_at: str) - 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 diff --git a/tests/test_sbom_inventory_aggregator.py b/tests/test_sbom_inventory_aggregator.py index cca6f47a75..b289e6a4bf 100644 --- a/tests/test_sbom_inventory_aggregator.py +++ b/tests/test_sbom_inventory_aggregator.py @@ -1,6 +1,7 @@ """Unit tests for the central SBOM inventory aggregator's pure logic.""" import json +from pathlib import Path import pytest @@ -151,12 +152,19 @@ 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() From 5d7958fe28ea62aae85bf80b6e194d6225cb5c3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:14:56 +0900 Subject: [PATCH 3/4] fix(sbom): escape dollar math delimiters --- scripts/ci/sbom_inventory_aggregator.py | 1 + tests/test_sbom_markdown_integrity.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/scripts/ci/sbom_inventory_aggregator.py b/scripts/ci/sbom_inventory_aggregator.py index a9bd81c1b3..114f137222 100644 --- a/scripts/ci/sbom_inventory_aggregator.py +++ b/scripts/ci/sbom_inventory_aggregator.py @@ -279,6 +279,7 @@ def _markdown_text(value: Any) -> str: "@": "@", "#": "#", ".": ".", + "$": "$", } return "".join(replacements.get(character, character) for character in text) diff --git a/tests/test_sbom_markdown_integrity.py b/tests/test_sbom_markdown_integrity.py index 5406781a65..da28dd9ad9 100644 --- a/tests/test_sbom_markdown_integrity.py +++ b/tests/test_sbom_markdown_integrity.py @@ -133,3 +133,23 @@ def test_empty_error_remains_unavailable_in_summary_and_report() -> None: 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 From f8b94d0dfb02c64761df07ebdf658eb4e1d8abc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:29:04 +0900 Subject: [PATCH 4/4] ci: refresh pip audit runtime --- requirements-pip-audit-ci-hashes.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index ade197a49a..0ae099d8fe 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,9 +213,9 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.1.2 \ - --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ - --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 +pip==26.2.1 \ + --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ + --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \