any2md 1.0.5__tar.gz → 1.0.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. {any2md-1.0.5 → any2md-1.0.7}/CHANGELOG.md +119 -6
  2. {any2md-1.0.5 → any2md-1.0.7}/PKG-INFO +18 -15
  3. {any2md-1.0.5 → any2md-1.0.7}/README.md +7 -7
  4. {any2md-1.0.5 → any2md-1.0.7}/any2md/__init__.py +1 -1
  5. any2md-1.0.7/any2md/_http.py +106 -0
  6. {any2md-1.0.5 → any2md-1.0.7}/any2md/cli.py +11 -2
  7. {any2md-1.0.5 → any2md-1.0.7}/any2md/config.py +16 -2
  8. {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/docx.py +50 -9
  9. {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/html.py +26 -65
  10. {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/pdf.py +9 -3
  11. {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/txt.py +3 -3
  12. {any2md-1.0.5 → any2md-1.0.7}/any2md/frontmatter.py +47 -6
  13. {any2md-1.0.5 → any2md-1.0.7}/any2md/heuristics.py +14 -22
  14. {any2md-1.0.5 → any2md-1.0.7}/any2md/pipeline/cleanup.py +1 -1
  15. {any2md-1.0.5 → any2md-1.0.7}/any2md/utils.py +44 -0
  16. {any2md-1.0.5 → any2md-1.0.7}/docs/architecture.md +5 -5
  17. {any2md-1.0.5 → any2md-1.0.7}/docs/cli-reference.md +1 -1
  18. {any2md-1.0.5 → any2md-1.0.7}/docs/output-format.md +19 -19
  19. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +19 -19
  20. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +11 -11
  21. any2md-1.0.7/docs/superpowers/plans/2026-04-27-v1.0.6-security-hardening.md +1867 -0
  22. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +25 -25
  23. any2md-1.0.7/docs/superpowers/specs/2026-04-27-v1.0.6-security-hardening-design.md +477 -0
  24. {any2md-1.0.5 → any2md-1.0.7}/docs/upgrading-from-0.7.md +9 -9
  25. {any2md-1.0.5 → any2md-1.0.7}/pyproject.toml +10 -7
  26. any2md-1.0.7/requirements.txt +10 -0
  27. {any2md-1.0.5 → any2md-1.0.7}/scripts/validate-phase1.sh +1 -1
  28. any2md-1.0.7/tests/integration/test_html_trafilatura.py +105 -0
  29. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_txt.py +1 -1
  30. any2md-1.0.7/tests/unit/test_atomic_write.py +52 -0
  31. any2md-1.0.7/tests/unit/test_config_walkup.py +53 -0
  32. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_content_hash.py +1 -1
  33. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_frontmatter_compose.py +2 -2
  34. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_heuristics.py +21 -27
  35. any2md-1.0.7/tests/unit/test_http_safe.py +202 -0
  36. any2md-1.0.7/tests/unit/test_log_sanitizer.py +27 -0
  37. any2md-1.0.7/tests/unit/test_meta_reserved_keys.py +37 -0
  38. any2md-1.0.7/tests/unit/test_safe_dir_name.py +25 -0
  39. any2md-1.0.7/tests/unit/test_zipbomb_guard.py +47 -0
  40. any2md-1.0.5/requirements.txt +0 -7
  41. any2md-1.0.5/tests/integration/test_html_trafilatura.py +0 -29
  42. {any2md-1.0.5 → any2md-1.0.7}/.gitignore +0 -0
  43. {any2md-1.0.5 → any2md-1.0.7}/CODE_OF_CONDUCT.md +0 -0
  44. {any2md-1.0.5 → any2md-1.0.7}/CONTRIBUTING.md +0 -0
  45. {any2md-1.0.5 → any2md-1.0.7}/LICENSE +0 -0
  46. {any2md-1.0.5 → any2md-1.0.7}/SECURITY.md +0 -0
  47. {any2md-1.0.5 → any2md-1.0.7}/any2md/__main__.py +0 -0
  48. {any2md-1.0.5 → any2md-1.0.7}/any2md/_docling.py +0 -0
  49. {any2md-1.0.5 → any2md-1.0.7}/any2md/converters/__init__.py +0 -0
  50. {any2md-1.0.5 → any2md-1.0.7}/any2md/pipeline/__init__.py +0 -0
  51. {any2md-1.0.5 → any2md-1.0.7}/any2md/pipeline/structured.py +0 -0
  52. {any2md-1.0.5 → any2md-1.0.7}/any2md/pipeline/text.py +0 -0
  53. {any2md-1.0.5 → any2md-1.0.7}/any2md/validators.py +0 -0
  54. {any2md-1.0.5 → any2md-1.0.7}/assets/logo-original.jpeg +0 -0
  55. {any2md-1.0.5 → any2md-1.0.7}/assets/logo.png +0 -0
  56. {any2md-1.0.5 → any2md-1.0.7}/assets/social-preview.jpg +0 -0
  57. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
  58. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
  59. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
  60. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
  61. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
  62. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
  63. {any2md-1.0.5 → any2md-1.0.7}/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +0 -0
  64. {any2md-1.0.5 → any2md-1.0.7}/docs/troubleshooting.md +0 -0
  65. {any2md-1.0.5 → any2md-1.0.7}/scripts/audit-outputs.py +0 -0
  66. {any2md-1.0.5 → any2md-1.0.7}/tests/__init__.py +0 -0
  67. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/__init__.py +0 -0
  68. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_backend.py +0 -0
  69. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_existing_flags.py +0 -0
  70. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_exit_codes.py +0 -0
  71. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_high_fidelity.py +0 -0
  72. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_meta.py +0 -0
  73. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
  74. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_ocr_save_images.py +0 -0
  75. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_quiet_verbose.py +0 -0
  76. {any2md-1.0.5 → any2md-1.0.7}/tests/cli/test_cli_strict.py +0 -0
  77. {any2md-1.0.5 → any2md-1.0.7}/tests/conftest.py +0 -0
  78. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/__init__.py +0 -0
  79. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
  80. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/docs/multi_column.pdf +0 -0
  81. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/docs/table_heavy.docx +0 -0
  82. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/docs/web_page.html +0 -0
  83. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/make_fixtures.py +0 -0
  84. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
  85. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
  86. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
  87. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
  88. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
  89. {any2md-1.0.5 → any2md-1.0.7}/tests/fixtures/snapshots/web_page.md +0 -0
  90. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/__init__.py +0 -0
  91. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_docx_backend_override.py +0 -0
  92. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_docx_docling.py +0 -0
  93. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_docx_fallback_on_warn.py +0 -0
  94. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_docx_mammoth_fallback.py +0 -0
  95. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_pdf_backend_override.py +0 -0
  96. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_pdf_docling.py +0 -0
  97. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
  98. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_save_images.py +0 -0
  99. {any2md-1.0.5 → any2md-1.0.7}/tests/integration/test_snapshots.py +0 -0
  100. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/__init__.py +0 -0
  101. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/__init__.py +0 -0
  102. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
  103. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
  104. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
  105. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
  106. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
  107. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
  108. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
  109. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
  110. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
  111. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_runner.py +0 -0
  112. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
  113. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
  114. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
  115. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
  116. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
  117. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
  118. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
  119. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
  120. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
  121. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
  122. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
  123. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
  124. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
  125. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
  126. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
  127. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
  128. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/scripts/__init__.py +0 -0
  129. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/scripts/test_audit_outputs.py +0 -0
  130. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_auto_id.py +0 -0
  131. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_config.py +0 -0
  132. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_docling_helper.py +0 -0
  133. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_frontmatter_helpers.py +0 -0
  134. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_meta_overrides.py +0 -0
  135. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_pdf_complexity.py +0 -0
  136. {any2md-1.0.5 → any2md-1.0.7}/tests/unit/test_validators.py +0 -0
@@ -4,6 +4,119 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [1.0.7] — 2026-04-29
8
+
9
+ Branding/rebrand release. The frontmatter contract that any2md emits is
10
+ now described as "SAGE-compatible" (Security Analysis and Guidance
11
+ Exchange) — the upstream specification was renamed from SSRM (Structured
12
+ Security Reasoning Markdown) to SAGE. No functional or schema changes:
13
+ field names, ordering, controlled vocabularies, and `content_hash`
14
+ derivation are byte-identical to v1.0.6. Existing v1.0.6 outputs remain
15
+ valid SAGE-compatible documents without modification.
16
+
17
+ ### Changed
18
+
19
+ - README, CHANGELOG, and all of `docs/` updated to reference SAGE /
20
+ Security Analysis and Guidance Exchange.
21
+ - `--auto-id` CLI help text now reads "SAGE-conformant document_id"
22
+ (previously "SSRM-conformant"). Behavior unchanged.
23
+ - Module/function docstrings in `any2md/frontmatter.py`,
24
+ `any2md/converters/txt.py`, and `any2md/pipeline/cleanup.py` updated
25
+ to reference SAGE §3 / §5.1 (same section numbers as the upstream
26
+ SSRM v1.0-RC1 spec).
27
+ - Two test functions renamed for naming consistency
28
+ (`test_txt_end_to_end_writes_sage_compat_output`,
29
+ `test_compose_emits_required_sage_fields`). Pytest auto-discovery is
30
+ unaffected; assertions unchanged.
31
+
32
+ ### Migration
33
+
34
+ None required. Public API, CLI flags, frontmatter shape, and
35
+ `content_hash` outputs are all preserved bit-for-bit.
36
+
37
+ ## [1.0.6] — 2026-04-27
38
+
39
+ Security hardening release. Closes 8 actionable findings from the
40
+ 2026-04-27 multi-tool audit (semgrep + trivy + gitleaks + bandit +
41
+ pip-audit + manual review by the security-engineer agent + Opus 4.7).
42
+ No public API changes; behavior unchanged for legitimate inputs.
43
+
44
+ ### Security
45
+
46
+ - **F1 (HIGH)** SSRF: replaced `trafilatura.fetch_url` and the bare
47
+ `urllib.request.urlopen` paths with a new `any2md/_http.py` module
48
+ that does manual redirect walking with per-hop revalidation. Defends
49
+ against DNS-rebinding and redirect-based bypass of the IP-class
50
+ check. New `validate_url` rejects non-http(s) schemes and
51
+ private/loopback/link-local/reserved targets. 20 MB body cap, 3-hop
52
+ redirect cap, redirect-loop detection. Applied to `convert_url`,
53
+ `_http_last_modified`, and `arxiv_lookup`.
54
+ - **F5 (MED)** `--meta` integrity: `content_hash`, `extracted_via`,
55
+ `source_file`, `lane`, `token_estimate`, `recommended_chunk_level`
56
+ are now **reserved** and silently filtered from `--meta` /
57
+ `--meta-file` / `.any2md.toml` overrides with a stderr `WARN` per
58
+ drop. Defense-in-depth filter also runs inside `frontmatter.compose`.
59
+ - **F3 (MED)** TOML auto-discovery now stops at the first ancestor
60
+ containing `.git` / `pyproject.toml` / `setup.py` / `setup.cfg` /
61
+ `package.json` / `.any2md.toml.boundary`. New `--no-config` flag
62
+ fully disables discovery.
63
+ - **F4 (MED)** DOCX zip-bomb: `_safe_zip_open` enforces a 1 MB cap on
64
+ the declared uncompressed size of `core.xml` and `app.xml` before
65
+ parsing. Oversized members raise `ValueError` → clean per-file FAIL.
66
+ - **F-CVE (MED)** Dependency floors bumped: `lxml>=6.1.0,<7`
67
+ (CVE-2026-41066), `pillow>=12.2.0,<13` (CVE-2026-25990,
68
+ CVE-2026-40192), `urllib3>=2.6.3,<3` (CVE-2026-21441). All ranges
69
+ acquire upper bounds (compat envelope). `requirements.txt` becomes
70
+ a development/CI lockfile with exact pins.
71
+ - **F6 (LOW)** Atomic writes: new `any2md.utils.atomic_write_text`
72
+ writes via `tempfile.mkstemp` + `os.replace`. Refuses to overwrite
73
+ a pre-existing symlink at the output path. Wired through
74
+ `pdf/docx/html/txt` converters; PDF image extraction adds a symlink
75
+ check before `pil_image.save`.
76
+ - **F2 (LOW)** Control-char sanitizer strips C0/C1 controls (preserves
77
+ `\t`, `\n`) from captured Docling `msword_backend` warnings before
78
+ forwarding them to `collected_warnings()` and stderr. Defends
79
+ terminals from ANSI-escape spoofing.
80
+ - **F11 (LOW)** PDF image-dir name now goes through `safe_dir_name`
81
+ (alphanumeric/`_`/`-` only). Closes a `Path.stem == ".."` corner
82
+ case that co-mingled images with markdown outputs.
83
+ - **XXE defense-in-depth**: migrated `xml.etree.ElementTree` →
84
+ `defusedxml.ElementTree` in `docx.py` and `heuristics.py`. Stdlib
85
+ was already safe in Python 3.7+; this silences bandit B405/B314
86
+ permanently and protects against future stdlib regressions.
87
+
88
+ ### Added
89
+
90
+ - New module `any2md/_http.py` (`validate_url`, `safe_fetch`).
91
+ - New CLI flag `--no-config`.
92
+ - New helpers `any2md.utils.atomic_write_text` and `safe_dir_name`.
93
+ - New runtime dependency: `defusedxml>=0.7.1,<1`.
94
+
95
+ ### Tests
96
+
97
+ - 7 new unit-test files (40 new tests): `test_http_safe.py`,
98
+ `test_meta_reserved_keys.py`, `test_config_walkup.py`,
99
+ `test_zipbomb_guard.py`, `test_atomic_write.py`,
100
+ `test_safe_dir_name.py`, `test_log_sanitizer.py`.
101
+ - Existing 290 tests continue to pass.
102
+ - Bandit audit went from **9 findings** (3 medium, 6 low) to **1 low**
103
+ (legitimate `try/except/pass` in arxiv `_warn`).
104
+ - 5-DOCX regression run reproduces v1.0.5 word counts exactly
105
+ (978/6726/15566/7811/1477).
106
+
107
+ ### Audit summary
108
+
109
+ | ID | Title | Severity |
110
+ |----|-------|----------|
111
+ | F1 | SSRF: DNS rebind + redirect bypass | High |
112
+ | F5 | `--meta` clobbers reserved frontmatter | Medium |
113
+ | F3 | TOML discovery walks above project root | Medium |
114
+ | F4 | DOCX zip-bomb amplification | Medium |
115
+ | F-CVE | Transitive deps with known CVEs | Medium |
116
+ | F6 | Symlink-following on output writes | Low |
117
+ | F2 | Control-char passthrough in Docling logs | Low |
118
+ | F11 | PDF stem `..` corner case | Low |
119
+
7
120
  ## [1.0.5] — 2026-04-27
8
121
 
9
122
  Patch release. Recovers DOCX content that Docling's msword backend
@@ -235,7 +348,7 @@ Patch release. Adds an explicit backend-selection CLI flag.
235
348
  First stable release of any2md v1.0. Validated end-to-end against
236
349
  real-world documents (a 164-page PDF technical standard via Docling,
237
350
  a multi-page academic DOCX, and a Wikipedia article via trafilatura).
238
- The output contract — SSRM-compatible frontmatter, deterministic
351
+ The output contract — SAGE-compatible frontmatter, deterministic
239
352
  `content_hash`, NFC + LF body normalization — is now stable. Downstream
240
353
  consumers can rely on this shape.
241
354
 
@@ -257,7 +370,7 @@ Phase 4: configuration, polish, and the v1.0 documentation set.
257
370
  - `--meta-file PATH` flag plus auto-discovery of `.any2md.toml` (walks up
258
371
  from cwd). The `[meta]` table supplies frontmatter overrides; the
259
372
  `[document_id]` table supplies `--auto-id` prefix and type code.
260
- - `--auto-id` flag — generates an SSRM-conformant `document_id` as
373
+ - `--auto-id` flag — generates an SAGE-conformant `document_id` as
261
374
  `{PREFIX}-{YYYY}-{TYPE}-{SHA8}`. Defaults to `LOCAL`/`DOC`; override via
262
375
  the `[document_id]` table in `.any2md.toml`.
263
376
  - `--strict` flag — promotes pipeline validation warnings (heading
@@ -276,7 +389,7 @@ Phase 4: configuration, polish, and the v1.0 documentation set.
276
389
  - Comprehensive documentation set:
277
390
  - `README.md` rewritten with educational tone — every command preceded
278
391
  by why you'd run it and followed by what you'll see.
279
- - `docs/output-format.md` — SSRM-compatible field reference and the
392
+ - `docs/output-format.md` — SAGE-compatible field reference and the
280
393
  `content_hash` recomputation recipe.
281
394
  - `docs/cli-reference.md` — flag-by-flag reference with use cases and a
282
395
  worked-example matrix.
@@ -379,14 +492,14 @@ any2md transparently falls back to pymupdf4llm (PDF) and mammoth (DOCX).
379
492
 
380
493
  First prerelease of any2md v1.0. Phase 1 of 5: foundation only — no Docling
381
494
  backend yet, no new CLI flags. Output frontmatter has been rewritten to be
382
- SSRM-compatible (Structured Security Reasoning Markdown) — this is a
495
+ SAGE-compatible (Security Analysis and Guidance Exchange) — this is a
383
496
  breaking change for downstream consumers parsing v0.7 output. See
384
497
  `docs/superpowers/specs/2026-04-26-any2md-v1-design.md` for the full
385
498
  v1.0 design and `docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md`
386
499
  for this phase's task plan.
387
500
 
388
501
  ### Added
389
- - New `any2md/frontmatter.py` module — SSRM-compatible YAML emitter with the
502
+ - New `any2md/frontmatter.py` module — SAGE-compatible YAML emitter with the
390
503
  `SourceMeta` dataclass, deterministic `compute_content_hash`,
391
504
  `estimate_tokens`, `recommend_chunk_level`, `extract_abstract`, and
392
505
  `derive_title` helpers, plus the central `compose()` function (the only
@@ -412,7 +525,7 @@ for this phase's task plan.
412
525
  `reportlab`, `ruff`.
413
526
 
414
527
  ### Changed
415
- - **BREAKING:** Output frontmatter shape is now SSRM-compatible. New required
528
+ - **BREAKING:** Output frontmatter shape is now SAGE-compatible. New required
416
529
  fields: `document_id` (empty string by default — opt-in via future
417
530
  `--auto-id`), `version`, `date`, `status: "draft"`, `document_type` (empty
418
531
  for non-security documents), `content_domain` (empty array),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: any2md
3
- Version: 1.0.5
3
+ Version: 1.0.7
4
4
  Summary: Convert PDF, DOCX, HTML, and TXT files — or web pages by URL — to clean, LLM-optimized Markdown with YAML frontmatter.
5
5
  Project-URL: Homepage, https://github.com/rocklambros/any2md
6
6
  Project-URL: Issues, https://github.com/rocklambros/any2md/issues
@@ -12,13 +12,16 @@ Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Topic :: Text Processing :: Markup :: Markdown
14
14
  Requires-Python: >=3.10
15
- Requires-Dist: beautifulsoup4>=4.12.0
16
- Requires-Dist: lxml>=5.0.0
17
- Requires-Dist: mammoth>=1.6.0
18
- Requires-Dist: markdownify>=0.13.0
19
- Requires-Dist: pymupdf4llm>=0.0.17
20
- Requires-Dist: pymupdf>=1.24.0
21
- Requires-Dist: trafilatura>=1.12.0
15
+ Requires-Dist: beautifulsoup4<5,>=4.12.0
16
+ Requires-Dist: defusedxml<1,>=0.7.1
17
+ Requires-Dist: lxml<7,>=6.1.0
18
+ Requires-Dist: mammoth<2,>=1.6.0
19
+ Requires-Dist: markdownify<2,>=0.13.0
20
+ Requires-Dist: pillow<13,>=12.2.0
21
+ Requires-Dist: pymupdf4llm<1,>=0.0.17
22
+ Requires-Dist: pymupdf<2,>=1.24.0
23
+ Requires-Dist: trafilatura<3,>=1.12.0
24
+ Requires-Dist: urllib3<3,>=2.6.3
22
25
  Provides-Extra: dev
23
26
  Requires-Dist: pytest-snapshot>=0.9.0; extra == 'dev'
24
27
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
@@ -49,7 +52,7 @@ The full design rationale lives in [docs/superpowers/specs/2026-04-26-v1.0.3-emp
49
52
 
50
53
  ## What this is
51
54
 
52
- any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is SSRM-compatible (Structured Security Reasoning Markdown — a documented schema for LLM-consumable documents), so retrieval pipelines, embeddings jobs, and chunking utilities downstream can rely on field names, types, and a deterministic `content_hash` for cache invalidation. The body is NFC-normalized with LF line endings, and the heading hierarchy is guaranteed to start at H1 and not skip levels. The goal is one stable shape, regardless of whether a document started life as a scanned PDF, a Word doc, a Wikipedia page, or a plain text dump.
55
+ any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is SAGE-compatible (Security Analysis and Guidance Exchange — a documented schema for LLM-consumable documents), so retrieval pipelines, embeddings jobs, and chunking utilities downstream can rely on field names, types, and a deterministic `content_hash` for cache invalidation. The body is NFC-normalized with LF line endings, and the heading hierarchy is guaranteed to start at H1 and not skip levels. The goal is one stable shape, regardless of whether a document started life as a scanned PDF, a Word doc, a Wikipedia page, or a plain text dump.
53
56
 
54
57
  ## Quick start
55
58
 
@@ -141,8 +144,8 @@ document_id: "" # empty unless you pass --aut
141
144
  version: "1" # "1" for source files without an embedded version
142
145
  date: "2026-03-15" # docx core props "modified" → ISO-8601
143
146
  status: "draft" # always "draft" for converted documents
144
- document_type: "" # SSRM controlled vocab — empty for non-security docs
145
- content_domain: [] # SSRM controlled vocab — empty array when unknown
147
+ document_type: "" # SAGE controlled vocab — empty for non-security docs
148
+ content_domain: [] # SAGE controlled vocab — empty array when unknown
146
149
  authors: # extracted from docProps/core.xml dc:creator
147
150
  - "Rock Lambros"
148
151
  organization: "" # docProps/app.xml Company; empty when absent
@@ -163,7 +166,7 @@ produced_by: "Microsoft® Word for Microsoft 365" # software that produced the
163
166
  ---
164
167
  ```
165
168
 
166
- Fields under `# any2md extension field` are retained from v0.7 for traceability and observability — they're not part of the SSRM contract proper, but they live in the same frontmatter block. See [docs/output-format.md](docs/output-format.md) for the full field reference.
169
+ Fields under `# any2md extension field` are retained from v0.7 for traceability and observability — they're not part of the SAGE contract proper, but they live in the same frontmatter block. See [docs/output-format.md](docs/output-format.md) for the full field reference.
167
170
 
168
171
  ### Before / after
169
172
 
@@ -359,7 +362,7 @@ You'll see the OK lines as if the conversion were fresh; previously-written outp
359
362
 
360
363
  ## The output format
361
364
 
362
- Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **SSRM-compatible** — it matches the field names, types, and ordering of the Structured Security Reasoning Markdown specification, but values aren't required to match SSRM's controlled vocabularies (most converted documents aren't security research). Fields that require a controlled vocabulary (`document_type`, `content_domain`, `tlp`) are emitted empty unless you supply them via `--meta` or `.any2md.toml`.
365
+ Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **SAGE-compatible** — it matches the field names, types, and ordering of the Security Analysis and Guidance Exchange specification, but values aren't required to match SAGE's controlled vocabularies (most converted documents aren't security research). Fields that require a controlled vocabulary (`document_type`, `content_domain`, `tlp`) are emitted empty unless you supply them via `--meta` or `.any2md.toml`.
363
366
 
364
367
  ### Field auto-fill summary
365
368
 
@@ -404,7 +407,7 @@ You'll see the OK line as usual. The frontmatter of `Text/paper.md` will have `o
404
407
 
405
408
  ### `.any2md.toml`
406
409
 
407
- You'd use this when you have a stable set of frontmatter defaults that should apply to every conversion in a project. The file is auto-discovered by walking up from the current working directory, so you can drop one at the root of your corpus repo and forget about it. Worked example for a security-research org producing SSRM-conforming outputs:
410
+ You'd use this when you have a stable set of frontmatter defaults that should apply to every conversion in a project. The file is auto-discovered by walking up from the current working directory, so you can drop one at the root of your corpus repo and forget about it. Worked example for a security-research org producing SAGE-conforming outputs:
408
411
 
409
412
  ```toml
410
413
  [meta]
@@ -558,7 +561,7 @@ The two-lane design exists because Docling output is layout-correct (running agg
558
561
 
559
562
  ## Migrating from v0.7
560
563
 
561
- v1.0 emits SSRM-compatible frontmatter — that's the one intended breaking change. v0.7's small frontmatter (`title`, `source_file`, `pages`, `type`, `word_count`) becomes a fuller block with `document_id`, `version`, `date`, `status`, `content_hash`, `token_estimate`, `authors`, and several other fields. The v0.7 fields are retained as any2md extension fields in the same block, so traceability is preserved, but downstream parsers that expected the old fixed shape will need to update. The body is now NFC-normalized with LF line endings, which means `content_hash` is reproducible. If you need bit-for-bit v0.7 output, pin `any2md==0.7.0`. The full field-by-field migration guide and behavior-change list lives in [docs/upgrading-from-0.7.md](docs/upgrading-from-0.7.md).
564
+ v1.0 emits SAGE-compatible frontmatter — that's the one intended breaking change. v0.7's small frontmatter (`title`, `source_file`, `pages`, `type`, `word_count`) becomes a fuller block with `document_id`, `version`, `date`, `status`, `content_hash`, `token_estimate`, `authors`, and several other fields. The v0.7 fields are retained as any2md extension fields in the same block, so traceability is preserved, but downstream parsers that expected the old fixed shape will need to update. The body is now NFC-normalized with LF line endings, which means `content_hash` is reproducible. If you need bit-for-bit v0.7 output, pin `any2md==0.7.0`. The full field-by-field migration guide and behavior-change list lives in [docs/upgrading-from-0.7.md](docs/upgrading-from-0.7.md).
562
565
 
563
566
  ## Security
564
567
 
@@ -19,7 +19,7 @@ The full design rationale lives in [docs/superpowers/specs/2026-04-26-v1.0.3-emp
19
19
 
20
20
  ## What this is
21
21
 
22
- any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is SSRM-compatible (Structured Security Reasoning Markdown — a documented schema for LLM-consumable documents), so retrieval pipelines, embeddings jobs, and chunking utilities downstream can rely on field names, types, and a deterministic `content_hash` for cache invalidation. The body is NFC-normalized with LF line endings, and the heading hierarchy is guaranteed to start at H1 and not skip levels. The goal is one stable shape, regardless of whether a document started life as a scanned PDF, a Word doc, a Wikipedia page, or a plain text dump.
22
+ any2md ingests heterogeneous source documents and emits Markdown with a fixed YAML frontmatter contract. The frontmatter shape is SAGE-compatible (Security Analysis and Guidance Exchange — a documented schema for LLM-consumable documents), so retrieval pipelines, embeddings jobs, and chunking utilities downstream can rely on field names, types, and a deterministic `content_hash` for cache invalidation. The body is NFC-normalized with LF line endings, and the heading hierarchy is guaranteed to start at H1 and not skip levels. The goal is one stable shape, regardless of whether a document started life as a scanned PDF, a Word doc, a Wikipedia page, or a plain text dump.
23
23
 
24
24
  ## Quick start
25
25
 
@@ -111,8 +111,8 @@ document_id: "" # empty unless you pass --aut
111
111
  version: "1" # "1" for source files without an embedded version
112
112
  date: "2026-03-15" # docx core props "modified" → ISO-8601
113
113
  status: "draft" # always "draft" for converted documents
114
- document_type: "" # SSRM controlled vocab — empty for non-security docs
115
- content_domain: [] # SSRM controlled vocab — empty array when unknown
114
+ document_type: "" # SAGE controlled vocab — empty for non-security docs
115
+ content_domain: [] # SAGE controlled vocab — empty array when unknown
116
116
  authors: # extracted from docProps/core.xml dc:creator
117
117
  - "Rock Lambros"
118
118
  organization: "" # docProps/app.xml Company; empty when absent
@@ -133,7 +133,7 @@ produced_by: "Microsoft® Word for Microsoft 365" # software that produced the
133
133
  ---
134
134
  ```
135
135
 
136
- Fields under `# any2md extension field` are retained from v0.7 for traceability and observability — they're not part of the SSRM contract proper, but they live in the same frontmatter block. See [docs/output-format.md](docs/output-format.md) for the full field reference.
136
+ Fields under `# any2md extension field` are retained from v0.7 for traceability and observability — they're not part of the SAGE contract proper, but they live in the same frontmatter block. See [docs/output-format.md](docs/output-format.md) for the full field reference.
137
137
 
138
138
  ### Before / after
139
139
 
@@ -329,7 +329,7 @@ You'll see the OK lines as if the conversion were fresh; previously-written outp
329
329
 
330
330
  ## The output format
331
331
 
332
- Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **SSRM-compatible** — it matches the field names, types, and ordering of the Structured Security Reasoning Markdown specification, but values aren't required to match SSRM's controlled vocabularies (most converted documents aren't security research). Fields that require a controlled vocabulary (`document_type`, `content_domain`, `tlp`) are emitted empty unless you supply them via `--meta` or `.any2md.toml`.
332
+ Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **SAGE-compatible** — it matches the field names, types, and ordering of the Security Analysis and Guidance Exchange specification, but values aren't required to match SAGE's controlled vocabularies (most converted documents aren't security research). Fields that require a controlled vocabulary (`document_type`, `content_domain`, `tlp`) are emitted empty unless you supply them via `--meta` or `.any2md.toml`.
333
333
 
334
334
  ### Field auto-fill summary
335
335
 
@@ -374,7 +374,7 @@ You'll see the OK line as usual. The frontmatter of `Text/paper.md` will have `o
374
374
 
375
375
  ### `.any2md.toml`
376
376
 
377
- You'd use this when you have a stable set of frontmatter defaults that should apply to every conversion in a project. The file is auto-discovered by walking up from the current working directory, so you can drop one at the root of your corpus repo and forget about it. Worked example for a security-research org producing SSRM-conforming outputs:
377
+ You'd use this when you have a stable set of frontmatter defaults that should apply to every conversion in a project. The file is auto-discovered by walking up from the current working directory, so you can drop one at the root of your corpus repo and forget about it. Worked example for a security-research org producing SAGE-conforming outputs:
378
378
 
379
379
  ```toml
380
380
  [meta]
@@ -528,7 +528,7 @@ The two-lane design exists because Docling output is layout-correct (running agg
528
528
 
529
529
  ## Migrating from v0.7
530
530
 
531
- v1.0 emits SSRM-compatible frontmatter — that's the one intended breaking change. v0.7's small frontmatter (`title`, `source_file`, `pages`, `type`, `word_count`) becomes a fuller block with `document_id`, `version`, `date`, `status`, `content_hash`, `token_estimate`, `authors`, and several other fields. The v0.7 fields are retained as any2md extension fields in the same block, so traceability is preserved, but downstream parsers that expected the old fixed shape will need to update. The body is now NFC-normalized with LF line endings, which means `content_hash` is reproducible. If you need bit-for-bit v0.7 output, pin `any2md==0.7.0`. The full field-by-field migration guide and behavior-change list lives in [docs/upgrading-from-0.7.md](docs/upgrading-from-0.7.md).
531
+ v1.0 emits SAGE-compatible frontmatter — that's the one intended breaking change. v0.7's small frontmatter (`title`, `source_file`, `pages`, `type`, `word_count`) becomes a fuller block with `document_id`, `version`, `date`, `status`, `content_hash`, `token_estimate`, `authors`, and several other fields. The v0.7 fields are retained as any2md extension fields in the same block, so traceability is preserved, but downstream parsers that expected the old fixed shape will need to update. The body is now NFC-normalized with LF line endings, which means `content_hash` is reproducible. If you need bit-for-bit v0.7 output, pin `any2md==0.7.0`. The full field-by-field migration guide and behavior-change list lives in [docs/upgrading-from-0.7.md](docs/upgrading-from-0.7.md).
532
532
 
533
533
  ## Security
534
534
 
@@ -1,3 +1,3 @@
1
1
  """Convert PDF, DOCX, HTML, and TXT files to LLM-optimized Markdown."""
2
2
 
3
- __version__ = "1.0.5"
3
+ __version__ = "1.0.7"
@@ -0,0 +1,106 @@
1
+ """SSRF-safe HTTP fetching utilities for any2md.
2
+
3
+ Centralizes URL scheme + IP validation and provides a fetcher that
4
+ walks redirects manually and revalidates the host on each hop. This
5
+ defends against:
6
+ - DNS rebinding (host -> public IP for validator, private IP for fetch)
7
+ - Redirect-based SSRF (public landing page -> 302 to 169.254.169.254)
8
+ - Non-http(s) schemes (file://, gopher://, etc.)
9
+ - Decompression / huge-response DoS (response-size cap)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ipaddress
15
+ import socket
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+
20
+ _MAX_REDIRECT_HOPS = 3
21
+ _FETCH_TIMEOUT = 15 # seconds
22
+ _MAX_RESPONSE_BYTES = 20 * 1024 * 1024 # 20 MB cap on body read
23
+ _USER_AGENT = "any2md/1.0.6"
24
+
25
+
26
+ def validate_url(url: str) -> str | None:
27
+ """Validate URL scheme + host. Returns an error message or None.
28
+
29
+ One-shot DNS lookup; rebind protection requires re-checking on
30
+ every redirect hop (handled by ``safe_fetch``).
31
+ """
32
+ parsed = urllib.parse.urlparse(url)
33
+ if parsed.scheme not in ("http", "https"):
34
+ return f"unsupported scheme: {parsed.scheme!r}"
35
+ if not parsed.hostname:
36
+ return f"no hostname in URL: {url}"
37
+ try:
38
+ infos = socket.getaddrinfo(parsed.hostname, None, type=socket.SOCK_STREAM)
39
+ except socket.gaierror:
40
+ return f"cannot resolve host: {parsed.hostname}"
41
+ for *_, sockaddr in infos:
42
+ try:
43
+ addr = ipaddress.ip_address(sockaddr[0])
44
+ except ValueError:
45
+ continue
46
+ if (
47
+ addr.is_private
48
+ or addr.is_reserved
49
+ or addr.is_loopback
50
+ or addr.is_link_local
51
+ ):
52
+ return f"URL resolves to disallowed address: {addr}"
53
+ return None
54
+
55
+
56
+ class _NoFollowRedirect(urllib.request.HTTPRedirectHandler):
57
+ """Disable urllib's automatic redirect following."""
58
+
59
+ def redirect_request(self, *args, **kwargs): # noqa: ARG002
60
+ return None
61
+
62
+
63
+ def safe_fetch(
64
+ url: str,
65
+ *,
66
+ method: str = "GET",
67
+ max_hops: int = _MAX_REDIRECT_HOPS,
68
+ ) -> tuple[bytes | None, dict | None, str | None]:
69
+ """Fetch ``url`` with manual redirect walking + per-hop revalidation.
70
+
71
+ Returns ``(body, headers, None)`` on 2xx, or ``(None, None, error)``
72
+ on rejection / failure. ``headers`` is the response headers as a
73
+ plain ``dict``.
74
+ """
75
+ visited: list[str] = []
76
+ current = url
77
+ opener = urllib.request.build_opener(_NoFollowRedirect())
78
+ for hop in range(max_hops + 1):
79
+ if current in visited:
80
+ return None, None, "redirect loop"
81
+ visited.append(current)
82
+ err = validate_url(current)
83
+ if err:
84
+ return None, None, err
85
+ req = urllib.request.Request(
86
+ current, method=method, headers={"User-Agent": _USER_AGENT}
87
+ )
88
+ try:
89
+ resp = opener.open(req, timeout=_FETCH_TIMEOUT)
90
+ except urllib.error.HTTPError as e:
91
+ if e.code in (301, 302, 303, 307, 308):
92
+ if hop >= max_hops:
93
+ return None, None, f"too many redirects (>{max_hops})"
94
+ location = e.headers.get("Location") if e.headers else None
95
+ if not location:
96
+ return None, None, f"HTTP {e.code} without Location"
97
+ current = urllib.parse.urljoin(current, location)
98
+ continue
99
+ return None, None, f"HTTP {e.code}"
100
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
101
+ return None, None, f"fetch error: {e}"
102
+ body = resp.read(_MAX_RESPONSE_BYTES + 1)
103
+ if len(body) > _MAX_RESPONSE_BYTES:
104
+ return None, None, f"response exceeds {_MAX_RESPONSE_BYTES} bytes"
105
+ return body, dict(resp.headers), None
106
+ return None, None, f"too many redirects (>{max_hops})"
@@ -20,6 +20,7 @@ from any2md.converters import (
20
20
  set_output_mode,
21
21
  )
22
22
  from any2md.converters.html import convert_url
23
+ from any2md.frontmatter import filter_reserved_overrides
23
24
  from any2md.pipeline import PipelineOptions
24
25
  from any2md.utils import sanitize_filename, url_to_filename
25
26
 
@@ -169,6 +170,11 @@ def main():
169
170
  action="store_true",
170
171
  help="Promote pipeline validation warnings to errors (exit 3).",
171
172
  )
173
+ parser.add_argument(
174
+ "--no-config",
175
+ action="store_true",
176
+ help="Skip .any2md.toml auto-discovery (overrides default walk-up).",
177
+ )
172
178
  parser.add_argument(
173
179
  "--docx-fallback-on-warn",
174
180
  action=argparse.BooleanOptionalAction,
@@ -194,7 +200,7 @@ def main():
194
200
  parser.add_argument(
195
201
  "--auto-id",
196
202
  action="store_true",
197
- help="Generate an SSRM-conformant document_id of the form "
203
+ help="Generate an SAGE-conformant document_id of the form "
198
204
  "{PREFIX}-{YYYY}-{TYPE}-{SHA8}. Defaults: PREFIX=LOCAL, TYPE=DOC. "
199
205
  "Override via .any2md.toml [document_id] (publisher_prefix, type_code).",
200
206
  )
@@ -222,7 +228,7 @@ def main():
222
228
  overrides: dict[str, Any] = {}
223
229
  auto_id_prefix, auto_id_type_code = "LOCAL", "DOC"
224
230
 
225
- discovered = discover_config()
231
+ discovered = None if args.no_config else discover_config()
226
232
  if discovered is not None:
227
233
  cfg = load_toml(discovered)
228
234
  overrides = _deep_merge_overrides(overrides, extract_meta_overrides(cfg))
@@ -249,6 +255,9 @@ def main():
249
255
  print(f"Error: {e}", file=sys.stderr)
250
256
  sys.exit(1)
251
257
  overrides = _deep_merge_overrides(overrides, cli_meta)
258
+ overrides = filter_reserved_overrides(
259
+ overrides, source_label="--meta/--meta-file/.any2md.toml"
260
+ )
252
261
 
253
262
  if (
254
263
  args.high_fidelity
@@ -42,17 +42,31 @@ def load_toml(path: Path) -> dict[str, Any]:
42
42
  return {}
43
43
 
44
44
 
45
+ _PROJECT_BOUNDARY_MARKERS = (
46
+ ".git",
47
+ "pyproject.toml",
48
+ "setup.py",
49
+ "setup.cfg",
50
+ "package.json",
51
+ ".any2md.toml.boundary",
52
+ )
53
+
54
+
45
55
  def discover_config(start: Path | None = None) -> Path | None:
46
56
  """Walk up from ``start`` (default: cwd) looking for ``.any2md.toml``.
47
57
 
48
- Returns the first match found or ``None`` if no config exists in
49
- any ancestor directory.
58
+ Stops at the first ancestor containing a project boundary marker
59
+ (``.git``, ``pyproject.toml``, ``setup.py``, ``setup.cfg``,
60
+ ``package.json``, or ``.any2md.toml.boundary``) so config in
61
+ unrelated parent directories cannot leak into a project's run.
50
62
  """
51
63
  cur = (start or Path.cwd()).resolve()
52
64
  while True:
53
65
  candidate = cur / ".any2md.toml"
54
66
  if candidate.is_file():
55
67
  return candidate
68
+ if any((cur / m).exists() for m in _PROJECT_BOUNDARY_MARKERS):
69
+ return None
56
70
  if cur.parent == cur:
57
71
  return None
58
72
  cur = cur.parent
@@ -10,9 +10,12 @@ produces the markdown body.
10
10
  from __future__ import annotations
11
11
 
12
12
  import logging
13
+ import re
13
14
  import sys
14
- import xml.etree.ElementTree as ET
15
15
  import zipfile
16
+
17
+ from defusedxml.ElementTree import ParseError as _XmlParseError
18
+ from defusedxml.ElementTree import parse as _xml_parse
16
19
  from datetime import date
17
20
  from pathlib import Path
18
21
 
@@ -25,11 +28,44 @@ from any2md.converters import add_warnings, is_quiet
25
28
  from any2md.frontmatter import SourceMeta, compose
26
29
  from any2md.heuristics import filter_organization
27
30
  from any2md.pipeline import PipelineOptions
28
- from any2md.utils import sanitize_filename
31
+ from any2md.utils import atomic_write_text, sanitize_filename
29
32
 
30
33
 
31
34
  _DOCLING_MSWORD_LOGGER = "docling.backend.msword_backend"
32
35
 
36
+ _LOG_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
37
+
38
+
39
+ def _sanitize_log_text(s: str) -> str:
40
+ """Strip ASCII C0/C1 control chars from a log message before re-emission.
41
+
42
+ Defends terminals from ANSI-escape spoofing planted in malicious DOCX
43
+ content that surfaces as a Docling msword_backend warning. ``\\t``
44
+ and ``\\n`` are preserved (not in the strip class).
45
+ """
46
+ return _LOG_CONTROL_CHARS_RE.sub("", s)
47
+
48
+
49
+ _MAX_DOCX_METADATA_SIZE = 1 * 1024 * 1024 # 1 MB; core.xml/app.xml are typically <10 KB
50
+
51
+
52
+ def _safe_zip_open(
53
+ z: zipfile.ZipFile, name: str, max_size: int = _MAX_DOCX_METADATA_SIZE
54
+ ):
55
+ """Open a fixed-name DOCX member after a declared-size sanity check.
56
+
57
+ Defends against zip-bomb amplification: a malicious DOCX can declare
58
+ an uncompressed_size of 10 GB for ``core.xml`` and bomb the XML
59
+ parser. Raises ``ValueError`` (caught by ``convert_docx``'s existing
60
+ handler -> file fails cleanly).
61
+ """
62
+ info = z.getinfo(name)
63
+ if info.file_size > max_size:
64
+ raise ValueError(
65
+ f"{name} declared size {info.file_size} exceeds {max_size}-byte limit"
66
+ )
67
+ return z.open(name)
68
+
33
69
 
34
70
  class _DoclingMswordWarningCapture:
35
71
  """Buffer WARNING+ records from Docling's DOCX backend logger.
@@ -89,8 +125,8 @@ def _read_docx_metadata(docx_path: Path) -> dict[str, object]:
89
125
  try:
90
126
  with zipfile.ZipFile(docx_path) as z:
91
127
  try:
92
- with z.open("docProps/core.xml") as f:
93
- root = ET.parse(f).getroot()
128
+ with _safe_zip_open(z, "docProps/core.xml") as f:
129
+ root = _xml_parse(f).getroot()
94
130
  title = root.findtext("dc:title", namespaces=_NS_CORE)
95
131
  if title:
96
132
  out["title_hint"] = title.strip()
@@ -105,8 +141,8 @@ def _read_docx_metadata(docx_path: Path) -> dict[str, object]:
105
141
  except KeyError:
106
142
  pass
107
143
  try:
108
- with z.open("docProps/app.xml") as f:
109
- root = ET.parse(f).getroot()
144
+ with _safe_zip_open(z, "docProps/app.xml") as f:
145
+ root = _xml_parse(f).getroot()
110
146
  company = root.findtext("ext:Company", namespaces=_NS_APP)
111
147
  application = root.findtext("ext:Application", namespaces=_NS_APP)
112
148
  # v1.0.2: Company takes priority for `organization` (real
@@ -126,7 +162,7 @@ def _read_docx_metadata(docx_path: Path) -> dict[str, object]:
126
162
  out["produced_by"] = app_result.produced_by
127
163
  except KeyError:
128
164
  pass
129
- except (zipfile.BadZipFile, ET.ParseError):
165
+ except (zipfile.BadZipFile, _XmlParseError):
130
166
  pass
131
167
  return out
132
168
 
@@ -233,7 +269,12 @@ def convert_docx(
233
269
  md_text, _ = _extract_via_mammoth(docx_path, options)
234
270
  extracted_via = "docling→mammoth (warning fallback)"
235
271
  lane = "text"
236
- add_warnings([f"docling.msword_backend: {m}" for m in docling_warnings])
272
+ add_warnings(
273
+ [
274
+ f"docling.msword_backend: {_sanitize_log_text(m)}"
275
+ for m in docling_warnings
276
+ ]
277
+ )
237
278
 
238
279
  md_text, warnings = pipeline.run(md_text, lane, options)
239
280
  add_warnings(warnings)
@@ -260,7 +301,7 @@ def convert_docx(
260
301
  full = compose(md_text, meta, options, overrides=options.frontmatter_overrides)
261
302
 
262
303
  output_dir.mkdir(parents=True, exist_ok=True)
263
- out_path.write_text(full, encoding="utf-8", newline="\n")
304
+ atomic_write_text(out_path, full)
264
305
  wc = meta.word_count or 0
265
306
  suffix = f", {len(warnings)} warning(s)" if warnings else ""
266
307
  if not is_quiet():