Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,14 @@
# invoking the project build script, so forcing a non-root USER breaks fuzz CI.
# Revisit by 2026-10-31 or when ClusterFuzzLite supports non-root build output.
DS-0002

# CVE-2026-61632 affects pymdown-extensions (< 11.0.1), a docs-build-only
# transitive dependency pulled by mkdocs-material to render the MkDocs manual;
# it is never installed in the API image or the service runtime. The fix,
# pymdown-extensions 11.0.1, requires mkdocs-material >= 9.7, which this repo
# deliberately avoids: tests/test_project_metadata.py
# ::test_docs_theme_range_stays_below_warning_release pins mkdocs-material < 9.7
# because 9.7 ships the disruptive "mkdocs 2.0" deprecation banner. MEDIUM
# (CVSS 5.3). Revisit by 2026-10-31, or earlier when mkdocs-material 9.7+ is
# adopted or a fix backports to a 9.6-compatible pymdown-extensions release.
CVE-2026-61632
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 서브모듈/사이드카 배포용 `docker-compose.yml`: healthcheck가 `/health`를 대상으로 하며 MinerU 번들 이미지/`NEWSDOM_MINERU_BIN` 및 readiness 주의사항을 README에 문서화.
- [CLI] 파싱된 NewsDOM JSON에서 순수 텍스트 데이터를 추출하여 텍스트 파일 또는 stdout으로 출력하는 `tools/extract_text.py` 도구를 추가했습니다.

### Fixed
- MinerU가 리스트 안에 비-객체(dict가 아닌) 블록/페이지 항목을 방출하면 `content_list`/`model` 요소를 검증해 문서화된 502 incomplete-output 오류로 매핑(기존에는 `AttributeError`가 전파되어 500으로 처리됨). `build_dom`도 비-객체 블록을 benign한 `ValueError`로 거부하도록 방어하고, `dom_builder` 퍼저가 비-dict 요소를 사전 제거하던 마스킹을 제거하여 해당 경계를 실제로 퍼징하도록 함.

### Security
- 전역 500 에러 응답에도 표준 보안 헤더를 적용하여 예외 경로에서 header 누락을 방지
- MinerU subprocess argv 생성 시 `-`로 시작하는 option-like 인자를 거부하여 argument injection 위험을 낮춤
Expand Down
18 changes: 14 additions & 4 deletions fuzzers/dom_builder_fuzzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@
from newsdom_api.dom_builder import build_dom


def _coerce_content_list(candidate: Any) -> list[dict[str, Any]]:
"""Return a MinerU-like content list or an empty list."""
def _coerce_content_list(candidate: Any) -> list[Any]:
"""Return the candidate unchanged when it is a list, else an empty list.

Non-dict members are deliberately preserved (not filtered out) so the fuzzer
actually exercises build_dom's content-block validation. Pre-stripping them
masked a real gap where a non-object block raised ``AttributeError`` instead
of build_dom's documented benign ``ValueError``.
"""

if not isinstance(candidate, list):
return []
return [item for item in candidate if isinstance(item, dict)]
return candidate


def exercise_dom_builder(raw_bytes: bytes) -> None:
Expand All @@ -27,7 +33,11 @@ def exercise_dom_builder(raw_bytes: bytes) -> None:
candidate = json.loads(decoded)
except json.JSONDecodeError:
return
build_dom(_coerce_content_list(candidate), document_id="fuzz")
try:
build_dom(_coerce_content_list(candidate), document_id="fuzz")
except ValueError:
# build_dom's documented, benign rejection of a malformed content list.
return


def _run_smoke(seed_path: Path) -> None:
Expand Down
3 changes: 3 additions & 0 deletions src/newsdom_api/dom_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,9 @@ def build_dom(
f"content_list contains more than {MAX_CONTENT_BLOCKS} content blocks"
)

if not all(type(block) is dict for block in content_list):
raise ValueError("content_list must contain only content-block objects")

page_info_by_idx = _extract_page_info_by_idx(model)
quality_warnings: list[str] = []

Expand Down
8 changes: 8 additions & 0 deletions src/newsdom_api/mineru_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,15 @@ def _parse_mineru_output(
except FileNotFoundError as exc:
raise MineruIncompleteOutputError() from exc
content_list = _read_mineru_json(content_path, artifact="content list")
if not isinstance(content_list, list):
raise MineruIncompleteOutputError("content list JSON was not a list")
if not all(isinstance(block, dict) for block in content_list):
raise MineruIncompleteOutputError("content list contained a non-object block")
model = _read_mineru_json(model_path, artifact="model")
if not isinstance(model, list):
raise MineruIncompleteOutputError("model JSON was not a list")
if not all(isinstance(page_model, dict) for page_model in model):
raise MineruIncompleteOutputError("model contained a non-object page entry")

return content_list, model

Expand Down
8 changes: 8 additions & 0 deletions tests/test_dom_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ def test_build_dom_rejects_non_list_content():
build_dom(("not", "a", "list"), document_id="doc-not-list")


def test_build_dom_rejects_non_dict_block():
with pytest.raises(ValueError, match="only content-block objects"):
build_dom(
[{"type": "text", "text": "ok"}, "not-a-block"],
document_id="doc-non-dict-block",
)


def test_build_dom_handles_non_headline_paths():
dom = build_dom(
[
Expand Down
64 changes: 64 additions & 0 deletions tests/test_mineru_runner_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,70 @@ def test_parse_mineru_output_distinguishes_malformed_json(
_assert_no_private_path_material(str(exc_info.value))


@pytest.mark.parametrize(
("file_name", "payload", "expected_detail"),
[
("sample_content_list.json", "{}", "content list JSON was not a list"),
("sample_model.json", '"unexpected"', "model JSON was not a list"),
],
ids=["content-list-not-a-list", "model-not-a-list"],
)
def test_parse_mineru_output_rejects_non_list_json(
tmp_path: Path, file_name: str, payload: str, expected_detail: str
):
"""Valid JSON that is not a list maps to the documented 502 incomplete-output error."""
ocr_dir = tmp_path / "sample" / "ocr"
ocr_dir.mkdir(parents=True)
(ocr_dir / "sample_content_list.json").write_text(
json.dumps([{"type": "text", "text": "ok"}]), encoding="utf-8"
)
(ocr_dir / "sample_model.json").write_text(
json.dumps([{"layout_dets": []}]), encoding="utf-8"
)
(ocr_dir / file_name).write_text(payload, encoding="utf-8")

with pytest.raises(MineruIncompleteOutputError, match=expected_detail) as exc_info:
mineru_runner._parse_mineru_output(tmp_path, Path("sample.pdf"))

_assert_no_private_path_material(str(exc_info.value))


@pytest.mark.parametrize(
("file_name", "payload", "expected_detail"),
[
(
"sample_content_list.json",
json.dumps([{"type": "text", "text": "ok"}, "not-a-block"]),
"content list contained a non-object block",
),
(
"sample_model.json",
json.dumps([{"layout_dets": []}, 7]),
"model contained a non-object page entry",
),
],
ids=["content-list-non-dict-block", "model-non-dict-entry"],
)
def test_parse_mineru_output_rejects_non_dict_entry(
tmp_path: Path, file_name: str, payload: str, expected_detail: str
):
"""A list whose members are not objects maps to the 502 incomplete-output error."""
ocr_dir = tmp_path / "sample" / "ocr"
ocr_dir.mkdir(parents=True)
(ocr_dir / "sample_content_list.json").write_text(
json.dumps([{"type": "text", "text": "ok"}]), encoding="utf-8"
)
(ocr_dir / "sample_model.json").write_text(
json.dumps([{"layout_dets": []}]), encoding="utf-8"
)
(ocr_dir / file_name).write_text(payload, encoding="utf-8")

with pytest.raises(MineruIncompleteOutputError, match=expected_detail) as exc_info:
mineru_runner._parse_mineru_output(tmp_path, Path("sample.pdf"))

_assert_no_private_path_material(str(exc_info.value))


@pytest.mark.parametrize(
("file_name", "read_error", "expected_detail"),
[
Expand Down
Loading
Loading