From 8ef9485d4407626e1806a4b44afd61caa5675724 Mon Sep 17 00:00:00 2001 From: alexweininger Date: Wed, 1 Jul 2026 13:10:18 -0400 Subject: [PATCH 1/5] utils: surface troubleshooting guidance for certificate trust errors Operations that fail because the Node extension host cannot build a trusted TLS certificate chain (for example "unable to get local issuer certificate") currently show only the raw, cryptic error. On developer machines this is almost always a corporate proxy or SSL-inspection appliance whose root CA is not trusted by Node, and the fix is documented (NODE_EXTRA_CA_CERTS), but the error gives no hint. Add a shared classifier isCertificateTrustError() and wire it into the central handleError path in callWithTelemetryAndErrorHandling, so every extension that uses it automatically gets: a short explanatory hint appended to the notification and output channel, plus a "Learn more" button that opens the troubleshooting docs. Classification is limited to known Node/OpenSSL certificate error codes and their human-readable messages to avoid false positives on unrelated network errors. Related to microsoft/vscode-azureappservice#2899 Related to microsoft/vscode-azurefunctions#5124 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/callWithTelemetryAndErrorHandling.ts | 22 +++++++ utils/src/utils/certificateTrustError.ts | 63 +++++++++++++++++++ utils/test/certificateTrustError.test.ts | 58 +++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 utils/src/utils/certificateTrustError.ts create mode 100644 utils/test/certificateTrustError.test.ts diff --git a/utils/src/callWithTelemetryAndErrorHandling.ts b/utils/src/callWithTelemetryAndErrorHandling.ts index 9b0756e759..7649c16e4b 100644 --- a/utils/src/callWithTelemetryAndErrorHandling.ts +++ b/utils/src/callWithTelemetryAndErrorHandling.ts @@ -12,6 +12,8 @@ import { parseError } from './parseError'; import { cacheIssueForCommand } from './registerReportIssueCommand'; import { IReportableIssue, reportAnIssue } from './reportAnIssue'; import { AzExtUserInput } from './userInput/AzExtUserInput'; +import { certificateTroubleshootingLink, isCertificateTrustError } from './utils/certificateTrustError'; +import { openUrl } from './utils/openUrl'; import { limitLines } from './utils/textStrings'; const maxStackLines: number = 3; @@ -169,8 +171,15 @@ function handleError(context: types.IActionContext, callbackId: string, error: u } if (!context.errorHandling.suppressDisplay) { + const isCertError: boolean = isCertificateTrustError(unMaskedErrorData); + const certHint: string = l10n.t('This can happen behind a corporate proxy or SSL-inspection appliance whose certificate authority is not trusted by VS Code. See the troubleshooting steps for how to trust it.'); + // Always append the error to the output channel, but only 'show' the output channel for multiline errors ext.outputChannel.appendLog(l10n.t('Error: {0}', unMaskedErrorData.message)); + if (isCertError) { + ext.outputChannel.appendLog(certHint); + ext.outputChannel.appendLog(l10n.t('Troubleshooting: {0}', certificateTroubleshootingLink)); + } let notificationMessage: string; if (unMaskedErrorData.message.includes('\n')) { @@ -180,7 +189,20 @@ function handleError(context: types.IActionContext, callbackId: string, error: u notificationMessage = unMaskedErrorData.message; } + if (isCertError && !notificationMessage.includes(certHint)) { + notificationMessage = `${notificationMessage} ${certHint}`; + } + const items: MessageItem[] = []; + if (isCertError) { + const learnMore: types.AzExtErrorButton = { + title: l10n.t('Learn more'), + callback: async (): Promise => { + await openUrl(certificateTroubleshootingLink); + } + }; + items.push(learnMore); + } if (!context.errorHandling.suppressReportIssue) { items.push(DialogResponses.reportAnIssue); } diff --git a/utils/src/utils/certificateTrustError.ts b/utils/src/utils/certificateTrustError.ts new file mode 100644 index 0000000000..7833e5833d --- /dev/null +++ b/utils/src/utils/certificateTrustError.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IParsedError } from '../../index'; + +/** + * Default troubleshooting documentation for certificate trust failures. All Azure extensions + * link their README troubleshooting sections to this canonical location. + */ +export const certificateTroubleshootingLink: string = 'https://github.com/microsoft/vscode-azureresourcegroups/blob/main/README.md#troubleshooting'; + +/** + * Node.js/OpenSSL TLS error codes that indicate the runtime could not build a trusted + * certificate chain, which on developer machines is almost always a corporate proxy or + * SSL-inspection appliance whose root CA is not trusted by the Node extension host. + */ +const certificateErrorCodes: readonly string[] = [ + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'CERT_UNTRUSTED', + 'CERT_SIGNATURE_FAILURE', + 'CERT_CHAIN_TOO_LONG', +]; + +/** + * Message fragments that map to the same certificate trust failures. Some SDKs surface the + * human-readable OpenSSL string rather than the error code. + */ +const certificateErrorMessages: readonly string[] = [ + 'unable to get local issuer certificate', + 'unable to verify the first certificate', + 'self signed certificate', + 'self-signed certificate', + 'certificate has expired', +]; + +/** + * Determines whether a parsed error was caused by an untrusted TLS certificate chain, which + * typically happens behind a corporate proxy or SSL-inspection appliance. Callers can use this + * to surface targeted troubleshooting guidance (see {@link certificateTroubleshootingLink}) + * instead of the raw, cryptic error. + */ +export function isCertificateTrustError(parsedError: IParsedError): boolean { + const errorType = (parsedError.errorType ?? '').toUpperCase(); + if (certificateErrorCodes.some(code => errorType === code)) { + return true; + } + + const message = (parsedError.message ?? '').toLowerCase(); + if (!message) { + return false; + } + + if (certificateErrorCodes.some(code => message.includes(code.toLowerCase()))) { + return true; + } + + return certificateErrorMessages.some(fragment => message.includes(fragment)); +} diff --git a/utils/test/certificateTrustError.test.ts b/utils/test/certificateTrustError.test.ts new file mode 100644 index 0000000000..aeccf5f76a --- /dev/null +++ b/utils/test/certificateTrustError.test.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { IParsedError } from '..'; +import { parseError } from '../src/parseError'; +import { isCertificateTrustError } from '../src/utils/certificateTrustError'; + +function parsed(partial: Partial): IParsedError { + return { + errorType: '', + message: '', + isUserCancelledError: false, + name: 'Error', + ...partial, + } as IParsedError; +} + +suite('isCertificateTrustError', () => { + test('matches Node TLS error codes (errorType)', () => { + const codes = [ + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'CERT_UNTRUSTED', + ]; + for (const errorType of codes) { + assert.strictEqual(isCertificateTrustError(parsed({ errorType })), true, errorType); + } + }); + + test('matches error code embedded in the message', () => { + assert.strictEqual(isCertificateTrustError(parsed({ message: 'request failed: SELF_SIGNED_CERT_IN_CHAIN' })), true); + }); + + test('matches OpenSSL human-readable messages (case-insensitive)', () => { + assert.strictEqual(isCertificateTrustError(parsed({ message: 'unable to get local issuer certificate' })), true); + assert.strictEqual(isCertificateTrustError(parsed({ message: 'Error: Unable To Get Local Issuer Certificate' })), true); + assert.strictEqual(isCertificateTrustError(parsed({ message: 'self signed certificate in certificate chain' })), true); + assert.strictEqual(isCertificateTrustError(parsed({ message: 'self-signed certificate' })), true); + assert.strictEqual(isCertificateTrustError(parsed({ message: 'unable to verify the first certificate' })), true); + }); + + test('does not match unrelated errors', () => { + assert.strictEqual(isCertificateTrustError(parsed({ errorType: 'ECONNREFUSED', message: 'connection refused' })), false); + assert.strictEqual(isCertificateTrustError(parsed({ errorType: 'NotFoundError', message: 'The resource was not found.' })), false); + assert.strictEqual(isCertificateTrustError(parsed({ message: '' })), false); + assert.strictEqual(isCertificateTrustError(parsed({})), false); + }); + + test('works with parseError output for a realistic cert error', () => { + const err = Object.assign(new Error('unable to get local issuer certificate'), { code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY' }); + assert.strictEqual(isCertificateTrustError(parseError(err)), true); + }); +}); From 5b2e733cb150b39fd67aa62752ee7e06a20e3813 Mon Sep 17 00:00:00 2001 From: alexweininger Date: Wed, 1 Jul 2026 13:19:14 -0400 Subject: [PATCH 2/5] Remove unnecessary type assertion in test helper Fixes CI lint failure (@typescript-eslint/no-unnecessary-type-assertion): the object literal already satisfies IParsedError, so the cast is redundant. Build the base as a typed const and spread instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- utils/test/certificateTrustError.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/test/certificateTrustError.test.ts b/utils/test/certificateTrustError.test.ts index aeccf5f76a..a363359844 100644 --- a/utils/test/certificateTrustError.test.ts +++ b/utils/test/certificateTrustError.test.ts @@ -9,13 +9,13 @@ import { parseError } from '../src/parseError'; import { isCertificateTrustError } from '../src/utils/certificateTrustError'; function parsed(partial: Partial): IParsedError { - return { + const base: IParsedError = { errorType: '', message: '', isUserCancelledError: false, name: 'Error', - ...partial, - } as IParsedError; + }; + return { ...base, ...partial }; } suite('isCertificateTrustError', () => { From 8a109f45b66de0940e1d569508968677bee4647f Mon Sep 17 00:00:00 2001 From: alexweininger Date: Wed, 1 Jul 2026 13:21:07 -0400 Subject: [PATCH 3/5] Address review: drop 'certificate has expired', clarify hint wording - Remove 'certificate has expired' from the classifier: an expired leaf/server cert (or bad local clock) is not an untrusted corporate root CA, so including it risks false positives that surface proxy/CA guidance when it does not apply. - Reword the hint to attribute the failure to the Node.js extension host being unable to build a trusted chain (not 'VS Code'), and mention NODE_EXTRA_CA_CERTS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- utils/src/callWithTelemetryAndErrorHandling.ts | 2 +- utils/src/utils/certificateTrustError.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/utils/src/callWithTelemetryAndErrorHandling.ts b/utils/src/callWithTelemetryAndErrorHandling.ts index 7649c16e4b..65e83ee5c7 100644 --- a/utils/src/callWithTelemetryAndErrorHandling.ts +++ b/utils/src/callWithTelemetryAndErrorHandling.ts @@ -172,7 +172,7 @@ function handleError(context: types.IActionContext, callbackId: string, error: u if (!context.errorHandling.suppressDisplay) { const isCertError: boolean = isCertificateTrustError(unMaskedErrorData); - const certHint: string = l10n.t('This can happen behind a corporate proxy or SSL-inspection appliance whose certificate authority is not trusted by VS Code. See the troubleshooting steps for how to trust it.'); + const certHint: string = l10n.t('This can happen behind a corporate proxy or SSL-inspection appliance whose certificate authority is not trusted by the Node.js extension host. See the troubleshooting steps for how to trust it (for example, by setting NODE_EXTRA_CA_CERTS).'); // Always append the error to the output channel, but only 'show' the output channel for multiline errors ext.outputChannel.appendLog(l10n.t('Error: {0}', unMaskedErrorData.message)); diff --git a/utils/src/utils/certificateTrustError.ts b/utils/src/utils/certificateTrustError.ts index 7833e5833d..657955dede 100644 --- a/utils/src/utils/certificateTrustError.ts +++ b/utils/src/utils/certificateTrustError.ts @@ -35,7 +35,6 @@ const certificateErrorMessages: readonly string[] = [ 'unable to verify the first certificate', 'self signed certificate', 'self-signed certificate', - 'certificate has expired', ]; /** From 3feb29e11ed2b040d2b64a104a96277b49914918 Mon Sep 17 00:00:00 2001 From: alexweininger Date: Wed, 1 Jul 2026 13:25:36 -0400 Subject: [PATCH 4/5] Cover UNABLE_TO_GET_ISSUER_CERT (non-_LOCALLY) variant Completeness: also match the non-_LOCALLY issuer-cert error code and its human-readable message, which some OpenSSL/Node versions surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- utils/src/utils/certificateTrustError.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utils/src/utils/certificateTrustError.ts b/utils/src/utils/certificateTrustError.ts index 657955dede..c6a20f4b1b 100644 --- a/utils/src/utils/certificateTrustError.ts +++ b/utils/src/utils/certificateTrustError.ts @@ -18,6 +18,7 @@ export const certificateTroubleshootingLink: string = 'https://github.com/micros */ const certificateErrorCodes: readonly string[] = [ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_GET_ISSUER_CERT', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'SELF_SIGNED_CERT_IN_CHAIN', 'DEPTH_ZERO_SELF_SIGNED_CERT', @@ -32,6 +33,7 @@ const certificateErrorCodes: readonly string[] = [ */ const certificateErrorMessages: readonly string[] = [ 'unable to get local issuer certificate', + 'unable to get issuer certificate', 'unable to verify the first certificate', 'self signed certificate', 'self-signed certificate', From 6cec7b3921a2bebb119d147a8cdee88b1e8d7413 Mon Sep 17 00:00:00 2001 From: alexweininger Date: Wed, 1 Jul 2026 13:31:55 -0400 Subject: [PATCH 5/5] Use aka.ms redirect for the troubleshooting link Point certificateTroubleshootingLink at the stable aka.ms/AA11ri61 redirect instead of a branch-specific GitHub README URL, so it survives README moves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- utils/src/utils/certificateTrustError.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/src/utils/certificateTrustError.ts b/utils/src/utils/certificateTrustError.ts index c6a20f4b1b..d708dc1484 100644 --- a/utils/src/utils/certificateTrustError.ts +++ b/utils/src/utils/certificateTrustError.ts @@ -6,10 +6,10 @@ import type { IParsedError } from '../../index'; /** - * Default troubleshooting documentation for certificate trust failures. All Azure extensions - * link their README troubleshooting sections to this canonical location. + * Default troubleshooting documentation for certificate trust failures. This is a stable aka.ms + * redirect that points at the canonical Azure extensions troubleshooting guidance. */ -export const certificateTroubleshootingLink: string = 'https://github.com/microsoft/vscode-azureresourcegroups/blob/main/README.md#troubleshooting'; +export const certificateTroubleshootingLink: string = 'https://aka.ms/AA11ri61'; /** * Node.js/OpenSSL TLS error codes that indicate the runtime could not build a trusted