From 5059ec39e1272cd25f9d4b59dbc4894851a99ced Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:46:02 +0000 Subject: [PATCH 1/7] Initial plan From 0d9457ab2c7f3ca3acd56bbdec0bfc003f9f47fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:54:49 +0000 Subject: [PATCH 2/7] fix: preserve template delimiters inside fenced code blocks in safe-output sanitizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `neutralizeTemplateDelimiters` function now uses `applyToNonCodeRegions` so that `{{`, `${`, `<%=`, `{#`, and `{%` inside fenced code blocks (including GitHub `suggestion` blocks) and inline code spans are no longer escaped. This fixes the bug where suggesting documentation changes that include valid template-like syntax (e.g. Elastic `{{fleet-server}}`) would corrupt the patch when a reviewer clicked "Commit suggestion". Updated tests: - Replaced the "should escape template delimiters in code blocks" test (which asserted the now-incorrect behaviour) with four targeted tests: - inline code spans are preserved - fenced code blocks are preserved - `suggestion` fenced blocks are preserved ← main regression case - template delimiters outside code blocks are still escaped Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b3d5dfb0-f0a0-4bc0-8294-ec87a63672bd Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/sanitize_content.test.cjs | 34 +++++- actions/setup/js/sanitize_content_core.cjs | 114 +++++++++++++-------- 2 files changed, 101 insertions(+), 47 deletions(-) diff --git a/actions/setup/js/sanitize_content.test.cjs b/actions/setup/js/sanitize_content.test.cjs index a2540cdb436..29b7406d186 100644 --- a/actions/setup/js/sanitize_content.test.cjs +++ b/actions/setup/js/sanitize_content.test.cjs @@ -2416,11 +2416,37 @@ describe("sanitize_content.cjs", () => { expect(mockCore.warning).not.toHaveBeenCalledWith(expect.stringContaining("Template-like syntax detected")); }); - it("should escape template delimiters in code blocks", () => { - // Template delimiters should still be escaped even in code blocks - // This is defense-in-depth - we escape everywhere + it("should preserve template delimiters inside inline code spans", () => { + // Template delimiters inside inline code spans must NOT be escaped – + // code content is reproduced verbatim. const result = sanitizeContent("`code with {{ var }}`"); - expect(result).toBe("`code with \\{\\{ var }}`"); + expect(result).toBe("`code with {{ var }}`"); + }); + + it("should preserve template delimiters inside fenced code blocks", () => { + // Template delimiters inside fenced code blocks must NOT be escaped. + const input = "Text before\n```\n{{ template_var }}\n```\nText after"; + const result = sanitizeContent(input); + expect(result).toContain("{{ template_var }}"); + expect(result).not.toContain("\\{\\{"); + }); + + it("should preserve template delimiters inside GitHub suggestion blocks", () => { + // Suggestion blocks are fenced code blocks – their content is applied literally + // as a patch, so template delimiters must not be escaped. + const input = "Review comment\n```suggestion\nRefer to [Advanced {{fleet-server}} options](/ref.md).\n```"; + const result = sanitizeContent(input); + expect(result).toContain("{{fleet-server}}"); + expect(result).not.toContain("\\{\\{"); + }); + + it("should still escape template delimiters outside code blocks", () => { + // Template delimiters in regular prose must still be escaped. + const input = "Outside: {{ var }}\n```\nInside: {{ safe }}\n```\nAlso outside: {{ other }}"; + const result = sanitizeContent(input); + expect(result).toContain("\\{\\{ var }}"); + expect(result).toContain("\\{\\{ other }}"); + expect(result).toContain("{{ safe }}"); // inside fence – preserved }); it("should handle real-world GitHub Actions template expressions", () => { diff --git a/actions/setup/js/sanitize_content_core.cjs b/actions/setup/js/sanitize_content_core.cjs index 00c77995992..ca726e4f049 100644 --- a/actions/setup/js/sanitize_content_core.cjs +++ b/actions/setup/js/sanitize_content_core.cjs @@ -744,69 +744,97 @@ function neutralizeBotTriggers(s, maxBotMentions = MAX_BOT_TRIGGER_REFERENCES) { * template syntax, but this prevents issues if content is later processed by * template engines (Jinja2, Liquid, ERB, JavaScript template literals). * + * Fenced code blocks (including GitHub suggestion blocks) and inline code spans are + * preserved verbatim so that legitimate source content inside code regions is not altered. + * * @param {string} s - The string to process - * @returns {string} The string with escaped template delimiters + * @returns {string} The string with escaped template delimiters (outside code regions) */ function neutralizeTemplateDelimiters(s) { if (!s || typeof s !== "string") { return ""; } - let result = s; - let templatesDetected = false; + // Track which template types were detected (outside code regions) for deduped logging. + const detectedTypes = new Set(); - // Escape Jinja2/Liquid double curly braces: {{ ... }} - // Replace {{ with \{\{ to prevent template evaluation - if (/\{\{/.test(result)) { - templatesDetected = true; - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: Jinja2/Liquid double braces {{"); + /** + * Escapes template delimiters in a plain-text segment (no fenced blocks or inline code). + * @param {string} text - Plain text to escape + * @returns {string} Text with template delimiters escaped + */ + function escapeInText(text) { + let result = text; + + // Escape Jinja2/Liquid double curly braces: {{ ... }} + // Replace {{ with \{\{ to prevent template evaluation + if (/\{\{/.test(result)) { + if (!detectedTypes.has("jinja2")) { + detectedTypes.add("jinja2"); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: Jinja2/Liquid double braces {{"); + } + } + result = result.replace(/\{\{/g, "\\{\\{"); } - result = result.replace(/\{\{/g, "\\{\\{"); - } - // Escape ERB delimiters: <%= ... %> - // Replace <%= with \<%= to prevent ERB evaluation - if (/<%=/.test(result)) { - templatesDetected = true; - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: ERB delimiter <%="); + // Escape ERB delimiters: <%= ... %> + // Replace <%= with \<%= to prevent ERB evaluation + if (/<%=/.test(result)) { + if (!detectedTypes.has("erb")) { + detectedTypes.add("erb"); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: ERB delimiter <%="); + } + } + result = result.replace(/<%=/g, "\\<%="); } - result = result.replace(/<%=/g, "\\<%="); - } - // Escape JavaScript template literal delimiters: ${ ... } - // Replace ${ with \$\{ to prevent template literal evaluation - if (/\$\{/.test(result)) { - templatesDetected = true; - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: JavaScript template literal ${"); + // Escape JavaScript template literal delimiters: ${ ... } + // Replace ${ with \$\{ to prevent template literal evaluation + if (/\$\{/.test(result)) { + if (!detectedTypes.has("js")) { + detectedTypes.add("js"); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: JavaScript template literal ${"); + } + } + result = result.replace(/\$\{/g, "\\$\\{"); } - result = result.replace(/\$\{/g, "\\$\\{"); - } - // Escape Jinja2 comment delimiters: {# ... #} - // Replace {# with \{\# to prevent Jinja2 comment evaluation - if (/\{#/.test(result)) { - templatesDetected = true; - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: Jinja2 comment {#"); + // Escape Jinja2 comment delimiters: {# ... #} + // Replace {# with \{\# to prevent Jinja2 comment evaluation + if (/\{#/.test(result)) { + if (!detectedTypes.has("jinja2comment")) { + detectedTypes.add("jinja2comment"); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: Jinja2 comment {#"); + } + } + result = result.replace(/\{#/g, "\\{\\#"); } - result = result.replace(/\{#/g, "\\{\\#"); - } - // Escape Jekyll raw blocks: {% raw %} and {% endraw %} - // Replace {% with \{\% to prevent Jekyll directive evaluation - if (/\{%/.test(result)) { - templatesDetected = true; - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: Jekyll/Liquid directive {%"); + // Escape Jekyll raw blocks: {% raw %} and {% endraw %} + // Replace {% with \{\% to prevent Jekyll directive evaluation + if (/\{%/.test(result)) { + if (!detectedTypes.has("jekyll")) { + detectedTypes.add("jekyll"); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: Jekyll/Liquid directive {%"); + } + } + result = result.replace(/\{%/g, "\\{\\%"); } - result = result.replace(/\{%/g, "\\{\\%"); + + return result; } + // Apply escaping only to non-code regions (skip fenced code blocks and inline code spans). + // This preserves the verbatim content of suggestion blocks and other code fences. + const result = applyToNonCodeRegions(s, escapeInText); + // Log a summary warning if any template patterns were detected - if (templatesDetected && typeof core !== "undefined" && core.warning) { + if (detectedTypes.size > 0 && typeof core !== "undefined" && core.warning) { core.warning( "Template-like syntax detected and escaped. " + "This is a defense-in-depth measure to prevent potential template injection " + From fe0182812d20c8c158ba052820dd3130a0de99b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:35:41 +0000 Subject: [PATCH 3/7] fix: remove typeof core checks from neutralizeTemplateDelimiters core is always available as a global (or a shim is loaded), so the typeof core !== "undefined" guards are unnecessary. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/9b0d87f4-8e26-4ea0-9f21-395bf174b0e4 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/sanitize_content_core.cjs | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/actions/setup/js/sanitize_content_core.cjs b/actions/setup/js/sanitize_content_core.cjs index ca726e4f049..5730bbadf17 100644 --- a/actions/setup/js/sanitize_content_core.cjs +++ b/actions/setup/js/sanitize_content_core.cjs @@ -771,9 +771,7 @@ function neutralizeTemplateDelimiters(s) { if (/\{\{/.test(result)) { if (!detectedTypes.has("jinja2")) { detectedTypes.add("jinja2"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: Jinja2/Liquid double braces {{"); - } + core.info("Template syntax detected: Jinja2/Liquid double braces {{"); } result = result.replace(/\{\{/g, "\\{\\{"); } @@ -783,9 +781,7 @@ function neutralizeTemplateDelimiters(s) { if (/<%=/.test(result)) { if (!detectedTypes.has("erb")) { detectedTypes.add("erb"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: ERB delimiter <%="); - } + core.info("Template syntax detected: ERB delimiter <%="); } result = result.replace(/<%=/g, "\\<%="); } @@ -795,9 +791,7 @@ function neutralizeTemplateDelimiters(s) { if (/\$\{/.test(result)) { if (!detectedTypes.has("js")) { detectedTypes.add("js"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: JavaScript template literal ${"); - } + core.info("Template syntax detected: JavaScript template literal ${"); } result = result.replace(/\$\{/g, "\\$\\{"); } @@ -807,9 +801,7 @@ function neutralizeTemplateDelimiters(s) { if (/\{#/.test(result)) { if (!detectedTypes.has("jinja2comment")) { detectedTypes.add("jinja2comment"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: Jinja2 comment {#"); - } + core.info("Template syntax detected: Jinja2 comment {#"); } result = result.replace(/\{#/g, "\\{\\#"); } @@ -819,9 +811,7 @@ function neutralizeTemplateDelimiters(s) { if (/\{%/.test(result)) { if (!detectedTypes.has("jekyll")) { detectedTypes.add("jekyll"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: Jekyll/Liquid directive {%"); - } + core.info("Template syntax detected: Jekyll/Liquid directive {%"); } result = result.replace(/\{%/g, "\\{\\%"); } @@ -834,7 +824,7 @@ function neutralizeTemplateDelimiters(s) { const result = applyToNonCodeRegions(s, escapeInText); // Log a summary warning if any template patterns were detected - if (detectedTypes.size > 0 && typeof core !== "undefined" && core.warning) { + if (detectedTypes.size > 0) { core.warning( "Template-like syntax detected and escaped. " + "This is a defense-in-depth measure to prevent potential template injection " + From f9cf5e1dea462a9b20956b6166ea114a29b1a260 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:47:12 +0000 Subject: [PATCH 4/7] test: add extensive unit + fuzz tests for parser (getFencedCodeRanges, applyFnOutsideInlineCode, applyToNonCodeRegions) 63 new tests covering: - getFencedCodeRanges contract (backtick/tilde fences, info strings, suggestion blocks, unclosed fences, closing fence length, fence character mismatch, adjacent/consecutive blocks, CRLF) - applyFnOutsideInlineCode inline code span handling (single/double/triple backticks, mismatched counts, nested backticks, very long spans, many spans in a row) - applyToNonCodeRegions mixed code region combinations - Fuzz-style invariant tests (identity transform, fn never sees fenced content, no crash on adversarial inputs, fn never receives non-strings) - Edge cases (empty fence, fence-only document, Windows CRLF, Unicode) No mocks; core is stubbed as a plain object. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ac84c1d9-91a1-4cd1-a204-871dcd17d5a2 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../js/sanitize_content_core_parser.test.cjs | 625 ++++++++++++++++++ 1 file changed, 625 insertions(+) create mode 100644 actions/setup/js/sanitize_content_core_parser.test.cjs diff --git a/actions/setup/js/sanitize_content_core_parser.test.cjs b/actions/setup/js/sanitize_content_core_parser.test.cjs new file mode 100644 index 00000000000..65291d9c03b --- /dev/null +++ b/actions/setup/js/sanitize_content_core_parser.test.cjs @@ -0,0 +1,625 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +/** + * Unit + fuzz tests for the core markdown parser helpers: + * - getFencedCodeRanges + * - applyFnOutsideInlineCode + * - applyToNonCodeRegions + * + * No mocks are used; core is set to a minimal stub so that functions that + * call core.info / core.warning do not throw during the tests that exercise + * the full sanitize pipeline. + */ + +describe("sanitize_content_core.cjs – parser internals", () => { + let getFencedCodeRanges; + let applyFnOutsideInlineCode; + let applyToNonCodeRegions; + + beforeEach(async () => { + // Set up a minimal stub so code that calls core.* doesn't throw. + // We deliberately avoid vi.fn() to keep tests mock-free. + global.core = { + info: () => {}, + warning: () => {}, + debug: () => {}, + error: () => {}, + }; + + const mod = await import("./sanitize_content_core.cjs"); + // applyToNonCodeRegions is exported; the two lower-level helpers are + // accessed indirectly through applyToNonCodeRegions in the fuzz section. + // For unit tests we grab them from the module if available, otherwise + // we exercise them through applyToNonCodeRegions. + getFencedCodeRanges = mod.getFencedCodeRanges ?? null; + applyFnOutsideInlineCode = mod.applyFnOutsideInlineCode ?? null; + applyToNonCodeRegions = mod.applyToNonCodeRegions; + }); + + // --------------------------------------------------------------------------- + // getFencedCodeRanges – only if exported directly + // --------------------------------------------------------------------------- + describe("getFencedCodeRanges (via applyToNonCodeRegions contract)", () => { + /** + * Helper: verify that a transform fn is NOT called on regions inside a + * fenced block and IS called on regions outside it. + */ + function verifyFences(input, expectedOutside, expectedInsideRaw) { + const calls = []; + applyToNonCodeRegions(input, chunk => { + calls.push(chunk); + return chunk; + }); + + // The raw fence text should appear verbatim in the result + const result = applyToNonCodeRegions(input, chunk => chunk.toUpperCase()); + return { result, calls }; + } + + it("empty string returns empty string", () => { + expect(applyToNonCodeRegions("", x => x + "X")).toBe(""); + }); + + it("null returns empty string", () => { + expect(applyToNonCodeRegions(null, x => x)).toBe(""); + }); + + it("non-string truthy value is returned as-is (not coerced)", () => { + // The guard is `!s || typeof s !== "string"` → `return s || ""` + // For a truthy non-string like 42: !42 is false, typeof 42 !== "string" is true + // so it enters the guard; then `42 || ""` is 42 – returned as-is. + expect(applyToNonCodeRegions(42, x => x)).toBe(42); + }); + + it("plain text with no fences applies fn to everything", () => { + const result = applyToNonCodeRegions("hello world", s => s.toUpperCase()); + expect(result).toBe("HELLO WORLD"); + }); + + it("backtick-fenced block is preserved verbatim", () => { + const input = "before\n```\ncontent\n```\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("```\ncontent\n```"); + expect(result).toContain("BEFORE"); + expect(result).toContain("AFTER"); + }); + + it("tilde-fenced block is preserved verbatim", () => { + const input = "before\n~~~\ncontent\n~~~\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("~~~\ncontent\n~~~"); + expect(result).toContain("BEFORE"); + expect(result).toContain("AFTER"); + }); + + it("fenced block with info string (language tag) is preserved verbatim", () => { + const input = "lead\n```js\nconst x = 1;\n```\ntrail"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("```js\nconst x = 1;\n```"); + expect(result).toContain("LEAD"); + expect(result).toContain("TRAIL"); + }); + + it("suggestion block is preserved verbatim", () => { + const input = "Review:\n```suggestion\nRefer to {{fleet-server}}.\n```\nEnd"; + const result = applyToNonCodeRegions(input, s => s.replace(/\{\{/g, "\\{\\{")); + expect(result).toContain("{{fleet-server}}"); + expect(result).not.toContain("\\{\\{fleet-server"); + }); + + it("multiple fenced blocks are each preserved verbatim", () => { + const input = "a\n```\nblock1\n```\nb\n```\nblock2\n```\nc"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("```\nblock1\n```"); + expect(result).toContain("```\nblock2\n```"); + expect(result).toContain("A\n"); + expect(result).toContain("\nB\n"); + expect(result).toContain("\nC"); + }); + + it("longer opening fence requires longer or equal closing fence", () => { + // ```` closes with ```` or longer, NOT with ``` + const input = "text\n````\ncode\n```\nstill code\n````\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + // The ``` line inside is not a valid closer for a ```` fence + expect(result).toContain("````\ncode\n```\nstill code\n````"); + expect(result).toContain("TEXT"); + expect(result).toContain("AFTER"); + }); + + it("unclosed fenced block treats rest of string as code (safe fallback)", () => { + const input = "prose\n```\nunclosed code {{ secret }}"; + const result = applyToNonCodeRegions(input, s => s.replace(/\{\{/g, "ESCAPED")); + // The unclosed block's content must NOT be transformed + expect(result).toContain("{{ secret }}"); + expect(result).not.toContain("ESCAPED"); + }); + + it("fence must start at beginning of (possibly indented) line", () => { + // Fences with up to 3 spaces of indentation are valid in CommonMark + const input = " ```\ncontent\n ```\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("content"); // preserved, not uppercased + }); + + it("backtick fence is not closed by tilde fence", () => { + const input = "a\n```\ncontent\n~~~\nstill in backtick block\n```\nb"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("content"); + expect(result).toContain("still in backtick block"); + // Text after the closing ``` is outside – fn applied + expect(result).toContain("B"); + }); + + it("tilde fence is not closed by backtick fence", () => { + const input = "a\n~~~\ncontent\n```\nstill code\n~~~\nb"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("content"); + expect(result).toContain("still code"); + expect(result).toContain("B"); + }); + + it("content between two fenced blocks is transformed", () => { + const input = "```\nblock1\n```\nmiddle text\n```\nblock2\n```"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("MIDDLE TEXT"); + expect(result).toContain("```\nblock1\n```"); + expect(result).toContain("```\nblock2\n```"); + }); + + it("text before first fence is transformed", () => { + const input = "preamble\n```\ncode\n```"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("PREAMBLE"); + expect(result).toContain("```\ncode\n```"); + }); + + it("text after last fence is transformed", () => { + const input = "```\ncode\n```\npostamble"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("POSTAMBLE"); + expect(result).toContain("```\ncode\n```"); + }); + + it("no trailing newline – fence on last line is treated as unclosed (safe)", () => { + const input = "text\n```\ncode"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + // code inside unclosed fence preserved verbatim + expect(result).toContain("code"); + }); + + it("empty fenced block is preserved verbatim", () => { + const input = "before\n```\n```\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("```\n```"); + expect(result).toContain("BEFORE"); + expect(result).toContain("AFTER"); + }); + + it("fence-only document (entire content is one block) has fn not called at all", () => { + const input = "```\nall code\n```"; + let fnCalled = false; + applyToNonCodeRegions(input, s => { + if (s.trim()) fnCalled = true; + return s; + }); + expect(fnCalled).toBe(false); + }); + + it("adjacent fenced blocks with no prose between them", () => { + const input = "```\nblock1\n```\n```\nblock2\n```"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("```\nblock1\n```"); + expect(result).toContain("```\nblock2\n```"); + }); + }); + + // --------------------------------------------------------------------------- + // applyFnOutsideInlineCode – exercised via applyToNonCodeRegions (no fences) + // --------------------------------------------------------------------------- + describe("inline code span handling (via applyToNonCodeRegions, no fences)", () => { + const upper = s => s.toUpperCase(); + + it("plain text with no backticks applies fn to everything", () => { + expect(applyToNonCodeRegions("hello", upper)).toBe("HELLO"); + }); + + it("single-backtick inline code span is preserved verbatim", () => { + const result = applyToNonCodeRegions("before `code` after", upper); + expect(result).toBe("BEFORE `code` AFTER"); + }); + + it("double-backtick inline code span is preserved verbatim", () => { + const result = applyToNonCodeRegions("a ``two backtick code`` b", upper); + expect(result).toBe("A ``two backtick code`` B"); + }); + + it("triple-backtick inline code is preserved verbatim", () => { + const result = applyToNonCodeRegions("a ```triple``` b", upper); + expect(result).toBe("A ```triple``` B"); + }); + + it("inline code span containing template delimiters is preserved", () => { + const escape = s => s.replace(/\{\{/g, "ESCAPED"); + const result = applyToNonCodeRegions("text `{{ var }}` text", escape); + expect(result).toBe("text `{{ var }}` text"); + }); + + it("backtick with no closing match is treated as literal text (fn applied)", () => { + const result = applyToNonCodeRegions("text `unclosed", upper); + // The whole string (including the backtick) should be uppercased + expect(result).toBe("TEXT `UNCLOSED"); + }); + + it("multiple inline code spans in one line are each preserved", () => { + const result = applyToNonCodeRegions("a `x` b `y` c", upper); + expect(result).toBe("A `x` B `y` C"); + }); + + it("mismatched backtick counts: single-backtick sequence is not closed by double", () => { + // `code`` – the opening ` looks for a matching single ` to close. + // The `` at the end is two backticks, which is NOT the same count. + // So the opening ` is unmatched and treated as literal text. + const result = applyToNonCodeRegions("text `code`` end", upper); + // fn applied to entire string (no valid inline code span) + expect(result).toBe("TEXT `CODE`` END"); + }); + + it("double-backtick span closes only with double-backtick", () => { + // ``code`end`` – the inner ` is not a valid closer for `` + const result = applyToNonCodeRegions("before ``code`end`` after", upper); + expect(result).toContain("``code`end``"); + expect(result).toContain("BEFORE"); + expect(result).toContain("AFTER"); + }); + + it("inline code containing a single backtick via double-backtick wrapper", () => { + // CommonMark: `` ` `` renders as a single backtick + const result = applyToNonCodeRegions("a `` ` `` b", upper); + expect(result).toBe("A `` ` `` B"); + }); + + it("empty inline code span (`` ``) is preserved verbatim", () => { + const result = applyToNonCodeRegions("a `` b", upper); + // `` has no closing `` so it is literal text – fn applied + expect(result).toBe("A `` B"); + }); + + it("consecutive inline code spans with content between them", () => { + const result = applyToNonCodeRegions("`a` middle `b`", upper); + expect(result).toBe("`a` MIDDLE `b`"); + }); + + it("inline code at start of string", () => { + const result = applyToNonCodeRegions("`code` rest", upper); + expect(result).toBe("`code` REST"); + }); + + it("inline code at end of string", () => { + const result = applyToNonCodeRegions("rest `code`", upper); + expect(result).toBe("REST `code`"); + }); + + it("entire string is inline code", () => { + let fnCalled = false; + applyToNonCodeRegions("`entire`", s => { + if (s.trim()) fnCalled = true; + return s; + }); + expect(fnCalled).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // applyToNonCodeRegions – mixed fenced + inline combinations + // --------------------------------------------------------------------------- + describe("applyToNonCodeRegions – mixed code regions", () => { + it("inline code inside fenced block is entirely preserved verbatim", () => { + const input = "text\n```\nuse `backtick` inside\n```\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("use `backtick` inside"); + expect(result).toContain("AFTER"); + }); + + it("inline code BEFORE fenced block is also preserved", () => { + const input = "use `inline` before\n```\ncode\n```\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("`inline`"); + expect(result).toContain("USE"); + expect(result).toContain("BEFORE"); + }); + + it("inline code AFTER fenced block is preserved", () => { + const input = "```\ncode\n```\nuse `inline` after"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("`inline`"); + expect(result).toContain("AFTER"); + }); + + it("fn is idempotent when applied to non-code regions (identity transform)", () => { + const input = "prose\n```\ncode\n```\n`inline` prose"; + const result = applyToNonCodeRegions(input, s => s); + expect(result).toBe(input); + }); + + it("function returning empty string removes all prose", () => { + const input = "prose\n```\ncode\n```\nmore prose"; + const result = applyToNonCodeRegions(input, () => ""); + // Only the fenced block should survive + expect(result).toContain("```\ncode\n```"); + expect(result).not.toContain("prose"); + }); + + it("function wrapping each chunk does not merge chunks", () => { + const input = "a\n```\nblock\n```\nb"; + const result = applyToNonCodeRegions(input, s => `[${s}]`); + // The fence content must remain unwrapped + expect(result).toContain("```\nblock\n```"); + expect(result).not.toContain("[```"); + }); + }); + + // --------------------------------------------------------------------------- + // Fuzz-style tests – property-based verification + // --------------------------------------------------------------------------- + describe("fuzz: applyToNonCodeRegions invariants", () => { + /** + * Generate a range of varied markdown-like strings to verify core invariants: + * 1. Identity transform → output === input (length/content preserved) + * 2. Content inside fenced blocks is never transformed + * 3. The returned string has the same byte-length as input when fn is identity + */ + + const FENCE_CHARS = ["```", "~~~", "````", "~~~~"]; + const MARKERS = ["{{var}}", "${expr}", "<%=erb%>", "{#comment#}", "{%tag%}"]; + const TEXTS = ["hello world", "@user mention", "normal prose", "line\ntwo", ""]; + + function makeFencedBlock(fence, content) { + return `${fence}\n${content}\n${fence}`; + } + + const seedCases = [ + // Single fenced block + ...FENCE_CHARS.map(f => makeFencedBlock(f, MARKERS[0])), + // Prose + fenced block + ...FENCE_CHARS.map(f => `preamble\n${makeFencedBlock(f, MARKERS[1])}\npostamble`), + // Multiple fenced blocks + ...FENCE_CHARS.map(f => `${makeFencedBlock(f, MARKERS[0])}\ninterlude\n${makeFencedBlock(f, MARKERS[2])}`), + // Inline code in prose + ...MARKERS.map(m => `prose with \`${m}\` inline code`), + // Inline code + fenced block + `\`${MARKERS[0]}\` before\n${makeFencedBlock("```", MARKERS[1])}\nafter`, + // Deeply nested appearance (not real nesting – just consecutive) + makeFencedBlock("```", makeFencedBlock("~~~", "deep")), + // Text only + ...TEXTS.filter(t => t.length > 0), + // Unclosed fence + "prose\n```\nunclosed {{ leaked }}", + // Empty fence + "before\n```\n```\nafter", + ]; + + it("identity transform preserves input exactly (fuzz seed cases)", () => { + for (const input of seedCases) { + const result = applyToNonCodeRegions(input, s => s); + expect(result).toBe(input); + } + }); + + it("fenced block content is never passed to fn (fuzz seed cases)", () => { + const SENTINEL = "\x00TOUCHED\x00"; + for (const input of seedCases) { + // Build an fn that poisons any text it receives by prepending SENTINEL + const poisoned = applyToNonCodeRegions(input, s => SENTINEL + s); + // Extract every fenced block from `input` and verify none were poisoned + const fenceRe = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1\s*$/gm; + let m; + while ((m = fenceRe.exec(input)) !== null) { + expect(poisoned).not.toContain(SENTINEL + m[2]); + } + } + }); + + it("result is a string for all seed inputs", () => { + for (const input of seedCases) { + const result = applyToNonCodeRegions(input, s => s.split("").reverse().join("")); + expect(typeof result).toBe("string"); + } + }); + + it("throws or returns string for adversarial inputs (no crash)", () => { + const adversarial = [ + // Very long fence opener + "`".repeat(200) + "\ncontent\n" + "`".repeat(200), + // Mixed fence characters in same block + "```\ncontent\n~~~", + // Backtick storm + "`".repeat(50), + // Alternating open/close-like lines + "```\n~~~\n```\n~~~", + // Nested look-alike + "```\n```\ncontent\n```\n```", + // Only newlines + "\n\n\n", + // Very long plain text + "a".repeat(10000), + // Unicode + "```\n\u{1F600} emoji\n```\n\u{1F4A5}boom", + // Null bytes in content + "text\x00\x01\x02\n```\n\x00\n```", + ]; + for (const input of adversarial) { + let result; + expect(() => { + result = applyToNonCodeRegions(input, s => s); + }).not.toThrow(); + expect(typeof result).toBe("string"); + } + }); + + it("fn called exactly once per non-code prose segment (fuzz seed)", () => { + // Verify that fn is not called with empty strings when there is no prose + for (const input of seedCases) { + const calls = []; + applyToNonCodeRegions(input, s => { + calls.push(s); + return s; + }); + // fn should never be called with undefined or non-string + for (const call of calls) { + expect(typeof call).toBe("string"); + } + } + }); + + // Property: applyToNonCodeRegions(applyToNonCodeRegions(x, f), f) behaves consistently + // when f is idempotent – result should equal single application. + it("idempotent fn gives same result on second application (fuzz seed)", () => { + const idempotentFn = s => s.replace(/\{\{/g, "X{{X").replace(/X\{\{X/g, "\\{\\{"); + // eslint-disable-next-line no-unused-vars + for (const input of seedCases) { + const once = applyToNonCodeRegions(input, idempotentFn); + const twice = applyToNonCodeRegions(once, idempotentFn); + // After first pass everything is already replaced; second pass should not change prose + // We only assert no crashes and that both are strings. + expect(typeof once).toBe("string"); + expect(typeof twice).toBe("string"); + } + }); + }); + + // --------------------------------------------------------------------------- + // Edge cases for getFencedCodeRanges (verified via applyToNonCodeRegions) + // --------------------------------------------------------------------------- + describe("getFencedCodeRanges edge cases (contract verification)", () => { + it("fence on very first line with no preceding text", () => { + const input = "```\ncode\n```\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("```\ncode\n```"); + expect(result).toContain("AFTER"); + }); + + it("fence on very last line (no trailing newline) – unclosed is safe", () => { + const input = "before\n```"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + // The lonely ``` line starts an unclosed block; safe fallback preserves it + expect(typeof result).toBe("string"); + }); + + it("fence character must appear ≥3 times to be valid (two backticks form inline code instead)", () => { + // "text ``\ncontent\n``\nafter" – the double-backtick `` on the first content + // line is NOT a fence (needs ≥3). getFencedCodeRanges finds no fences. + // applyFnOutsideInlineCode then processes the whole string: the opening `` + // at "text ``" finds a matching closing `` at the start of the line "``", + // so "``\ncontent\n``" is treated as a multi-line inline code span and is + // preserved verbatim. Only the surrounding prose is transformed. + const input = "text ``\ncontent\n``\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("``\ncontent\n``"); // inline code span preserved + expect(result).toContain("TEXT "); // prose before transformed + expect(result).toContain("\nAFTER"); // prose after transformed + }); + + it("closing fence can be longer than opening fence", () => { + // Opening: ``` (3), Closing: ```` (4) – valid: closing must be ≥ opening length + const input = "before\n```\ncode\n````\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("```\ncode\n````"); + expect(result).toContain("BEFORE"); + expect(result).toContain("AFTER"); + }); + + it("four-tilde fence opened and closed correctly", () => { + const input = "a\n~~~~\ncontent\n~~~~\nb"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain("~~~~\ncontent\n~~~~"); + expect(result).toContain("A"); + expect(result).toContain("B"); + }); + + it("fenced block with Windows-style CRLF line endings", () => { + // The parser splits on \n; CRLF (\r\n) lines will have trailing \r in content. + // Verify the parser doesn't crash and the fence content is still preserved. + const input = "before\r\n```\r\ncode\r\n```\r\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(typeof result).toBe("string"); + expect(result).toContain("code"); + }); + + it("three consecutive fenced blocks separated by single newlines", () => { + const input = "```\nA\n```\n```\nB\n```\n```\nC\n```"; + const result = applyToNonCodeRegions(input, () => "PROSE"); + // No prose between consecutive blocks – fn should not add PROSE within blocks + expect(result).toContain("```\nA\n```"); + expect(result).toContain("```\nB\n```"); + expect(result).toContain("```\nC\n```"); + }); + + it("fenced block immediately inside another fenced block is treated as content", () => { + // Outer ``` starts a block; the inner ``` is just content until the outer closes + const input = "outer open\n```\ninner ```\nstill code\n```\nafter"; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + // "inner ```" and "still code" should be preserved verbatim + expect(result).toContain("inner ```"); + expect(result).toContain("still code"); + expect(result).toContain("OUTER OPEN"); + expect(result).toContain("AFTER"); + }); + }); + + // --------------------------------------------------------------------------- + // applyFnOutsideInlineCode specific edge cases (via applyToNonCodeRegions, no fences) + // --------------------------------------------------------------------------- + describe("inline code edge cases (no fences present)", () => { + it("string with only backticks and no content", () => { + const result = applyToNonCodeRegions("```", s => s.toUpperCase()); + // Three backticks alone (no newline, not a full fence line in the line-based parser) + // Treated as prose OR as start of unclosed block – either way must not throw + expect(typeof result).toBe("string"); + }); + + it("backtick followed immediately by newline", () => { + const result = applyToNonCodeRegions("`\n`", s => s.toUpperCase()); + expect(typeof result).toBe("string"); + }); + + it("inline code spanning backtick runs of length 4", () => { + const result = applyToNonCodeRegions("a ````four```` b", s => s.toUpperCase()); + expect(result).toContain("````four````"); + expect(result).toContain("A "); + expect(result).toContain(" B"); + }); + + it("backtick inside inline code content does not end the span prematurely", () => { + // `` a`b `` – the single backtick inside does not close the double-backtick span + const result = applyToNonCodeRegions("x `` a`b `` y", s => s.toUpperCase()); + expect(result).toContain("`` a`b ``"); + expect(result).toContain("X "); + expect(result).toContain(" Y"); + }); + + it("multi-line inline code (inline code spans do not cross line boundaries in CommonMark)", () => { + // In CommonMark, inline code CAN span lines. Verify no crash and sensible output. + const result = applyToNonCodeRegions("`line1\nline2`", s => s.toUpperCase()); + expect(typeof result).toBe("string"); + }); + + it("very long inline code span is preserved", () => { + const content = "x".repeat(5000); + const input = `before \`${content}\` after`; + const result = applyToNonCodeRegions(input, s => s.toUpperCase()); + expect(result).toContain(`\`${content}\``); + expect(result).toContain("BEFORE"); + expect(result).toContain("AFTER"); + }); + + it("many inline code spans in a row", () => { + const spans = Array.from({ length: 50 }, (_, i) => `\`s${i}\``).join(" gap "); + const result = applyToNonCodeRegions(spans, s => s.toUpperCase()); + for (let i = 0; i < 50; i++) { + expect(result).toContain(`\`s${i}\``); + } + expect(result).toContain("GAP"); + }); + }); +}); From f8a9047834b52660831741df6b8b623363ba81fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:15:37 +0000 Subject: [PATCH 5/7] fix: address all PR review comments on parser tests and core guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore typeof core !== "undefined" guards in neutralizeTemplateDelimiters (core.info x5, core.warning) to match the rest of the file and prevent ReferenceError when running in a plain Node context - Add afterEach to delete global.core in the parser test suite so it does not leak into other test files - Remove unused verifyFences helper function (dead code) - Fix misleading test name/comment that contradicted itself about whether inline code spans can cross line boundaries (they can per CommonMark §6.11) Agent-Logs-Url: https://github.com/github/gh-aw/sessions/72c80953-78ef-4fc6-9f8a-3b139a884202 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/sanitize_content_core.cjs | 22 +++++++++++---- .../js/sanitize_content_core_parser.test.cjs | 28 ++++++------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/actions/setup/js/sanitize_content_core.cjs b/actions/setup/js/sanitize_content_core.cjs index 5730bbadf17..ca726e4f049 100644 --- a/actions/setup/js/sanitize_content_core.cjs +++ b/actions/setup/js/sanitize_content_core.cjs @@ -771,7 +771,9 @@ function neutralizeTemplateDelimiters(s) { if (/\{\{/.test(result)) { if (!detectedTypes.has("jinja2")) { detectedTypes.add("jinja2"); - core.info("Template syntax detected: Jinja2/Liquid double braces {{"); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: Jinja2/Liquid double braces {{"); + } } result = result.replace(/\{\{/g, "\\{\\{"); } @@ -781,7 +783,9 @@ function neutralizeTemplateDelimiters(s) { if (/<%=/.test(result)) { if (!detectedTypes.has("erb")) { detectedTypes.add("erb"); - core.info("Template syntax detected: ERB delimiter <%="); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: ERB delimiter <%="); + } } result = result.replace(/<%=/g, "\\<%="); } @@ -791,7 +795,9 @@ function neutralizeTemplateDelimiters(s) { if (/\$\{/.test(result)) { if (!detectedTypes.has("js")) { detectedTypes.add("js"); - core.info("Template syntax detected: JavaScript template literal ${"); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: JavaScript template literal ${"); + } } result = result.replace(/\$\{/g, "\\$\\{"); } @@ -801,7 +807,9 @@ function neutralizeTemplateDelimiters(s) { if (/\{#/.test(result)) { if (!detectedTypes.has("jinja2comment")) { detectedTypes.add("jinja2comment"); - core.info("Template syntax detected: Jinja2 comment {#"); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: Jinja2 comment {#"); + } } result = result.replace(/\{#/g, "\\{\\#"); } @@ -811,7 +819,9 @@ function neutralizeTemplateDelimiters(s) { if (/\{%/.test(result)) { if (!detectedTypes.has("jekyll")) { detectedTypes.add("jekyll"); - core.info("Template syntax detected: Jekyll/Liquid directive {%"); + if (typeof core !== "undefined" && core.info) { + core.info("Template syntax detected: Jekyll/Liquid directive {%"); + } } result = result.replace(/\{%/g, "\\{\\%"); } @@ -824,7 +834,7 @@ function neutralizeTemplateDelimiters(s) { const result = applyToNonCodeRegions(s, escapeInText); // Log a summary warning if any template patterns were detected - if (detectedTypes.size > 0) { + if (detectedTypes.size > 0 && typeof core !== "undefined" && core.warning) { core.warning( "Template-like syntax detected and escaped. " + "This is a defense-in-depth measure to prevent potential template injection " + diff --git a/actions/setup/js/sanitize_content_core_parser.test.cjs b/actions/setup/js/sanitize_content_core_parser.test.cjs index 65291d9c03b..c211e6b3944 100644 --- a/actions/setup/js/sanitize_content_core_parser.test.cjs +++ b/actions/setup/js/sanitize_content_core_parser.test.cjs @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; /** * Unit + fuzz tests for the core markdown parser helpers: @@ -36,26 +36,14 @@ describe("sanitize_content_core.cjs – parser internals", () => { applyToNonCodeRegions = mod.applyToNonCodeRegions; }); + afterEach(() => { + delete global.core; + }); + // --------------------------------------------------------------------------- // getFencedCodeRanges – only if exported directly // --------------------------------------------------------------------------- describe("getFencedCodeRanges (via applyToNonCodeRegions contract)", () => { - /** - * Helper: verify that a transform fn is NOT called on regions inside a - * fenced block and IS called on regions outside it. - */ - function verifyFences(input, expectedOutside, expectedInsideRaw) { - const calls = []; - applyToNonCodeRegions(input, chunk => { - calls.push(chunk); - return chunk; - }); - - // The raw fence text should appear verbatim in the result - const result = applyToNonCodeRegions(input, chunk => chunk.toUpperCase()); - return { result, calls }; - } - it("empty string returns empty string", () => { expect(applyToNonCodeRegions("", x => x + "X")).toBe(""); }); @@ -598,8 +586,10 @@ describe("sanitize_content_core.cjs – parser internals", () => { expect(result).toContain(" Y"); }); - it("multi-line inline code (inline code spans do not cross line boundaries in CommonMark)", () => { - // In CommonMark, inline code CAN span lines. Verify no crash and sensible output. + it("multi-line inline code span is handled without crashing", () => { + // CommonMark spec §6.11: inline code spans CAN span line endings – + // a newline is treated as a space. The parser here does not strip the + // newline, but must not crash or hang on such input. const result = applyToNonCodeRegions("`line1\nline2`", s => s.toUpperCase()); expect(typeof result).toBe("string"); }); From fd30d3e6bda95e40bc56e14eee3470bdae3697c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:28:45 +0000 Subject: [PATCH 6/7] fix: remove all typeof core guards from sanitize_content_core.cjs A shim is always loaded in Node.js environments, so the guards are unnecessary. Removed all 13 remaining typeof core !== "undefined" guard wrappers across the file (URL redaction, mention escaping, template delimiter detection, GitHub reference filtering). Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d67f99b2-824d-43ca-a316-13659e8da6a5 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/sanitize_content_core.cjs | 62 ++++++---------------- 1 file changed, 16 insertions(+), 46 deletions(-) diff --git a/actions/setup/js/sanitize_content_core.cjs b/actions/setup/js/sanitize_content_core.cjs index ca726e4f049..f275c330ec0 100644 --- a/actions/setup/js/sanitize_content_core.cjs +++ b/actions/setup/js/sanitize_content_core.cjs @@ -208,12 +208,8 @@ function sanitizeUrlProtocols(s) { const domainLower = domain.toLowerCase(); const sanitized = sanitizeDomainName(domainLower); const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower; - if (typeof core !== "undefined" && core.info) { - core.info(`Redacted URL: ${truncated}`); - } - if (typeof core !== "undefined" && core.debug) { - core.debug(`Redacted URL (full): ${match}`); - } + core.info(`Redacted URL: ${truncated}`); + core.debug(`Redacted URL (full): ${match}`); addRedactedDomain(domainLower); // Return sanitized domain format return sanitized ? `(${sanitized}/redacted)` : "(redacted)"; @@ -224,12 +220,8 @@ function sanitizeUrlProtocols(s) { const protocol = protocolMatch[1] + ":"; // Truncate the matched URL for logging (keep first 12 chars + "...") const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match; - if (typeof core !== "undefined" && core.info) { - core.info(`Redacted URL: ${truncated}`); - } - if (typeof core !== "undefined" && core.debug) { - core.debug(`Redacted URL (full): ${match}`); - } + core.info(`Redacted URL: ${truncated}`); + core.debug(`Redacted URL (full): ${match}`); addRedactedDomain(protocol); } return "(redacted)"; @@ -288,12 +280,8 @@ function sanitizeUrlDomains(s, allowed) { // Redact the domain but preserve the protocol and structure for debugging const sanitized = sanitizeDomainName(hostname); const truncated = hostname.length > 12 ? hostname.substring(0, 12) + "..." : hostname; - if (typeof core !== "undefined" && core.info) { - core.info(`Redacted URL: ${truncated}`); - } - if (typeof core !== "undefined" && core.debug) { - core.debug(`Redacted URL (full): ${match}`); - } + core.info(`Redacted URL: ${truncated}`); + core.debug(`Redacted URL (full): ${match}`); addRedactedDomain(hostname); // Return sanitized domain format return sanitized ? `(${sanitized}/redacted)` : "(redacted)"; @@ -356,9 +344,7 @@ function neutralizeAllMentions(s) { // This prevents bypass patterns like "test_@user" from escaping sanitization return s.replace(/(^|[^A-Za-z0-9`])@([A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (m, p1, p2) => { // Log when a mention is escaped to help debug issues - if (typeof core !== "undefined" && core.info) { - core.info(`Escaped mention: @${p2} (not in allowed list)`); - } + core.info(`Escaped mention: @${p2} (not in allowed list)`); return `${p1}\`@${p2}\``; }); } @@ -771,9 +757,7 @@ function neutralizeTemplateDelimiters(s) { if (/\{\{/.test(result)) { if (!detectedTypes.has("jinja2")) { detectedTypes.add("jinja2"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: Jinja2/Liquid double braces {{"); - } + core.info("Template syntax detected: Jinja2/Liquid double braces {{"); } result = result.replace(/\{\{/g, "\\{\\{"); } @@ -783,9 +767,7 @@ function neutralizeTemplateDelimiters(s) { if (/<%=/.test(result)) { if (!detectedTypes.has("erb")) { detectedTypes.add("erb"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: ERB delimiter <%="); - } + core.info("Template syntax detected: ERB delimiter <%="); } result = result.replace(/<%=/g, "\\<%="); } @@ -795,9 +777,7 @@ function neutralizeTemplateDelimiters(s) { if (/\$\{/.test(result)) { if (!detectedTypes.has("js")) { detectedTypes.add("js"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: JavaScript template literal ${"); - } + core.info("Template syntax detected: JavaScript template literal ${"); } result = result.replace(/\$\{/g, "\\$\\{"); } @@ -807,9 +787,7 @@ function neutralizeTemplateDelimiters(s) { if (/\{#/.test(result)) { if (!detectedTypes.has("jinja2comment")) { detectedTypes.add("jinja2comment"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: Jinja2 comment {#"); - } + core.info("Template syntax detected: Jinja2 comment {#"); } result = result.replace(/\{#/g, "\\{\\#"); } @@ -819,9 +797,7 @@ function neutralizeTemplateDelimiters(s) { if (/\{%/.test(result)) { if (!detectedTypes.has("jekyll")) { detectedTypes.add("jekyll"); - if (typeof core !== "undefined" && core.info) { - core.info("Template syntax detected: Jekyll/Liquid directive {%"); - } + core.info("Template syntax detected: Jekyll/Liquid directive {%"); } result = result.replace(/\{%/g, "\\{\\%"); } @@ -834,7 +810,7 @@ function neutralizeTemplateDelimiters(s) { const result = applyToNonCodeRegions(s, escapeInText); // Log a summary warning if any template patterns were detected - if (detectedTypes.size > 0 && typeof core !== "undefined" && core.warning) { + if (detectedTypes.size > 0) { core.warning( "Template-like syntax detected and escaped. " + "This is a defense-in-depth measure to prevent potential template injection " + @@ -859,9 +835,7 @@ function buildAllowedGitHubReferences() { } if (allowedRefsEnv === "") { - if (typeof core !== "undefined" && core.info) { - core.info("GitHub reference filtering: all references will be escaped (GH_AW_ALLOWED_GITHUB_REFS is empty)"); - } + core.info("GitHub reference filtering: all references will be escaped (GH_AW_ALLOWED_GITHUB_REFS is empty)"); return []; // Empty array means escape all references } @@ -869,9 +843,7 @@ function buildAllowedGitHubReferences() { .split(",") .map(ref => ref.trim().toLowerCase()) .filter(ref => ref); - if (typeof core !== "undefined" && core.info) { - core.info(`GitHub reference filtering: allowed repos = ${refs.join(", ")}`); - } + core.info(`GitHub reference filtering: allowed repos = ${refs.join(", ")}`); return refs; } @@ -931,9 +903,7 @@ function neutralizeGitHubReferences(s, allowedRepos) { const refText = owner && repo ? `${owner}/${repo}#${issueNum}` : `#${issueNum}`; // Log when a reference is escaped - if (typeof core !== "undefined" && core.info) { - core.info(`Escaped GitHub reference: ${refText} (not in allowed list)`); - } + core.info(`Escaped GitHub reference: ${refText} (not in allowed list)`); return `${prefix}\`${refText}\``; } From b8ae0f05d9ededb4b977747627a00d612b911e13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:04:31 +0000 Subject: [PATCH 7/7] fix: add global.core setup to sanitize_title.test.cjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests in this file call sanitizeTitle → sanitizeContentCore, which now calls core.info/core.debug unconditionally (guards removed). Add beforeEach/afterEach to set up and tear down a global.core stub, consistent with sanitize_content.test.cjs. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/a74f7f25-be32-4dc6-b4e5-48ded4834099 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/sanitize_title.test.cjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/sanitize_title.test.cjs b/actions/setup/js/sanitize_title.test.cjs index e9731994e5f..1e5bff1711b 100644 --- a/actions/setup/js/sanitize_title.test.cjs +++ b/actions/setup/js/sanitize_title.test.cjs @@ -1,9 +1,21 @@ // @ts-check -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs"); describe("sanitize_title", () => { + beforeEach(() => { + global.core = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + delete global.core; + }); describe("sanitizeTitle", () => { describe("basic sanitization", () => { it("should return empty string for null/undefined", () => {