diff --git a/__tests__/proxy.test.ts b/__tests__/proxy.test.ts new file mode 100644 index 000000000..103e40c9f --- /dev/null +++ b/__tests__/proxy.test.ts @@ -0,0 +1,209 @@ +import {expect, test, jest, beforeEach, afterEach} from '@jest/globals' +import {initializeProxySupport} from '../src/proxy' +import * as core from '@actions/core' +import { + EnvHttpProxyAgent, + setGlobalDispatcher, + getGlobalDispatcher, + Dispatcher +} from 'undici' + +// Mock @actions/core +jest.mock('@actions/core') + +describe('proxy support', () => { + let originalEnv: NodeJS.ProcessEnv + let mockDebug: jest.MockedFunction + let mockInfo: jest.MockedFunction + let mockWarning: jest.MockedFunction + let originalDispatcher: Dispatcher + + beforeEach(() => { + // Save original environment + originalEnv = {...process.env} + originalDispatcher = getGlobalDispatcher() + + delete process.env.HTTP_PROXY + delete process.env.HTTPS_PROXY + delete process.env.http_proxy + delete process.env.https_proxy + + // Setup mocks + mockDebug = jest.mocked(core.debug) + mockInfo = jest.mocked(core.info) + mockWarning = jest.mocked(core.warning) + + mockDebug.mockClear() + mockInfo.mockClear() + mockWarning.mockClear() + }) + + afterEach(() => { + // Restore original environment + process.env = originalEnv + setGlobalDispatcher(originalDispatcher) + }) + + test('does nothing when no proxy is configured', () => { + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith('No proxy configuration detected') + expect(mockInfo).not.toHaveBeenCalled() + expect(mockWarning).not.toHaveBeenCalled() + }) + + test('configures proxy from HTTPS_PROXY environment variable', () => { + process.env.HTTPS_PROXY = 'http://proxy.company.com:8080' + + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith( + 'Proxy configured: proxy.company.com:8080' + ) + expect(mockWarning).not.toHaveBeenCalled() + expect(mockDebug).not.toHaveBeenCalledWith( + 'No proxy configuration detected' + ) + + const dispatcher = getGlobalDispatcher() + expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent) + }) + + test('configures proxy from https_proxy environment variable (lowercase)', () => { + process.env.https_proxy = 'http://proxy.example.com:3128' + + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith( + 'Proxy configured: proxy.example.com:3128' + ) + const dispatcher = getGlobalDispatcher() + expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent) + }) + + test('configures proxy from HTTP_PROXY environment variable', () => { + process.env.HTTP_PROXY = 'http://proxy.example.com:8888' + + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith( + 'Proxy configured: proxy.example.com:8888' + ) + }) + + test('configures proxy from http_proxy environment variable (lowercase)', () => { + process.env.http_proxy = 'http://proxy.test.com:9090' + + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith( + 'Proxy configured: proxy.test.com:9090' + ) + }) + + test('prioritizes uppercase over lowercase', () => { + process.env.HTTPS_PROXY = 'http://uppercase.com:8080' + process.env.https_proxy = 'http://lowercase.com:8080' + + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith( + 'Proxy configured: uppercase.com:8080' + ) + }) + + test('handles proxy with authentication credentials', () => { + process.env.HTTPS_PROXY = 'http://user:pass@proxy.secure.com:8080' + + initializeProxySupport() + + // Should log proxy without showing credentials + expect(mockDebug).toHaveBeenCalledWith( + 'Proxy configured: proxy.secure.com:8080' + ) + expect(mockWarning).not.toHaveBeenCalled() + + const dispatcher = getGlobalDispatcher() + expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent) + }) + + test('handles proxy with URL-encoded credentials', () => { + process.env.HTTPS_PROXY = + 'http://user%40domain:p%40ss%3Aword@proxy.com:8080' + + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith('Proxy configured: proxy.com:8080') + expect(mockWarning).not.toHaveBeenCalled() + }) + + test('handles proxy with HTTPS scheme', () => { + process.env.HTTPS_PROXY = 'https://secure-proxy.com:443' + + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith( + 'Proxy configured: secure-proxy.com:443' + ) + }) + + test('uses default port 443 for https proxy without explicit port', () => { + process.env.HTTPS_PROXY = 'https://proxy.com' + + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith('Proxy configured: proxy.com:443') + }) + + test('uses default port 80 for http proxy without explicit port', () => { + process.env.HTTP_PROXY = 'http://proxy.com' + + initializeProxySupport() + + expect(mockDebug).toHaveBeenCalledWith('Proxy configured: proxy.com:80') + }) + + test('handles invalid proxy URL gracefully', () => { + process.env.HTTPS_PROXY = 'not-a-valid-url' + + initializeProxySupport() + + expect(mockWarning).toHaveBeenCalled() + const warningCall = mockWarning.mock.calls[0][0] as string + expect(warningCall).toContain('Failed to configure proxy') + expect(warningCall).toContain('not-a-valid-url') + }) + + test('handles proxy URL with only hostname (no scheme)', () => { + process.env.HTTPS_PROXY = 'proxy.company.com:8080' + + initializeProxySupport() + + // Should fail gracefully as URL requires a scheme + expect(mockWarning).toHaveBeenCalled() + const warningCall = mockWarning.mock.calls[0][0] as string + expect(warningCall).toContain('Failed to configure proxy') + }) + + test('redacts credentials in scheme-less malformed proxy URL logs', () => { + process.env.HTTPS_PROXY = 'user:super-secret@proxy.company.com:8080' + + initializeProxySupport() + + expect(mockWarning).toHaveBeenCalled() + const warningCall = mockWarning.mock.calls[0][0] as string + expect(warningCall).toContain('Failed to configure proxy') + expect(warningCall).toContain('[REDACTED]@proxy.company.com:8080') + expect(warningCall).not.toContain('user:super-secret') + expect(warningCall).not.toContain('super-secret') + }) + + test('handles empty proxy URL', () => { + process.env.HTTPS_PROXY = '' + + initializeProxySupport() + + // Empty string is falsy, should detect as no proxy + expect(mockDebug).toHaveBeenCalledWith('No proxy configuration detected') + }) +}) diff --git a/package-lock.json b/package-lock.json index 490cbc56b..e49ed33f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "spdx-expression-parse": "^4.0.0", "spdx-satisfies": "^6.0.0", "ts-jest": "^29.4.1", + "undici": "^6.23.0", "yaml": "^2.8.1", "zod": "^3.24.1" }, @@ -178,6 +179,18 @@ "undici": "^5.28.5" } }, + "node_modules/@actions/github/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@actions/http-client": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", @@ -188,6 +201,18 @@ "undici": "^5.25.4" } }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@actions/io": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", @@ -9098,15 +9123,12 @@ "dev": true }, "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" + "node": ">=18.17" } }, "node_modules/undici-types": { @@ -9465,4 +9487,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 2320d6103..98795ff9e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "spdx-expression-parse": "^4.0.0", "spdx-satisfies": "^6.0.0", "ts-jest": "^29.4.1", + "undici": "^6.23.0", "yaml": "^2.8.1", "zod": "^3.24.1" }, @@ -64,4 +65,4 @@ "cross-spawn": ">=7.0.5", "@octokit/request-error@5.0.1": "5.1.1" } -} +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index a23fc00c6..0ed9130ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import {getInvalidLicenseChanges} from './licenses' import {getScorecardLevels} from './scorecard' import * as summary from './summary' import {getRefs} from './git-refs' +import {initializeProxySupport} from './proxy' import {groupDependenciesByManifest} from './utils' import {commentPr, MAX_COMMENT_LENGTH} from './comment-pr' @@ -123,6 +124,8 @@ interface RepoWithPrivate extends PayloadRepository { async function run(): Promise { try { + initializeProxySupport() + const config = await readConfig() const refs = getRefs(config, github.context) diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 000000000..f6088901a --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,74 @@ +import {EnvHttpProxyAgent, setGlobalDispatcher} from 'undici' +import * as core from '@actions/core' + +function sanitizeProxyUrlForLogging(proxyUrl: string): string { + try { + const url = new URL(proxyUrl) + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.hostname === '' + ) { + throw new Error('Invalid proxy URL format') + } + + const port = url.port || (url.protocol === 'https:' ? '443' : '80') + return `${url.hostname}:${port}` + } catch { + // Redact anything before the last '@' to also cover scheme-less input + // like "user:pass@proxy:8080". + const atIndex = proxyUrl.lastIndexOf('@') + if (atIndex === -1) { + return proxyUrl + } + + const hostPart = proxyUrl.slice(atIndex + 1) + const schemeMatch = proxyUrl.match(/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//) + + if (schemeMatch) { + return `${schemeMatch[0]}[REDACTED]@${hostPart}` + } + + return `[REDACTED]@${hostPart}` + } +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + return String(error) +} + +/** + * Initializes proxy support for native fetch() API in Node.js 20+. + * Uses undici's EnvHttpProxyAgent which automatically reads proxy configuration + * from environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, etc.) + * and handles credential extraction from proxy URLs. + * + * This must be called early in the application lifecycle before any fetch() calls. + */ +export function initializeProxySupport(): void { + const proxyUrl = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy + + if (!proxyUrl) { + core.debug('No proxy configuration detected') + return + } + + try { + const agent = new EnvHttpProxyAgent() + setGlobalDispatcher(agent) + + // Log proxy host without credentials + core.debug(`Proxy configured: ${sanitizeProxyUrlForLogging(proxyUrl)}`) + } catch (error: unknown) { + const sanitizedProxyUrl = sanitizeProxyUrlForLogging(proxyUrl) + core.warning( + `Failed to configure proxy from ${sanitizedProxyUrl}: ${getErrorMessage(error)}` + ) + } +}