Uh oh!
There was an error while loading. Please reload this page.
test(safe-outputs): verify work item rendering fidelity end to end - #1974
test(safe-outputs): verify work item rendering fidelity end to end#1974jamesadevine with Copilot wants to merge 7 commits into
Conversation
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
…y checks Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
|
Azure Pipelines: 2 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Azure Pipelines: 2 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
✅ Rust Code Quality Reviewer completed the Rust code quality review.
|
✅ PR Security Reviewer completed the security review.
|
✅ Compiler Contract Reviewer completed the compiler contract review.
|
✅ Test Quality Sentinel completed the test quality analysis.
|
✅ TypeScript Code Quality Reviewer completed the TypeScript code quality review. No scripts/ado-script/ TypeScript files were changed in this PR — the diff is Rust-only (sanitize/markdown.rs, safe_outputs/create_work_item.rs) plus a shared JSON test fixture (markdown-rendering-corpus.json) and an update to Rust-side executor-e2e test infra. Nothing falls under the TypeScript Code Quality Reviewer's remit, so no review comments were posted.
|
There was a problem hiding this comment.
Compiler contract review
No compiler-contract concerns here. This PR is test/coverage-only:
- No front-matter grammar, typed IR, or
CompilerExtensionchanges. - No new safe-output tool or
Paramsfield —create-work-item'sdescriptionstays a plainString, which is correct: it's agent-authored Markdown prose, not a path/ref/identifier that thesrc/secure.rsnewtypes are meant for. Cargo.tomlgainsammonia/pulldown-cmark;Cargo.lockmoved with it (186 additions) — no drift.- No
.github/workflows/*.mdchanged, so no.lock.ymldrift. - No gate/fact IR touched (
src/compile/filter_ir.rs,Factenum untouched), sotypes.gen.ts/fact-catalog.gen.jsoncorrectly did not move. - No new ADO definition or per-case orchestrator variable was added for the two new executor-e2e scenarios — they're appended to the existing scenario list in
work-item.ts, consistent with the shared-corpus/lane model. - Docs are in sync:
docs/safe-outputs.mdgained a full "Markdown body sanitization" section and thedescriptionfield doc links to it;tests/executor-e2e/README.mddocuments the two new rendering scenarios.
The sanitizer rewrite itself (src/sanitize/markdown.rs, HTML/URL allowlisting) is security-relevant but outside this reviewer's contract-drift scope — deferring to the security reviewer for that logic.
No blocking findings from the compiler-contract perspective.
🏗️ Compiler contract review by Compiler Contract Reviewer · auto · 38.2 AIC · ⌖ 2.9 AIC · ⊞ 12.7K
Comment /review to run again
There was a problem hiding this comment.
Security review: no regressions found.
This PR is test-only — it adds end-to-end and golden-test coverage for the Markdown sanitizer's rendering fidelity in create-work-item. The allowlist-based markdown.rs sanitizer itself was already merged on main in a prior commit (eba900a refactor(sanitize): allowlist-based Markdown sanitizer); this diff does not change sanitization logic, URL-scheme allowlisting, HTML tag allowlisting, or token/credential handling.
Checked specifically for regressions and found none:
- No validated newtype (
src/secure.rs) downgraded to rawString. - No widening of
ALLOWED_TAGS/ALLOWED_URL_SCHEMESin this diff. - The new executor-e2e assertions (
scenarios/work-item.ts) actively check that<script,onerror=,<iframe, and `(redacted) do not survive in rendered prose, while verifying fenced-code twins survive verbatim — this strengthens the safety net rather than weakening it. - The wiremock test in
src/safe_outputs/create_work_item.rspins the existing/multilineFieldsFormat/System.Description = Markdownpatch path; no new write path or token is introduced. AdoRest.getWorkItem/workItemTypeExistsadditions are read-only test-harness helpers using the existing segment-encoding helper (AdoRest.seg), consistent with existing usage elsewhere in the file.
Nothing merge-blocking. COMMENT only.
🔒 Security review by PR Security Reviewer · auto · 64.5 AIC · ⌖ 2.35 AIC · ⊞ 11.7K
Comment /review to run again
There was a problem hiding this comment.
Verdict: request changes
Rust-quality pass over the sanitizer rewrite (src/sanitize.rs / src/sanitize/markdown.rs). One inline finding is merge-blocking: locate_destination() can match the destination string inside the link label instead of the actual destination when both are byte-identical (e.g. [(redacted) letting a denied URL scheme survive sanitization untouched — confirmed by running the sanitizer locally against this branch. Everything else in the rewrite (allowlist approach, code-span protection via pulldown_cmark`, region cursor logic in the non-colliding case) looks sound and is a real improvement over the previous blocklist/fixed-point approach.
Themes reviewed
- Error handling: no new
unwrap/expectreachable from untrusted input; the newrendering_corpushelpers are#[cfg(test)]-only. - Region/cursor overlap handling in
sanitize_markdown_htmlis correct for genuinely nested constructs (code spans within code fences, etc.) but the mislocated-destination case above can produce an incorrectly ordered region that defeats therange.start < cursorskip. scheme_allowed()scheme-confusion handling (whitespace/entity-decoded schemes, relative refs) looked correct for the cases exercised by the new test suite.- No cross-platform path or casting concerns in the diff.
🦀 Rust code quality review by Rust Code Quality Reviewer · auto · 84.2 AIC · ⌖ 1.89 AIC · ⊞ 11.4K
Comment /review to run again
| return None; | ||
| } | ||
| let source = input.get(span.clone())?; | ||
| let offset = source.find(dest_url)?; |
There was a problem hiding this comment.
locate_destination finds the first byte-for-byte match of dest_url anywhere in the element's full source span, which includes the link/alt text, not just the destination — for a payload like `[(redacted) this locates the label occurrence instead of the actual destination, so the real dangerous URL is left in the output untouched.
💡 Proof and suggested fix
Confirmed by running sanitize_markdown("[(redacted) against this branch: it returns "[(redacted)]((redacted) — the label was redacted but the `(redacted) destination survives verbatim in the sanitized output that gets written to the work item description. This is a full sanitizer bypass for the deny-by-default URL-scheme policy this module documents.
Root cause: source.find(dest_url) searches the whole range (which pulldown_cmark gives as the entire [label](dest) span for inline links), and returns the first match — the label text, when it happens to contain the same bytes as the destination.
Fix direction: search from the end of the span backwards (destinations are the last non-title component before the closing )/> in inline links, or use rfind for standard inline syntax), or better, walk the source structurally (e.g. locate the last (...) pair, or the reference-definition span which doesn't have this ambiguity) rather than a naive forward substring search over the whole element. At minimum, prefer rfind over find for inline links/images, since the destination always follows the label in that syntax.
This also compounds with the sanitize_markdown_html region-overlap handling (the range.start < cursor skip at line 128), since a mislocated destination range that starts before the enclosing element's redact range can cause the outer (correct) region to be skipped entirely once regions are sorted only by start.
There was a problem hiding this comment.
Confirmed and fixed in 8d0f6f0. locate_destination now starts the search after the label terminator (]( for inline links/images, ]: for reference definitions), since the destination always follows the label in those syntaxes; an autolink has no label so the search still starts at 0. [javascript:alert(1)](javascript:alert(1)) now sanitizes to [javascript:alert(1)]((redacted)) instead of leaving the live destination. Added three regression tests: label-repeats-destination for inline links and for reference definitions, plus an allowed-scheme case asserting the destination stays out of the text transform while the label is still processed.
There was a problem hiding this comment.
Solid test coverage for this rewrite: the shared corpus (markdown-rendering-corpus.json) genuinely closes the gap called out in the PR description — a Rust golden test and an against-real-ADO executor scenario now both pin the same expected output, so the sanitizer and the ADO storage/rendering path can't silently drift apart. The vitest suite for work-item.ts covers happy path, skip-on-missing-type, format mismatch, security leak, fenced-code mangling, and the Bug repro-steps field — genuinely behavior-level assertions, not implementation mirroring. The sanitize.rs/markdown.rs test rewrite correctly loosens assertions that pinned exact escaping behavior that changed by design (allowlist vs. blocklist), while adding new coverage for autolinks, reference definitions, encoded schemes, and nested code — no unjustified weakening found.
One gap noted inline: AdoRest.workItemTypeExists has no direct unit test exercising its real request()/allow404 path.
🧪 Test quality analysis by Test Quality Sentinel · auto · 87.2 AIC · ⌖ 15.3 AIC · ⊞ 11.2K
Comment /review to run again
| /** True when the project defines the given work item type (e.g. `Bug`). */ | ||
| async workItemTypeExists(type: string): Promise<boolean> { | ||
| const path = this.projPath( | ||
| `_apis/wit/workitemtypes/${AdoRest.seg(type)}?api-version=7.1`, |
There was a problem hiding this comment.
workItemTypeExists has no direct unit test — it's only reached indirectly through the renderingCtx mock in work-item-scenarios.test.ts, which stubs ctx.rest entirely and never exercises this method's real body (URL construction via AdoRest.seg, or the allow404 → undefined → false translation through the shared request() path). A bug in the 404 handling here (e.g. treating a non-404 error as "type doesn't exist") would ship silently.
💡 suggestion
Add a focused unit test for AdoRest.workItemTypeExists (mocking fetch/request directly) asserting: 404 → false, 200 → true, and a non-404 error still throws rather than being swallowed as false.
There was a problem hiding this comment.
Added scripts/ado-script/src/executor-e2e/__tests__/ado-rest.test.ts in 8d0f6f0, stubbing global fetch: 200 → true (also asserting the encoded project/type URL), 404 → false, and 403 → throws rather than being swallowed as a missing type.
jamesadevine
commented
Aug 21, 2026
@copilot address feedback |
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
jamesadevine
commented
Aug 21, 2026
@copilot comprehensively review the new sanitization code for back compost breaks and security holes. Post your review as a comment. |
Review: |
jamesadevine
commented
Aug 21, 2026
@copilot fixup all reviewer findings including low pri ones |
…e that looks like markup Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
All findings from my review are fixed in Security
Fidelity
Each item has a regression test, plus an idempotency test over the corpus and the tricky inputs. |
Summary
The Markdown sanitizer rewrite changes what ends up in a work item description, but nothing asserted the rendered result —
scenarios/work-item.tsonly checked title and assignee, so a rendering regression would ship silently.sanitize_markdownreaches ADO through exactly one path:create-work-item's description, written toSystem.Description(orMicrosoft.VSTS.TCM.ReproStepsfor Bugs) with a/multilineFieldsFormat/<field>=Markdownpatch. This adds coverage for that path at both the sub-second and against-real-ADO levels.Shared corpus (single source of truth)
scripts/ado-script/src/executor-e2e/scenarios/markdown-rendering-corpus.jsonholdsinput(headings, nested lists, GFM + HTML tables withcolspan,<details>/<summary>,<kbd>/<sub>/<sup>, autolink, image, and denied constructs interleaved with their fenced-code twins) andexpected(the sanitized golden), as line arrays.include_str!s it; the harness imports it. A deliberate rendering change means editingexpectedin one file — the two layers cannot drift.Rust
src/sanitize/markdown.rs:rendering_corpustest module + golden test assertingsanitize_markdown(input) == expected.src/safe_outputs/create_work_item.rs: wiremock test pinning the default path — the patch carries the sanitized golden and/multilineFieldsFormat/System.Description = Markdown.Executor E2E
create-work-item-rendering(Task) andcreate-work-item-rendering-bug(Bug) propose the raw corpus, then assert on the stored field:<script/onerror=/<iframe/javascript:outside fenced code, while the fenced twins survive verbatim;multilineFieldsFormat: Markdown;AdoRest.getWorkItemnow surfacesmultilineFieldsFormat; newworkItemTypeExistslets the Bug scenarioSkipErrorwhere the type is undefined.Deviation worth reviewing: ADO does not reliably surface
multilineFieldsFormaton read and I could not probe AgentPlayground from the sandbox, so assertion (2) is conditional — it fails when the value is present and notMarkdown, and logs a note when absent, with the patch itself pinned by the new Rust test. This is the fallback the plan allowed; if the field does come back on AgentPlayground, the conditional should be tightened to a hard requirement. The optional comment-based renderer probe was skipped, since comments go throughsanitize_text, not the changed code.Test plan
cargo test --all-targets(new golden + executor patch tests included)npx vitest runinscripts/ado-script— 1149 tests, including new coverage for golden mismatch, non-Markdown format, leaked construct, mangled fenced code, and the Bug-type skipnpm run typecheck;cargo clippy --all-targetsshows only a pre-existing warningallScenarios); no pipeline change needed and they have not been exercised against ADO from here