docs-pipeline survey: pelican joins clean-scan honor roll, sphinx+docutils investigated

Scanned the three foundational Python documentation tools (Sphinx,
docutils, Pelican) and documented the triage outcome.

Pelican joins the clean-scan honor roll (now 25 projects). Both flagged
findings tested as false positives — utils.py:485 is String.index for
'\n', not list iteration; pelican_import.py:663 ReDoS pattern scales
linearly at N=40 (0.11ms).

Sphinx + docutils have multiple M1 hits in node-tree walks
(Node.findall via parent.index per ancestor). Investigated and
benchmarked: both old and new algorithms are O(D*S) — constant-factor
only, not CWE-407-grade complexity-class change. A real fix would
require maintaining a parent_index cache on Element nodes, a refactor
with cache-invalidation surface area beyond a single-defect patch.

No patches shipped this pass; the work is documented for the next
reviewer.
This commit is contained in:
russell@unturf.com 2026-04-25 13:07:09 -04:00
parent a150602100
commit 4c4a8ecac9
No known key found for this signature in database

View file

@ -0,0 +1,58 @@
# Documentation Pipeline — Sphinx, docutils, Pelican
**Survey date:** 2026-04-25
**Tool:** unmoad (9 active MOAD detectors, HIGH+ severity filter)
**Scope:** the three foundational Python tools that build most reST/Markdown documentation pipelines: Sphinx (the reference docs builder), docutils (the parser/DOM beneath it), and Pelican (the static-site generator that powers undefect.com itself).
---
## Summary
These three tools together build a non-trivial fraction of the open-source documentation web. We scanned each and triaged the findings against the bar for shipping a CWE-407 patch (must change complexity class with measurable wall-clock impact).
**Outcome: no patches shipped this pass.** Pelican is essentially clean. Sphinx and docutils have findings that look like O(N²) on first read but turn out to be either bounded-N, false-positive, or constant-factor-only on benchmark.
## Per-target findings
| Target | Total | M1 | M11 | CRIT | Outcome |
|--------|------:|---:|----:|-----:|---------|
| Pelican | 2 | 1 | 1 | 1 | one String.index false positive + one ReDoS in pelican_import.py — empirically tested at N=40, 0.11ms, no catastrophic backtracking. Effectively a clean scan. |
| docutils | 99 | 30 | 13 | 16 | most M1 hits in `/sandbox/` experimental subprojects (not core); `nodes.py:338-341` Node.findall parent.index investigated and benchmarked — both old and new code are O(D×S), constant factor 1.1-1.4× only, not a complexity-class change. |
| Sphinx | 45 | 25 | 15 | 19 | hot files: util/cfamily.py (4 hits, no .index/find/contains in actual code — likely scanner false positive), themes/static/*.js (vendored), writers/texinfo.py + manpage.py (`node.parent.index` — same docutils pattern, same constant-factor verdict). |
## What was investigated
### Pelican
- `pelican/utils.py:485``self.rawdata.index("\n", line_start)` is **String.index** for a newline character. Single string scan, not a list iteration. **False positive.**
- `pelican/tools/pelican_import.py:663``re.sub(r"((-)+([0-9a-f]+|DRAFT))+$", "", slug)` flagged as nested-quantifier ReDoS. Empirical test at N=40 dashes: 0.11ms (linear scaling). **False positive** — pelican_import is a one-shot CMS migration tool, not a per-build hot path.
**Pelican earns the clean-scan honor roll.** Both findings are non-defects under empirical test.
### docutils
- `docutils/nodes.py:336-348``Node.findall(siblings=True or ascend=True)` walks up the tree calling `parent.index(node)` per ancestor. Looked like a candidate (Node.findall is the canonical tree walk, called by Sphinx, Pelican, and every reST pipeline). Built a Python bench modelling the parent.index per ancestor vs a single-pass iterator skip-until-is. Both algorithms are **O(D×S)** — same complexity class. The skip-until-is variant only saves the `list.index` constant factor and the value-equality retry overhead.
- Bench: D=3, B=10..30: 1.1-1.4× speedup. Not a CWE-407 complexity-class win.
- **Not patch-shipped.** Real fix requires maintaining a parent_index cache on Element (significant refactor with cache-invalidation surface area). Out of scope for a single-defect patch.
- 90% of docutils M1 hits are in `/sandbox/` directories — experimental subprojects (movesec, rst2chunkedhtml, viewcvs, py-rest-doc) that are not part of core docutils. Excluded from triage.
### Sphinx
- `sphinx/util/cfamily.py` — 4 M1 findings reported by scanner but `grep` of the file shows no `.index`/`.find`/`.contains` patterns. Likely scanner false positive on a different shape (`x in ('a','b','c')` style).
- `sphinx/writers/texinfo.py:696`, `sphinx/writers/manpage.py:61` — same `node.parent.index(node.parent)` pattern as docutils. Same conclusion: O(D×S) → O(D×S), constant-factor only.
- `sphinx/themes/*/static/*.js` — vendored CSS3-mediaqueries.js + searchtools.js. Vendor exclusion.
- `sphinx/builders/_epub_base.py:3 hits` — epub-specific path, runs only when building EPUB output. Bounded impact.
## Triage outcome
**Pelican joins the clean-scan honor roll** (now 25 projects).
**Sphinx and docutils** had findings worth examining. Of the inspectable hot paths, none met the CWE-407 bar of complexity-class change with measurable wall-clock impact. The interesting `Node.findall` pattern would require a parent-index cache to fix properly — a refactor, not a one-line set hoist.
This is what an honest "investigated, no patch this pass" outcome looks like: the work is documented, the empirical benches are committed-or-discarded as appropriate, and the next reviewer can pick up where we left off without redoing the analysis.
## References
- `unmoad` detection engine: `git.unturf.com/engineering/unmoad.com`
- Earlier surveys: `/test-harness-survey/`, `/wave4-linter-ci-survey/`, `/wave5-cicd-iac-survey/`, `/wave6-docgen-webfw-tui-survey/`