Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions utils/src/callWithTelemetryAndErrorHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 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));
if (isCertError) {
ext.outputChannel.appendLog(certHint);
ext.outputChannel.appendLog(l10n.t('Troubleshooting: {0}', certificateTroubleshootingLink));
}

let notificationMessage: string;
if (unMaskedErrorData.message.includes('\n')) {
Expand All @@ -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<void> => {
await openUrl(certificateTroubleshootingLink);
}
};
items.push(learnMore);
}
if (!context.errorHandling.suppressReportIssue) {
items.push(DialogResponses.reportAnIssue);
}
Expand Down
64 changes: 64 additions & 0 deletions utils/src/utils/certificateTrustError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*---------------------------------------------------------------------------------------------
* 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. This is a stable aka.ms
* redirect that points at the canonical Azure extensions troubleshooting guidance.
*/
export const certificateTroubleshootingLink: string = 'https://aka.ms/AA11ri61';

/**
* 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_GET_ISSUER_CERT',
'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 get issuer certificate',
'unable to verify the first certificate',
'self signed certificate',
'self-signed certificate',
];
Comment thread
Copilot marked this conversation as resolved.

/**
* 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));
}
58 changes: 58 additions & 0 deletions utils/test/certificateTrustError.test.ts
Original file line number Diff line number Diff line change
@@ -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>): IParsedError {
const base: IParsedError = {
errorType: '',
message: '',
isUserCancelledError: false,
name: 'Error',
};
return { ...base, ...partial };
}

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);
});
});
Loading