From fec3bad9ae20e5e9c07cee859997b1435512645e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:13:04 +0000 Subject: [PATCH 1/4] Initial plan From 08dcfb801970d57f01d1f2b77fc2cd4c4bf6c877 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:25:55 +0000 Subject: [PATCH 2/4] Update URL redaction to show sanitized domain with redacted path Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/collect_ndjson_output.test.cjs | 10 +- actions/setup/js/sanitize_content.test.cjs | 127 +++++++++++++++++- actions/setup/js/sanitize_content_core.cjs | 42 +++++- actions/setup/js/sanitize_output.test.cjs | 28 ++-- 4 files changed, 179 insertions(+), 28 deletions(-) diff --git a/actions/setup/js/collect_ndjson_output.test.cjs b/actions/setup/js/collect_ndjson_output.test.cjs index 806ba8f20d4..0a13ce13dbe 100644 --- a/actions/setup/js/collect_ndjson_output.test.cjs +++ b/actions/setup/js/collect_ndjson_output.test.cjs @@ -1179,7 +1179,7 @@ describe("collect_ndjson_output.cjs", () => { (fs.mkdirSync("/opt/gh-aw/safeoutputs", { recursive: !0 }), fs.writeFileSync(configPath, __config), await eval(`(async () => { ${collectScript}; await main(); })()`)); const outputCall = mockCore.setOutput.mock.calls.find(call => "output" === call[0]), parsedOutput = JSON.parse(outputCall[1]); - expect(parsedOutput.items[0].body).toBe("Use https://github.com/repo for code, avoid (redacted) and (redacted) but z3 -v:10 should work"); + expect(parsedOutput.items[0].body).toBe("Use https://github.com/repo for code, avoid /redacted and /redacted but z3 -v:10 should work"); }), it("should handle mixed protocols and command flags in complex text", async () => { const testFile = "/tmp/gh-aw/test-ndjson-output.txt", @@ -1191,7 +1191,7 @@ describe("collect_ndjson_output.cjs", () => { (fs.mkdirSync("/opt/gh-aw/safeoutputs", { recursive: !0 }), fs.writeFileSync(configPath, __config), await eval(`(async () => { ${collectScript}; await main(); })()`)); const outputCall = mockCore.setOutput.mock.calls.find(call => "output" === call[0]), parsedOutput = JSON.parse(outputCall[1]); - expect(parsedOutput.items[0].body).toBe("Install from https://github.com/z3prover/z3, then run: z3 -v:10 -memory:high -timeout:30000. Avoid (redacted) or (redacted)"); + expect(parsedOutput.items[0].body).toBe("Install from https://github.com/z3prover/z3, then run: z3 -v:10 -memory:high -timeout:30000. Avoid /redacted or /redacted"); }), it("should preserve allowed domains while redacting unknown ones", async () => { const testFile = "/tmp/gh-aw/test-ndjson-output.txt", @@ -1202,7 +1202,7 @@ describe("collect_ndjson_output.cjs", () => { (fs.mkdirSync("/opt/gh-aw/safeoutputs", { recursive: !0 }), fs.writeFileSync(configPath, __config), await eval(`(async () => { ${collectScript}; await main(); })()`)); const outputCall = mockCore.setOutput.mock.calls.find(call => "output" === call[0]), parsedOutput = JSON.parse(outputCall[1]); - expect(parsedOutput.items[0].body).toBe("GitHub URLs: https://github.com/repo, https://api.github.com/users, https://githubusercontent.com/file. External: (redacted)"); + expect(parsedOutput.items[0].body).toBe("GitHub URLs: https://github.com/repo, https://api.github.com/users, https://githubusercontent.com/file. External: /redacted"); }), it("should handle @mentions neutralization", async () => { const testFile = "/tmp/gh-aw/test-ndjson-output.txt", @@ -1250,7 +1250,7 @@ describe("collect_ndjson_output.cjs", () => { (fs.mkdirSync("/opt/gh-aw/safeoutputs", { recursive: !0 }), fs.writeFileSync(configPath, __config), await eval(`(async () => { ${collectScript}; await main(); })()`)); const outputCall = mockCore.setOutput.mock.calls.find(call => "output" === call[0]), parsedOutput = JSON.parse(outputCall[1]); - (expect(parsedOutput.items[0].body).toBe("Allowed: https://example.com/page, https://sub.example.com/file, https://test.org/doc. Blocked: (redacted), (redacted)"), + (expect(parsedOutput.items[0].body).toBe("Allowed: https://example.com/page, https://sub.example.com/file, https://test.org/doc. Blocked: /redacted, /redacted"), delete process.env.GH_AW_ALLOWED_DOMAINS, originalServerUrl && (process.env.GITHUB_SERVER_URL = originalServerUrl), originalApiUrl && (process.env.GITHUB_API_URL = originalApiUrl)); @@ -1314,7 +1314,7 @@ describe("collect_ndjson_output.cjs", () => { const outputCall = mockCore.setOutput.mock.calls.find(call => "output" === call[0]), parsedOutput = JSON.parse(outputCall[1]); (expect(parsedOutput.items[0].title).toBe("PR with z3 -v:10 flag"), - expect(parsedOutput.items[0].body).toBe("Testing https://github.com/repo and (redacted)"), + expect(parsedOutput.items[0].body).toBe("Testing https://github.com/repo and /redacted"), expect(parsedOutput.items[0].branch).toBe("feature/z3-timeout:5000"), expect(parsedOutput.items[0].labels).toEqual(["bug", "z3:solver"])); }), diff --git a/actions/setup/js/sanitize_content.test.cjs b/actions/setup/js/sanitize_content.test.cjs index aec4075286e..81dc73d107c 100644 --- a/actions/setup/js/sanitize_content.test.cjs +++ b/actions/setup/js/sanitize_content.test.cjs @@ -362,9 +362,9 @@ describe("sanitize_content.cjs", () => { expect(result).toBe("Visit https://github.com"); }); - it("should redact HTTP URLs", () => { + it("should redact HTTP URLs with sanitized domain", () => { const result = sanitizeContent("Visit http://example.com"); - expect(result).toContain("(redacted)"); + expect(result).toContain("/redacted"); expect(mockCore.info).toHaveBeenCalled(); }); @@ -399,9 +399,9 @@ describe("sanitize_content.cjs", () => { }); }); - it("should redact disallowed domains", () => { + it("should redact disallowed domains with sanitized domain", () => { const result = sanitizeContent("Visit https://evil.com/malicious"); - expect(result).toContain("(redacted)"); + expect(result).toContain("/redacted"); expect(mockCore.info).toHaveBeenCalled(); }); @@ -443,7 +443,7 @@ describe("sanitize_content.cjs", () => { it("should redact domains not matching wildcard pattern", () => { process.env.GH_AW_ALLOWED_DOMAINS = "*.example.com"; const result = sanitizeContent("Visit https://evil.com/malicious"); - expect(result).toContain("(redacted)"); + expect(result).toContain("/redacted"); }); it("should support mixed wildcard and plain domains", () => { @@ -455,7 +455,7 @@ describe("sanitize_content.cjs", () => { it("should redact domains with wildcards that don't match pattern", () => { process.env.GH_AW_ALLOWED_DOMAINS = "*.github.com"; const result = sanitizeContent("Visit https://github.io/page"); - expect(result).toContain("(redacted)"); + expect(result).toContain("/redacted"); }); it("should handle multiple levels of subdomains with wildcard", () => { @@ -465,6 +465,121 @@ describe("sanitize_content.cjs", () => { }); }); + describe("domain sanitization", () => { + let sanitizeDomainName; + + beforeEach(async () => { + const module = await import("./sanitize_content_core.cjs"); + sanitizeDomainName = module.sanitizeDomainName; + }); + + it("should keep domains with 3 or fewer parts unchanged", () => { + expect(sanitizeDomainName("example.com")).toBe("example.com"); + expect(sanitizeDomainName("sub.example.com")).toBe("sub.example.com"); + // deep.sub.example.com has 4 parts, so it should be truncated + expect(sanitizeDomainName("a.b.c")).toBe("a.b.c"); + }); + + it("should truncate domains with more than 3 parts", () => { + expect(sanitizeDomainName("a.b.c.d.com")).toBe("a.b.c..."); + expect(sanitizeDomainName("one.two.three.four.five.com")).toBe("one.two.three..."); + }); + + it("should remove non-alphanumeric characters from each part", () => { + expect(sanitizeDomainName("ex@mple.com")).toBe("exmple.com"); + expect(sanitizeDomainName("my-domain.co.uk")).toBe("mydomain.co.uk"); + expect(sanitizeDomainName("test_site.com")).toBe("testsite.com"); + }); + + it("should handle empty parts after sanitization", () => { + expect(sanitizeDomainName("...example.com")).toBe("example.com"); + expect(sanitizeDomainName("test..com")).toBe("test.com"); + expect(sanitizeDomainName("a.-.-.b.com")).toBe("a.b.com"); + }); + + it("should handle domains with ports", () => { + expect(sanitizeDomainName("example.com:8080")).toBe("example.com8080"); + }); + + it("should handle complex special characters", () => { + expect(sanitizeDomainName("ex!@#$ample.c%^&*om")).toBe("example.com"); + expect(sanitizeDomainName("test.ex@mple.co-uk")).toBe("test.exmple.couk"); + }); + + it("should handle null and undefined inputs", () => { + expect(sanitizeDomainName(null)).toBe(""); + expect(sanitizeDomainName(undefined)).toBe(""); + }); + + it("should handle empty string", () => { + expect(sanitizeDomainName("")).toBe(""); + }); + + it("should handle non-string inputs", () => { + expect(sanitizeDomainName(123)).toBe(""); + expect(sanitizeDomainName({})).toBe(""); + }); + + it("should handle domains that become empty after sanitization", () => { + expect(sanitizeDomainName("...")).toBe(""); + expect(sanitizeDomainName("@#$")).toBe(""); + }); + + it("should truncate with ... for 4+ parts after sanitization", () => { + expect(sanitizeDomainName("alpha.beta.gamma.delta.epsilon.com")).toBe("alpha.beta.gamma..."); + }); + + it("should handle mixed case domains", () => { + expect(sanitizeDomainName("Example.COM")).toBe("Example.COM"); + expect(sanitizeDomainName("Sub.Example.Com")).toBe("Sub.Example.Com"); + }); + + it("should handle unicode characters", () => { + expect(sanitizeDomainName("tëst.com")).toBe("tst.com"); + expect(sanitizeDomainName("例え.com")).toBe("com"); + }); + + it("should apply sanitization in actual URL redaction for HTTP", () => { + const result = sanitizeContent("Visit http://sub.example.malicious.com/path"); + expect(result).toContain("/redacted"); + }); + + it("should apply sanitization in actual URL redaction for HTTPS", () => { + const result = sanitizeContent("Visit https://very.deep.nested.subdomain.evil.com/path"); + expect(result).toContain("/redacted"); + }); + + it("should handle domains with special characters in URL context", () => { + // The regex captures domain up to first special character like @ + // So http://ex@mple-domain.co_uk.net captures only "ex" as domain + const result = sanitizeContent("Visit http://ex@mple-domain.co_uk.net/path"); + expect(result).toContain("/redacted"); + }); + + it("should preserve simple domain structure", () => { + const result = sanitizeContent("Visit http://test.com/path"); + expect(result).toContain("/redacted"); + }); + + it("should handle subdomain with 3 parts correctly", () => { + // api.v2.example.com has 4 parts, so it will be truncated + const result = sanitizeContent("Visit http://api.v2.example.com/endpoint"); + expect(result).toContain("/redacted"); + }); + + it("should handle 5+ part domains", () => { + expect(sanitizeDomainName("a.b.c.d.e.f.com")).toBe("a.b.c..."); + }); + + it("should handle domains starting with numbers", () => { + expect(sanitizeDomainName("123.456.example.com")).toBe("123.456.example..."); + }); + + it("should handle single part domain", () => { + expect(sanitizeDomainName("localhost")).toBe("localhost"); + }); + }); + describe("bot trigger neutralization", () => { it("should neutralize 'fixes #123' patterns", () => { const result = sanitizeContent("This fixes #123"); diff --git a/actions/setup/js/sanitize_content_core.cjs b/actions/setup/js/sanitize_content_core.cjs index 6795f15ce20..2c558025164 100644 --- a/actions/setup/js/sanitize_content_core.cjs +++ b/actions/setup/js/sanitize_content_core.cjs @@ -135,7 +135,37 @@ function buildAllowedDomains() { } /** - * Sanitize URL protocols - replace non-https with (redacted) + * Sanitize a domain name to only include alphanumeric characters and dots, + * keeping up to 3 domain parts (e.g., sub.example.com). + * If more than 3 parts exist, truncates with "..." + * @param {string} domain - The domain to sanitize + * @returns {string} The sanitized domain + */ +function sanitizeDomainName(domain) { + if (!domain || typeof domain !== "string") { + return ""; + } + + // Split domain into parts + const parts = domain.split("."); + + // Keep only alphanumeric characters in each part + const sanitizedParts = parts.map(part => part.replace(/[^a-zA-Z0-9]/g, "")); + + // Filter out empty parts + const nonEmptyParts = sanitizedParts.filter(part => part.length > 0); + + // Take up to 3 parts + if (nonEmptyParts.length <= 3) { + return nonEmptyParts.join("."); + } else { + // Take first 3 parts and add "..." + return nonEmptyParts.slice(0, 3).join(".") + "..."; + } +} + +/** + * Sanitize URL protocols - replace non-https with /redacted * @param {string} s - The string to process * @returns {string} The string with non-https protocols redacted */ @@ -149,6 +179,7 @@ function sanitizeUrlProtocols(s) { // Extract domain for http/ftp/file/ssh/git protocols if (domain) { 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}`); @@ -157,6 +188,8 @@ function sanitizeUrlProtocols(s) { core.debug(`Redacted URL (full): ${match}`); } addRedactedDomain(domainLower); + // Return sanitized domain format + return sanitized ? `<${sanitized}>/redacted` : "(redacted)"; } else { // For other protocols (data:, javascript:, etc.), track the protocol itself const protocolMatch = match.match(/^([^:]+):/); @@ -172,8 +205,8 @@ function sanitizeUrlProtocols(s) { } addRedactedDomain(protocol); } + return "(redacted)"; } - return "(redacted)"; }); } @@ -221,6 +254,7 @@ function sanitizeUrlDomains(s, allowed) { return match; // Keep the full URL as-is } else { // 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}`); @@ -229,7 +263,8 @@ function sanitizeUrlDomains(s, allowed) { core.debug(`Redacted URL (full): ${match}`); } addRedactedDomain(hostname); - return "(redacted)"; + // Return sanitized domain format + return sanitized ? `<${sanitized}>/redacted` : "(redacted)"; } }); } @@ -522,6 +557,7 @@ module.exports = { buildAllowedDomains, buildAllowedGitHubReferences, getCurrentRepoSlug, + sanitizeDomainName, sanitizeUrlProtocols, sanitizeUrlDomains, neutralizeCommands, diff --git a/actions/setup/js/sanitize_output.test.cjs b/actions/setup/js/sanitize_output.test.cjs index 348f2b373a6..6eb4f3f232f 100644 --- a/actions/setup/js/sanitize_output.test.cjs +++ b/actions/setup/js/sanitize_output.test.cjs @@ -84,7 +84,7 @@ const mockCore = { }), it("should block HTTP URLs while preserving HTTPS URLs", () => { const result = sanitizeContentFunction("HTTP: http://bad.com and HTTPS: https://github.com"); - (expect(result).toContain("(redacted)"), expect(result).toContain("https://github.com"), expect(result).not.toContain("http://bad.com")); + (expect(result).toContain("/redacted"), expect(result).toContain("https://github.com"), expect(result).not.toContain("http://bad.com")); }), it("should block various unsafe protocols", () => { const result = sanitizeContentFunction("Bad: ftp://file.com javascript:alert(1) file://local data:text/html,