fix(security): scan doctest examples in docstrings as code - #8870
fix(security): scan doctest examples in docstrings as code#8870javenciu wants to merge 1 commit into
Conversation
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of Both wiring sites check out against the base: the fence-path loop lands as a sibling of the raw-value check inside First-Principles-Verdict: CONCERNS The fix is real and every piece earns its place, but half the shipped wiring — the fence-path check and two of seven tests — appears nowhere in the description. What this change shipsIntent: a dangerous command inside a docstring's
Watch
[FIRST-PRINCIPLES-REVIEWED] f81afe3 |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of The seams check out: Design-Verdict: PASS Closes a real decode-gap bypass at the right seam, deny-direction throughout, and beats the whole-docstring alternative on both false positives and coverage. [DESIGN-REVIEWED] f81afe3 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 1 of 1 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/security.py:11356 -- Depth exhaustion reopens the doctest credential-path bypass Adjudication (Opus 4.8) — is blocking on each finding proportionate?I've read the prompt, the findings, and the diff. The adjudicable block is empty (0 findings). One fenced finding, F1, requires a ruling. F1 analysis — The conditions (a body with ~4 levels of nested doctest examples, escape-spelled at the deepest) are entirely producible by the author of a malicious cron script — no timing contradiction, no legacy-only artifact, no input the system's writer cannot generate. Hiding a payload behind added nesting is exactly what a motivated attacker does. Harm is a governance-ceiling / protected-path read via cron vet — unbounded, security-class. I cannot complete a rarity record showing the combination is unproducible, so the FLAG bar is not met. Fenced harm: UNBOUNDED (ceiling bypass, protected-path read). Conditions confirmed at security.py:11352 ( |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
1032d14 to
c73a82b
Compare
|
CI note: the Backend Tests shard-1 failure is test_approval_threading.py:219 (the event-loop-closed concurrency class), which has no overlap with this diff (doctest subject extraction in security.py plus its tests only). It passes 5/5 locally at this head, including with current main merged in. Happy to rebase to re-roll CI if that is preferred. |
doctest executes the >>> examples inside a docstring, but the source-body scan passes saw a docstring as one opaque string: its Python-call spelling is not shell grammar for the traversal passes, and its escape spelling matches no fence pattern as raw text while the example's own parse decodes it into a read of the real store. Extract every example with doctest.DocTestParser (Python's own PS1/PS2 grammar) and hand its string literals to BOTH consumers: each rides the traversal passes as its own subject under the same subject cap, and each takes the escape-aware fence check, wired after the literal's own raw text is checked. The pattern-slot exoneration is not inherited: it is earned by the literal occupying the slot, and an example literal sits inside that literal. All fallbacks err toward denying: unparseable example code is scanned as raw text, and a malformed doctest directive routes each contiguous >>>/... block (continuation lines included, example indentation preserved) through the same literal extraction, so broken doctest syntax cannot buy an exemption for any line of the example. Proven fails-before on both mechanisms with body-position twins convicting at parity; benign examples stay clear. Spec doc gains the extraction paragraph covering both consumers. Fixes kirodotdev#8824
c73a82b to
f81afe3
Compare
|
Thanks — this residual was real, and the fence-scan wiring now on the branch (
The deferred |
|
Closing this as superseded by upstream direction rather than rebasing. This PR hardened the docstring-prose exclusion inside Two merged changes have since removed the layer this fix lives in:
The gap this PR fixed (doctest examples executing literals that the prose exclusion hid from the scanner) is real in the old architecture, but the old architecture is gone by design: there is no Thanks to the reviewers for the earlier rounds on this one. |
Problem / Motivation
doctestexecutes the>>>examples inside docstrings, but no pass of the source-body vet ever scans those examples as code. A dangerous command spelled inside an example sails through, while the identical string as a plain literal is blocked. Measured onmain(3006935):Why it matters
The vet is a security gate: it exists so a cron/script body cannot smuggle a credential read or fenced-store traversal past review. Examples in docstrings are a runnable position (
python -m doctest, pytest--doctest-modules), so the gap is a real bypass shape, not a formatting nit — and the inconsistency (blocked as a literal, allowed one docstring away) is exactly the kind of seam an author can trip accidentally or exploit deliberately.What changed (motivation → approach → change)
>>> subprocess.run(...)spelling is Python-call syntax, not shell grammar, so the traversal passes cannot read it; and the inner shell string is text-inside-text, never its own AST constant, so it never becomes a subject of its own._doctest_example_subjects()re-reads every string constant containing>>>with Python's own example parser (doctest.DocTestParser— the same PS1/PS2 grammar the runner uses, no hand-rolled regex) and hands the string literals inside each example's code to the same traversal passes as body-position literals, under the same subject cap. Every fallback errs toward denying: example code that is not valid Python (a shell transcript pasted after>>>) becomes a raw-text subject; a malformed doctest directive (ValueErrorfrom the parser) routes each contiguous>>>/...block — continuation lines included, with the example's own indentation preserved — through the same literal extraction, so breaking doctest syntax cannot buy an exemption for any line of the example. Nested examples are followed to a fixed small depth.Interplay with open #8811 (docstring subject treatment): this extraction hooks the constant walk and is additive — it does not depend on docstring-position constants remaining whole subjects, so it composes with either resolution of #8811. If #8811 lands first, this rebases cleanly; the example-literal subjects are unaffected.
Fence-scan wiring (review follow-up, folded into the single rebased commit): the subject extraction closed the asymmetry for the traversal passes, but the escape-aware fence pass (
_sensitive_run_in_source_literals) walks tree constants directly, so a docstring stayed one raw string to it: an example literal spelled\x25LOCALAPPDATA\x25\x5c\x5ckiro-cli\x5c\x5cc.jsonmatches no fence pattern as raw text, while the example's own parse decodes it into a read of the real store. Proven end-to-end fails-before against the subject-extraction-only tree: fence pass(True, None)and entrypointNoneon the hidden spelling while the body-position twin convicts. The extracted example literals (same extractor — no second parser) now take the same fence check, wired at the constant after its own raw text is checked, so the two layers cannot disagree about which strings exist. The pattern-slot exoneration is not inherited: it is earned by the literal that occupies the slot, and an example literal sits inside that literal rather than in the slot — denying matches the module's direction everywhere an exoneration is in doubt. The spec doc (docs/system-specs/modules/security.md) gains the doctest-extraction paragraph covering both consumers.Passes-after on the same probes:
Tests
Seven tests added to
test/test_mcp_cron_security.py(module + seam-owner neighbortest_security_source_command_subject.py: 239 passed at the final tree):test_doctest_examples_in_docstrings_are_scanned_as_code— both attack spellings convict, with the body-position literal pinned as the control.test_benign_doctest_examples_stay_allowed— the fix adds subjects, not grammar; ordinary examples must not new-deny.test_doctest_shell_transcript_example_still_denies— unparseable example code is scanned as raw text (deny-direction fallback).test_malformed_doctest_directive_cannot_exonerate— a directive the parser refuses still yields subjects via the same extraction.test_malformed_doctest_continuation_lines_cannot_exonerate— a payload on a...continuation line under a malformed directive convicts at deny-parity with the identical valid-syntax example (malformed input must never be safer than well-formed input).test_escape_spelled_fenced_read_in_example_convicts_via_fence_path— fence wiring: the decoded example literal convicts on the fence pass at body-position parity (twin pinned as control), and the entrypoint denies.test_benign_examples_stay_clear_of_the_fence_path— fence wiring: the wiring adds a check, not grammar; an escape-spelled innocent path in an example must not new-deny.Manual verification
N/A — unit coverage sufficient: the seam is a pure function (
source text in → verdict out) and the probes above are the manual scenario, pasted with their outputs.Related Issues
Fixes #8824
Pattern harvest
Pattern: executable content in a documentation position treated as prose by a scanner (docstring doctest examples here).
Rule candidate: review-prompt — "any text position the runtime can execute (doctest examples,
doctest.testfiletargets, eval'd templates) must reach the same scanner passes as body code."Adjacent same-class candidate, deferred (one topic per PR): text-file doctests (
doctest.testfile/ README examples) never pass through this vet either; that is a separate ingestion seam and would be its own issue+PR.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835).