diff --git a/package-lock.json b/package-lock.json index d479141..ea4e04a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/sarif": "2.1.7", "@types/sinon": "^10.0.16", "@types/temp": "^0.9.1", + "@types/tunnel": "^0.0.7", "@types/vscode": "^1.102.0", "@typescript-eslint/eslint-plugin": "^6.2.1", "@typescript-eslint/parser": "^6.2.1", @@ -998,6 +999,16 @@ "@types/node": "*" } }, + "node_modules/@types/tunnel": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.7.tgz", + "integrity": "sha512-VYKjZSmb2PvUwXoux4Gy4LAk7kzOB1ktkjyL4lxvpkqL2adgR+Qrh/yFyWluvJgIXWFicqs7XuzPI2NbTO/r3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", diff --git a/package.json b/package.json index c36c3cc..0f2744c 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "title": "Install Local Analysis", "description": "Get instant feedback as you type by analyzing your code locally.\n\n[Install Codacy CLI](command:codacy.installCLI)\n\nInstalls all required dependencies: Node, Python, Java", "media": { - "image": { + "image": { "light": "resources/walkthrough/light/local-analysis.svg", "dark": "resources/walkthrough/dark/local-analysis.svg", "hc": "resources/walkthrough/dark/local-analysis.svg", @@ -586,6 +586,7 @@ "@types/sarif": "2.1.7", "@types/sinon": "^10.0.16", "@types/temp": "^0.9.1", + "@types/tunnel": "^0.0.7", "@types/vscode": "^1.102.0", "@typescript-eslint/eslint-plugin": "^6.2.1", "@typescript-eslint/parser": "^6.2.1", diff --git a/src/api/index.ts b/src/api/index.ts index 0229b01..528761f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -23,8 +23,10 @@ import { import { Config } from '../common/config' import { Tools } from '../codacy/Tools' import { detectEditor } from '../auth' +import { configureAxiosProxy } from '../common/proxy' export const initializeApi = () => { + configureAxiosProxy() const ide = detectEditor() // set up OpenAPI client diff --git a/src/common/proxy.ts b/src/common/proxy.ts new file mode 100644 index 0000000..fe86519 --- /dev/null +++ b/src/common/proxy.ts @@ -0,0 +1,131 @@ +import * as vscode from 'vscode' +import * as tunnel from 'tunnel' +import { HttpProxyAgent } from 'http-proxy-agent' +import axios from 'axios' +import type { HTTPClient, HTTPClientRequest, HTTPResponse } from '@segment/analytics-node' + +function resolveProxyUrl(): string | undefined { + // VS Code setting takes precedence; an empty string means "no proxy" + const vscodeProxy = vscode.workspace.getConfiguration('http').get('proxy') + if (vscodeProxy !== undefined) { + return vscodeProxy + } + + return process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy +} + +function resolveStrictSSL(): boolean { + return vscode.workspace.getConfiguration('http').get('proxyStrictSSL', true) +} + +function resolveProxyAuthorization(): string | undefined { + return vscode.workspace.getConfiguration('http').get('proxyAuthorization') ?? undefined +} + +function resolveNoProxy(): string[] { + const raw = process.env.NO_PROXY || process.env.no_proxy || '' + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +} + +function hostMatchesNoProxy(hostname: string, entry: string): boolean { + if (entry === '*') return true + const normalized = entry.startsWith('.') ? entry.slice(1) : entry + return hostname === normalized || hostname.endsWith('.' + normalized) +} + +function shouldBypassProxy(url: string, noProxyList: string[]): boolean { + try { + const { hostname } = new URL(url) + return noProxyList.some((entry) => hostMatchesNoProxy(hostname, entry)) + } catch { + return false + } +} + +// Ensures the URL has a protocol so `new URL()` doesn't throw for bare host:port strings +function normalizeProxyUrl(url: string): string { + return url.includes('://') ? url : `http://${url}` +} + +let noProxyInterceptorId: number | null = null + +/** + * Applies proxy settings from VS Code config and environment variables to the global axios instance + */ +export function configureAxiosProxy(): void { + if (noProxyInterceptorId !== null) { + axios.interceptors.request.eject(noProxyInterceptorId) + noProxyInterceptorId = null + } + + const proxyUrl = resolveProxyUrl() + + if (proxyUrl) { + const strictSSL = resolveStrictSSL() + const authHeader = resolveProxyAuthorization() + + const normalizedUrl = normalizeProxyUrl(proxyUrl) + const parsed = new URL(normalizedUrl) + const proxyHost = parsed.hostname + const proxyPort = parseInt(parsed.port || (parsed.protocol === 'https:' ? '443' : '80'), 10) + const proxyHeaders = authHeader ? { 'Proxy-Authorization': authHeader } : undefined + const proxyOpts = { host: proxyHost, port: proxyPort, ...(proxyHeaders ? { headers: proxyHeaders } : {}) } + + // Branch on the proxy protocol so TLS proxies work too. + if (parsed.protocol === 'https:') { + axios.defaults.httpsAgent = tunnel.httpsOverHttps({ rejectUnauthorized: strictSSL, proxy: proxyOpts }) + axios.defaults.httpAgent = tunnel.httpOverHttps({ proxy: proxyOpts }) + } else { + axios.defaults.httpsAgent = tunnel.httpsOverHttp({ rejectUnauthorized: strictSSL, proxy: proxyOpts }) + axios.defaults.httpAgent = new HttpProxyAgent(normalizedUrl) + } + // Disable axios's built-in proxy parsing so the agents take full control + axios.defaults.proxy = false + + // Per-connection rejectUnauthorized on the tunnel agent is not sufficient + // in all Node.js versions to suppress TLS errors from an intercepting + // proxy cert. Mirror the VS Code proxyStrictSSL setting via the + // process-level flag so it is reliably honoured. + if (!strictSSL) { + process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0' + } else { + delete process.env['NODE_TLS_REJECT_UNAUTHORIZED'] + } + + const noProxyList = resolveNoProxy() + if (noProxyList.length > 0) { + noProxyInterceptorId = axios.interceptors.request.use((config) => { + if (config.url && shouldBypassProxy(config.url, noProxyList)) { + config.httpsAgent = undefined + config.httpAgent = undefined + config.proxy = undefined + } + return config + }) + } + } else { + axios.defaults.httpsAgent = undefined + axios.defaults.httpAgent = undefined + axios.defaults.proxy = undefined + // Restore TLS verification when proxy is removed or strictSSL is re-enabled + delete process.env['NODE_TLS_REJECT_UNAUTHORIZED'] + } +} + +/** + * An HTTPClient implementation for @segment/analytics-node that routes + * requests through the proxy configured in axios defaults (if any). + */ +export class ProxiedSegmentHTTPClient implements HTTPClient { + async makeRequest(options: HTTPClientRequest): Promise { + const response = await axios.post(options.url, options.data, { + headers: options.headers, + timeout: options.httpRequestTimeout, + validateStatus: () => true, + }) + return { status: response.status, statusText: response.statusText } + } +} diff --git a/src/common/telemetry.ts b/src/common/telemetry.ts index c901ce6..4125f0f 100644 --- a/src/common/telemetry.ts +++ b/src/common/telemetry.ts @@ -4,6 +4,7 @@ import { EventProperties } from '@segment/analytics-core' import { Organization, User } from '../api/client' import { SEGMENT_WRITE_KEY } from '../env-secrets' import { v4 as uuidv4 } from 'uuid' +import { ProxiedSegmentHTTPClient } from './proxy' class TelemetryClient { private analytics: Analytics | undefined @@ -13,7 +14,10 @@ class TelemetryClient { constructor() { if (SEGMENT_WRITE_KEY) { - this.analytics = new Analytics({ writeKey: SEGMENT_WRITE_KEY }) + this.analytics = new Analytics({ + writeKey: SEGMENT_WRITE_KEY, + httpClient: new ProxiedSegmentHTTPClient(), + }) } } diff --git a/src/extension.ts b/src/extension.ts index 7edeb2a..8d47450 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ import * as os from 'os' import { CommandType, wrapExtensionCommand } from './common/utils' import Logger from './common/logger' import { initializeApi } from './api' +import { configureAxiosProxy } from './common/proxy' import { GitProvider } from './git/GitProvider' import { CodacyCloud } from './git/CodacyCloud' import { PullRequestSummaryTree } from './views/PullRequestSummaryTree' @@ -209,6 +210,9 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(new SupportTree(context)) const setupViewProvider = activateWebview(context) + // Apply proxy settings before any outbound calls (telemetry, API, etc.) + configureAxiosProxy() + // Initialize telemetry with anonymous ID Telemetry.init(context) @@ -221,6 +225,15 @@ export async function activate(context: vscode.ExtensionContext) { context.globalState.update('codacy.hasBeenActivated', true) } + // Re-apply proxy settings when the VS Code http configuration changes + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('http')) { + configureAxiosProxy() + } + }) + ) + // Listen for workspace folder changes context.subscriptions.push( vscode.workspace.onDidChangeWorkspaceFolders(() => { diff --git a/src/test/suite/common/proxy.test.ts b/src/test/suite/common/proxy.test.ts new file mode 100644 index 0000000..fd7fb8f --- /dev/null +++ b/src/test/suite/common/proxy.test.ts @@ -0,0 +1,312 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as assert from 'assert' +import * as http from 'http' +import * as https from 'https' +import { SinonSandbox, createSandbox } from 'sinon' +import * as vscode from 'vscode' +import axios from 'axios' +import { HttpProxyAgent } from 'http-proxy-agent' +import { configureAxiosProxy } from '../../../common/proxy' + +function makeHttpConfig(opts: { proxy?: string; proxyStrictSSL?: boolean; proxyAuthorization?: string | null }) { + return { + get: (key: string, defaultValue?: T): T | undefined => { + if (key === 'proxy') return opts.proxy as unknown as T + if (key === 'proxyStrictSSL') return (opts.proxyStrictSSL !== undefined ? opts.proxyStrictSSL : defaultValue) as T + if (key === 'proxyAuthorization') return (opts.proxyAuthorization ?? undefined) as T + return defaultValue + }, + has: () => false, + inspect: () => undefined, + update: async () => {}, + } as unknown as vscode.WorkspaceConfiguration +} + +suite('configureAxiosProxy', () => { + let sandbox: SinonSandbox + let savedEnv: Record + let initialInterceptorHandlerCount: number + + setup(() => { + sandbox = createSandbox() + initialInterceptorHandlerCount = (axios.interceptors.request as any).handlers.length + savedEnv = { + NODE_TLS_REJECT_UNAUTHORIZED: process.env['NODE_TLS_REJECT_UNAUTHORIZED'], + HTTPS_PROXY: process.env['HTTPS_PROXY'], + https_proxy: process.env['https_proxy'], + HTTP_PROXY: process.env['HTTP_PROXY'], + http_proxy: process.env['http_proxy'], + NO_PROXY: process.env['NO_PROXY'], + no_proxy: process.env['no_proxy'], + } + delete process.env['NODE_TLS_REJECT_UNAUTHORIZED'] + delete process.env['HTTPS_PROXY'] + delete process.env['https_proxy'] + delete process.env['HTTP_PROXY'] + delete process.env['http_proxy'] + delete process.env['NO_PROXY'] + delete process.env['no_proxy'] + }) + + teardown(() => { + sandbox.restore() + axios.defaults.httpsAgent = undefined + axios.defaults.httpAgent = undefined + axios.defaults.proxy = undefined + // Eject any interceptors added during this test + const handlers = (axios.interceptors.request as any).handlers as Array + for (let i = initialInterceptorHandlerCount; i < handlers.length; i++) { + if (handlers[i] !== null) { + axios.interceptors.request.eject(i) + } + } + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) { + delete process.env[k] + } else { + process.env[k] = v + } + } + }) + + function stubConfig(opts: { proxy?: string; proxyStrictSSL?: boolean; proxyAuthorization?: string | null }) { + sandbox.stub(vscode.workspace, 'getConfiguration').callsFake((section?: string) => { + if (section === 'http') return makeHttpConfig(opts) + return makeHttpConfig({}) + }) + } + + function activeInterceptorCount(): number { + return ((axios.interceptors.request as any).handlers as Array).filter(Boolean).length + } + + function getLastInterceptorFn(): ((config: any) => any) | undefined { + const handlers = (axios.interceptors.request as any).handlers as Array<{ fulfilled: (c: any) => any } | null> + return [...handlers].reverse().find(Boolean)?.fulfilled + } + + // --- proxy agent setup --- + + test('sets httpsOverHttp and HttpProxyAgent for an http:// proxy', () => { + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + configureAxiosProxy() + + const httpsAgent = axios.defaults.httpsAgent as any + assert.ok(httpsAgent, 'httpsAgent must be set') + assert.strictEqual(httpsAgent.request, http.request, 'httpsAgent should be a httpsOverHttp tunnel agent') + assert.strictEqual(httpsAgent.options.proxy.host, 'proxy.example.com') + assert.strictEqual(httpsAgent.options.proxy.port, 8080) + + assert.ok(axios.defaults.httpAgent instanceof HttpProxyAgent, 'httpAgent should be an HttpProxyAgent') + assert.strictEqual(axios.defaults.proxy, false, 'axios built-in proxy parsing must be disabled') + }) + + test('sets httpsOverHttps and httpOverHttps for an https:// proxy', () => { + stubConfig({ proxy: 'https://proxy.example.com:8443' }) + configureAxiosProxy() + + const httpsAgent = axios.defaults.httpsAgent as any + assert.ok(httpsAgent, 'httpsAgent must be set') + assert.strictEqual(httpsAgent.request, https.request, 'httpsAgent should be a httpsOverHttps tunnel agent') + assert.strictEqual(httpsAgent.options.proxy.host, 'proxy.example.com') + assert.strictEqual(httpsAgent.options.proxy.port, 8443) + + const httpAgent = axios.defaults.httpAgent as any + assert.ok(httpAgent, 'httpAgent must be set') + assert.strictEqual(httpAgent.request, https.request, 'httpAgent should be a httpOverHttps tunnel agent') + assert.strictEqual(axios.defaults.proxy, false) + }) + + test('sets NODE_TLS_REJECT_UNAUTHORIZED=0 when proxyStrictSSL is false', () => { + stubConfig({ proxy: 'http://proxy.example.com:8080', proxyStrictSSL: false }) + configureAxiosProxy() + + assert.strictEqual(process.env['NODE_TLS_REJECT_UNAUTHORIZED'], '0') + const httpsAgent = axios.defaults.httpsAgent as any + assert.strictEqual(httpsAgent.options.rejectUnauthorized, false) + }) + + test('clears NODE_TLS_REJECT_UNAUTHORIZED when proxyStrictSSL is true', () => { + process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0' + stubConfig({ proxy: 'http://proxy.example.com:8080', proxyStrictSSL: true }) + configureAxiosProxy() + + assert.strictEqual(process.env['NODE_TLS_REJECT_UNAUTHORIZED'], undefined) + const httpsAgent = axios.defaults.httpsAgent as any + assert.strictEqual(httpsAgent.options.rejectUnauthorized, true) + }) + + test('includes Proxy-Authorization header when proxyAuthorization is set', () => { + stubConfig({ proxy: 'http://proxy.example.com:8080', proxyAuthorization: 'Basic dXNlcjpwYXNz' }) + configureAxiosProxy() + + const httpsAgent = axios.defaults.httpsAgent as any + assert.deepStrictEqual(httpsAgent.options.proxy.headers, { 'Proxy-Authorization': 'Basic dXNlcjpwYXNz' }) + }) + + test('no proxy headers when proxyAuthorization is not set', () => { + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + configureAxiosProxy() + + const httpsAgent = axios.defaults.httpsAgent as any + assert.strictEqual(httpsAgent.options.proxy.headers, undefined) + }) + + test('clears agents and env var when proxy is not configured', () => { + axios.defaults.httpsAgent = {} as any + axios.defaults.httpAgent = {} as any + process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0' + + stubConfig({ proxy: undefined }) + configureAxiosProxy() + + assert.strictEqual(axios.defaults.httpsAgent, undefined) + assert.strictEqual(axios.defaults.httpAgent, undefined) + assert.strictEqual(axios.defaults.proxy, undefined) + assert.strictEqual(process.env['NODE_TLS_REJECT_UNAUTHORIZED'], undefined) + }) + + test('empty VS Code proxy string is treated as no proxy', () => { + stubConfig({ proxy: '' }) + configureAxiosProxy() + + assert.strictEqual(axios.defaults.httpsAgent, undefined) + assert.strictEqual(axios.defaults.httpAgent, undefined) + }) + + test('falls back to HTTPS_PROXY env var when VS Code proxy is undefined', () => { + process.env['HTTPS_PROXY'] = 'http://envproxy.example.com:3128' + stubConfig({ proxy: undefined }) + configureAxiosProxy() + + const httpsAgent = axios.defaults.httpsAgent as any + assert.ok(httpsAgent, 'httpsAgent must be set from HTTPS_PROXY env var') + assert.strictEqual(httpsAgent.options.proxy.host, 'envproxy.example.com') + assert.strictEqual(httpsAgent.options.proxy.port, 3128) + assert.strictEqual(axios.defaults.proxy, false) + }) + + test('VS Code proxy setting takes precedence over HTTPS_PROXY env var', () => { + process.env['HTTPS_PROXY'] = 'http://envproxy.example.com:3128' + stubConfig({ proxy: 'http://vscodeproxy.example.com:8080' }) + configureAxiosProxy() + + const httpsAgent = axios.defaults.httpsAgent as any + assert.strictEqual(httpsAgent.options.proxy.host, 'vscodeproxy.example.com') + assert.strictEqual(httpsAgent.options.proxy.port, 8080) + }) + + test('uses default port 80 when http:// proxy has no explicit port', () => { + stubConfig({ proxy: 'http://proxy.example.com' }) + configureAxiosProxy() + + const httpsAgent = axios.defaults.httpsAgent as any + assert.strictEqual(httpsAgent.options.proxy.port, 80) + }) + + test('uses default port 443 when https:// proxy has no explicit port', () => { + stubConfig({ proxy: 'https://proxy.example.com' }) + configureAxiosProxy() + + const httpsAgent = axios.defaults.httpsAgent as any + assert.strictEqual(httpsAgent.options.proxy.port, 443) + }) + + test('normalizes a protocol-less proxy URL before parsing', () => { + stubConfig({ proxy: 'proxy.example.com:8080' }) + configureAxiosProxy() + + const httpsAgent = axios.defaults.httpsAgent as any + assert.ok(httpsAgent, 'httpsAgent must be set for a bare host:port proxy') + assert.strictEqual(httpsAgent.options.proxy.host, 'proxy.example.com') + assert.strictEqual(httpsAgent.options.proxy.port, 8080) + }) + + // --- NO_PROXY --- + + test('registers a request interceptor when NO_PROXY is set', () => { + process.env['NO_PROXY'] = 'internal.example.com' + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + const before = activeInterceptorCount() + configureAxiosProxy() + + assert.strictEqual(activeInterceptorCount(), before + 1) + }) + + test('interceptor clears agents for a host that matches NO_PROXY', () => { + process.env['NO_PROXY'] = 'internal.example.com' + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + configureAxiosProxy() + + const fn = getLastInterceptorFn()! + const config = { url: 'https://internal.example.com/api', httpsAgent: {}, httpAgent: {}, proxy: false as const } + const result = fn(config) + + assert.strictEqual(result.httpsAgent, undefined) + assert.strictEqual(result.httpAgent, undefined) + assert.strictEqual(result.proxy, undefined) + }) + + test('interceptor leaves agents intact for a host that does not match NO_PROXY', () => { + process.env['NO_PROXY'] = 'internal.example.com' + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + configureAxiosProxy() + + const fn = getLastInterceptorFn()! + const agent = {} + const config = { url: 'https://app.codacy.com/api', httpsAgent: agent, httpAgent: agent, proxy: false as const } + const result = fn(config) + + assert.strictEqual(result.httpsAgent, agent) + assert.strictEqual(result.httpAgent, agent) + }) + + test('interceptor bypasses proxy for subdomains of a NO_PROXY entry', () => { + process.env['NO_PROXY'] = '.example.com' + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + configureAxiosProxy() + + const fn = getLastInterceptorFn()! + const result = fn({ url: 'https://sub.example.com/api', httpsAgent: {}, proxy: false as const }) + + assert.strictEqual(result.httpsAgent, undefined) + }) + + test('interceptor bypasses proxy for all hosts when NO_PROXY is *', () => { + process.env['NO_PROXY'] = '*' + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + configureAxiosProxy() + + const fn = getLastInterceptorFn()! + const result = fn({ url: 'https://anything.com/api', httpsAgent: {}, proxy: false as const }) + + assert.strictEqual(result.httpsAgent, undefined) + }) + + test('no_proxy lowercase env var is respected', () => { + process.env['no_proxy'] = 'internal.example.com' + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + const before = activeInterceptorCount() + configureAxiosProxy() + + assert.strictEqual(activeInterceptorCount(), before + 1) + }) + + test('no interceptor registered when NO_PROXY is not set', () => { + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + const before = activeInterceptorCount() + configureAxiosProxy() + + assert.strictEqual(activeInterceptorCount(), before) + }) + + test('re-calling configureAxiosProxy replaces the NO_PROXY interceptor rather than accumulating', () => { + process.env['NO_PROXY'] = 'internal.example.com' + stubConfig({ proxy: 'http://proxy.example.com:8080' }) + const before = activeInterceptorCount() + configureAxiosProxy() + configureAxiosProxy() + + assert.strictEqual(activeInterceptorCount(), before + 1) + }) +})