CVE-2023-46944: Insufficient Information (NVD-CWE-noinfo)
value: An issue in GitKraken GitLens before v.14.0.0 allows an attacker to execute arbitrary code via a crafted file to the Visual Studio Codes workspace trust component.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
CVSS v3 base score: 7.8
Published: 2023-11-28T22:15Z
Repository: https://github.com/gitkraken/vscode-gitlens
Language: TypeScript
Files changed: src/env/node/git/git.ts, src/env/node/git/localGitProvider.ts, src/git/errors.ts, src/git/gitProviderService.ts, src/webviews/apps/home/home.html, src/webviews/apps/home/home.ts, src/webviews/home/homeWebview.ts, src/webviews/home/protocol.ts
Commit date: 2023-05-17 15:58:24 -0400
Commit message: Disables Git access in Restricted Mode (untrusted)

Diff: diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts
index a964fef9..24391410 100644
--- a/src/env/node/git/git.ts
+++ b/src/env/node/git/git.ts
@@ -7,11 +7,11 @@ import { Uri, window, workspace } from 'vscode';
 import { hrtime } from '@env/hrtime';
 import type { CoreConfiguration } from '../../../constants';
 import { GlyphChars } from '../../../constants';
 import type { GitCommandOptions, GitSpawnOptions } from '../../../git/commandOptions';
 import { GitErrorHandling } from '../../../git/commandOptions';
-import { StashPushError, StashPushErrorReason } from '../../../git/errors';
+import { StashPushError, StashPushErrorReason, WorkspaceUntrustedError } from '../../../git/errors';
 import type { GitDiffFilter } from '../../../git/models/diff';
 import { isUncommitted, isUncommittedStaged, shortenRevision } from '../../../git/models/reference';
 import type { GitUser } from '../../../git/models/user';
 import { GitBranchParser } from '../../../git/parsers/branchParser';
 import { GitLogParser } from '../../../git/parsers/logParser';
@@ -121,10 +121,12 @@ export class Git {
 	private readonly pendingCommands = new Map<string, Promise<string | Buffer>>();
 
 	async git(options: ExitCodeOnlyGitCommandOptions, ...args: any[]): Promise<number>;
 	async git<T extends string | Buffer>(options: GitCommandOptions, ...args: any[]): Promise<T>;
 	async git<T extends string | Buffer>(options: GitCommandOptions, ...args: any[]): Promise<T> {
+		if (!workspace.isTrusted) throw new WorkspaceUntrustedError();
+
 		const start = hrtime();
 
 		const { configs, correlationKey, errors: errorHandling, encoding, ...opts } = options;
 
 		const runOpts: RunOptions = {
@@ -222,10 +224,12 @@ export class Git {
 			);
 		}
 	}
 
 	async gitSpawn(options: GitSpawnOptions, ...args: any[]): Promise<ChildProcess> {
+		if (!workspace.isTrusted) throw new WorkspaceUntrustedError();
+
 		const start = hrtime();
 
 		const { cancellation, configs, stdin, stdinEncoding, ...opts } = options;
 
 		const spawnOpts: SpawnOptions = {
@@ -1643,10 +1647,12 @@ export class Git {
 			// Keep trailing spaces which are part of the directory name
 			return data.length === 0
 				? (emptyArray as [])
 				: [true, normalizePath(data.trimStart().replace(/[\r|\n]+$/, ''))];
 		} catch (ex) {
+			if (ex instanceof WorkspaceUntrustedError) return emptyArray as [];
+
 			const unsafeMatch =
 				/^fatal: detected dubious ownership in repository at '([^']+)'[\s\S]*git config --global --add safe\.directory '?([^'\n]+)'?$/m.exec(
 					ex.stderr,
 				);
 			if (unsafeMatch?.length === 3) {
diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts
index 2208be4e..a3d0a1dd 100644
--- a/src/env/node/git/localGitProvider.ts
+++ b/src/env/node/git/localGitProvider.ts
@@ -1086,11 +1086,11 @@ export class LocalGitProvider implements GitProvider, Disposable {
 
 			let safe;
 			[safe, repoPath] = await this.git.rev_parse__show_toplevel(uri.fsPath);
 			if (safe) {
 				this.unsafePaths.delete(uri.fsPath);
-			} else {
+			} else if (safe === false) {
 				this.unsafePaths.add(uri.fsPath);
 			}
 			if (!repoPath) return undefined;
 
 			const repoUri = Uri.file(repoPath);
diff --git a/src/git/errors.ts b/src/git/errors.ts
index a3b81490..e9baa1e0 100644
--- a/src/git/errors.ts
+++ b/src/git/errors.ts
@@ -79,10 +79,19 @@ export class StashPushError extends Error {
 		this.original = original;
 		this.reason = reason;
 		Error.captureStackTrace?.(this, StashApplyError);
 	}
 }
+
+export class WorkspaceUntrustedError extends Error {
+	constructor() {
+		super('Unable to perform Git operations because the current workspace is untrusted');
+
+		Error.captureStackTrace?.(this, WorkspaceUntrustedError);
+	}
+}
+
 export const enum WorktreeCreateErrorReason {
 	AlreadyCheckedOut = 1,
 	AlreadyExists = 2,
 }
 
diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts
index a3531bf0..ade35f2a 100644
--- a/src/git/gitProviderService.ts
+++ b/src/git/gitProviderService.ts
@@ -80,10 +80,16 @@ import type { RemoteProvider } from './remotes/remoteProvider';
 import type { RemoteProviders } from './remotes/remoteProviders';
 import type { RichRemoteProvider } from './remotes/richRemoteProvider';
 import type { GitSearch, SearchQuery } from './search';
 
 const emptyArray = Object.freeze([]) as unknown as any[];
+const emptyDisposable = Object.freeze({
+	dispose: () => {
+		/* noop */
+	},
+});
+
 const maxDefaultBranchWeight = 100;
 const weightedDefaultBranches = new Map<string, number>([
 	['master', maxDefaultBranchWeight],
 	['main', 15],
 	['default', 10],
@@ -214,10 +220,17 @@ export class GitProviderService implements Disposable {
 				}
 
 				this.resetCaches('providers');
 				this.updateContext();
 			}),
+			!workspace.isTrusted
+				? workspace.onDidGrantWorkspaceTrust(() => {
+						if (workspace.isTrusted && workspace.workspaceFolders?.length) {
+							void this.discoverRepositories(workspace.workspaceFolders, { force: true });
+						}
+				  })
+				: emptyDisposable,
 			...this.registerCommands(),
 		);
 
 		this.container.BranchDateFormatting.reset();
 		this.container.CommitDateFormatting.reset();
diff --git a/src/webviews/apps/home/home.html b/src/webviews/apps/home/home.html
index 5d983327..01a02f30 100644
--- a/src/webviews/apps/home/home.html
+++ b/src/webviews/apps/home/home.html
@@ -94,10 +94,21 @@
 					<p class="centered">
 						<vscode-button data-action="command:workbench.view.scm">Manage in Source Control</vscode-button>
 					</p>
 				</div>
 			</div>
+			<div id="untrusted-alert" class="alert alert--info mb-0" aria-hidden="true" hidden>
+				<h1 class="alert__title">Untrusted workspace</h1>
+				<div class="alert__description">
+					<p>Unable to open repositories in Restricted Mode.</p>
+					<p class="centered">
+						<vscode-button data-action="command:workbench.trust.manage"
+							>Manage Workspace Trust</vscode-button
+						>
+					</p>
+				</div>
+			</div>
 			<header-card id="header-card" image="#{webroot}/media/gitlens-logo.webp"></header-card>
 		</header>
 		<main class="home__main scrollable" id="main" tabindex="-1">
 			<style nonce="#{cspNonce}">
 				.video-banner {
diff --git a/src/webviews/apps/home/home.ts b/src/webviews/apps/home/home.ts
index 54138825..f3ddae31 100644
--- a/src/webviews/apps/home/home.ts
+++ b/src/webviews/apps/home/home.ts
@@ -245,40 +245,30 @@ export class HomeApp extends App<State> {
 			}
 		});
 	}
 
 	private updateNoRepo() {
-		const { repositories } = this.state;
-		const hasRepos = repositories.openCount > 0;
-		const value = hasRepos ? 'true' : 'false';
-
-		let $el = document.getElementById('no-repo');
-		$el?.setAttribute('aria-hidden', value);
-		if (hasRepos) {
-			$el?.setAttribute('hidden', value);
-		} else {
-			$el?.removeAttribute('hidden');
-		}
+		const {
+			repositories: { openCount, hasUnsafe, trusted },
+		} = this.state;
 
-		$el = document.getElementById('no-repo-alert');
-		const showUnsafe = repositories.hasUnsafe && !hasRepos;
-		const $unsafeEl = document.getElementById('unsafe-repo-alert');
-		if (showUnsafe) {
-			$el?.setAttribute('aria-hidden', 'true');
-			$el?.setAttribute('hidden', 'true');
-			$unsafeEl?.setAttribute('aria-hidden', 'false');
-			$unsafeEl?.removeAttribute('hidden');
-		} else {
-			$unsafeEl?.setAttribute('aria-hidden', 'true');
-			$unsafeEl?.setAttribute('hidden', 'true');
-			$el?.setAttribute('aria-hidden', value);
-			if (hasRepos) {
-				$el?.setAttribute('hidden', value);
-			} else {
-				$el?.removeAttribute('hidden');
-			}
+		if (!trusted) {
+			setElementVisibility('untrusted-alert', true);
+			setElementVisibility('no-repo', false);
+			setElementVisibility('no-repo-alert', false);
+			setElementVisibility('unsafe-repo-alert', false);
+
+			return;
 		}
+
+		setElementVisibility('untrusted-alert', false);
+
+		const noRepos = openCount === 0;
+
+		setElementVisibility('no-repo', noRepos);
+		setElementVisibility('no-repo-alert', noRepos && !hasUnsafe);
+		setElementVisibility('unsafe-repo-alert', hasUnsafe);
 	}
 
 	private updateLayout() {
 		const { layout } = this.state;
 
@@ -369,10 +359,28 @@ export class HomeApp extends App<State> {
 		this.updateSections();
 		this.updateBanners();
 	}
 }
 
+function setElementVisibility(elementOrId: string | HTMLElement | null | undefined, visible: boolean) {
+	let el;
+	if (typeof elementOrId === 'string') {
+		el = document.getElementById(elementOrId);
+	} else {
+		el = elementOrId;
+	}
+	if (el == null) return;
+
+	if (visible) {
+		el.setAttribute('aria-hidden', 'false');
+		el.removeAttribute('hidden');
+	} else {
+		el.setAttribute('aria-hidden', 'true');
+		el?.setAttribute('hidden', 'true');
+	}
+}
+
 function toggleArrayItem(list: string[] = [], item: string, add = true) {
 	const hasStep = list.includes(item);
 	if (!hasStep && add) {
 		list.push(item);
 	} else if (hasStep && !add) {
diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts
index 337184ec..01b87b34 100644
--- a/src/webviews/home/homeWebview.ts
+++ b/src/webviews/home/homeWebview.ts
@@ -1,22 +1,29 @@
 import type { ConfigurationChangeEvent } from 'vscode';
-import { Disposable, window } from 'vscode';
+import { Disposable, window, workspace } from 'vscode';
 import { getAvatarUriFromGravatarEmail } from '../../avatars';
 import { ViewsLayout } from '../../commands/setViewsLayout';
 import type { Container } from '../../container';
 import type { RepositoriesVisibility } from '../../git/gitProviderService';
 import type { SubscriptionChangeEvent } from '../../plus/subscription/subscriptionService';
 import type { Subscription } from '../../subscription';
 import { executeCoreCommand, registerCommand } from '../../system/command';
 import { configuration } from '../../system/configuration';
 import type { Deferrable } from '../../system/function';
 import { debounce } from '../../system/function';
+import { getSettledValue } from '../../system/promise';
 import type { StorageChangeEvent } from '../../system/storage';
 import type { IpcMessage } from '../protocol';
 import { onIpc } from '../protocol';
 import type { WebviewController, WebviewProvider } from '../webviewController';
-import type { CompleteStepParams, DismissBannerParams, DismissSectionParams, State } from './protocol';
+import type {
+	CompleteStepParams,
+	DidChangeRepositoriesParams,
+	DismissBannerParams,
+	DismissSectionParams,
+	State,
+} from './protocol';
 import {
 	CompletedActions,
 	CompleteStepCommandType,
 	DidChangeConfigurationType,
 	DidChangeLayoutType,
@@ -25,19 +32,28 @@ import {
 	DismissBannerCommandType,
 	DismissSectionCommandType,
 	DismissStatusCommandType,
 } from './protocol';
 
+const emptyDisposable = Object.freeze({
+	dispose: () => {
+		/* noop */
+	},
+});
+
 export class HomeWebviewProvider implements WebviewProvider<State> {
 	private readonly _disposable: Disposable;
 
 	constructor(private readonly container: Container, private readonly host: WebviewController<State>) {
 		this._disposable = Disposable.from(
 			this.container.subscription.onDidChange(this.onSubscriptionChanged, this),
 			this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this),
 			configuration.onDidChange(this.onConfigurationChanged, this),
 			this.container.storage.onDidChange(this.onStorageChanged, this),
+			!workspace.isTrusted
+				? workspace.onDidGrantWorkspaceTrust(this.notifyDidChangeRepositories, this)
+				: emptyDisposable,
 		);
 	}
 
 	dispose() {
 		this._disposable.dispose();
@@ -219,11 +235,16 @@ export class HomeWebviewProvider implements WebviewProvider<State> {
 	private getPinStatus() {
 		return this.container.storage.get('home:status:pinned') ?? true;
 	}
 
 	private async getState(subscription?: Subscription): Promise<State> {
-		const sub = await this.getSubscription(subscription);
+		const [visibilityResult, subscriptionResult] = await Promise.allSettled([
+			this.getRepoVisibility(),
+			this.getSubscription(subscription),
+		]);
+
+		const sub = getSettledValue(subscriptionResult)!;
 		const steps = this.container.storage.get('home:steps:completed', []);
 		const sections = this.container.storage.get('home:sections:dismissed', []);
 		const dismissedBanners = this.container.storage.get('home:banners:dismissed', []);
 
 		return {
@@ -231,11 +252,11 @@ export class HomeWebviewProvider implements WebviewProvider<State> {
 			repositories: this.getRepositoriesState(),
 			webroot: this.host.getWebRoot(),
 			subscription: sub.subscription,
 			completedActions: sub.completedActions,
 			plusEnabled: this.getPlusEnabled(),
-			visibility: await this.getRepoVisibility(),
+			visibility: getSettledValue(visibilityResult)!,
 			completedSteps: steps,
 			dismissedSections: sections,
 			avatar: sub.avatar,
 			layout: this.getLayout(),
 			pinStatus: this.getPinStatus(),
@@ -253,15 +274,16 @@ export class HomeWebviewProvider implements WebviewProvider<State> {
 				pinStatus: this.getPinStatus(),
 			});
 		});
 	}
 
-	private getRepositoriesState() {
+	private getRepositoriesState(): DidChangeRepositoriesParams {
 		return {
 			count: this.container.git.repositoryCount,
 			openCount: this.container.git.openRepositoryCount,
 			hasUnsafe: this.container.git.hasUnsafeRepositories(),
+			trusted: workspace.isTrusted,
 		};
 	}
 
 	private notifyDidChangeRepositories() {
 		if (!this.host.ready) return;
diff --git a/src/webviews/home/protocol.ts b/src/webviews/home/protocol.ts
index 823eeb9c..050fbf67 100644
--- a/src/webviews/home/protocol.ts
+++ b/src/webviews/home/protocol.ts
@@ -55,10 +55,11 @@ export const DidChangeSubscriptionNotificationType = new IpcNotificationType<Did
 
 export interface DidChangeRepositoriesParams {
 	count: number;
 	openCount: number;
 	hasUnsafe: boolean;
+	trusted: boolean;
 }
 export const DidChangeRepositoriesType = new IpcNotificationType<DidChangeRepositoriesParams>('repositories/didChange');
 
 export interface DidChangeConfigurationParams {
 	plusEnabled: boolean;

Vulnerable code: import { StashPushError, StashPushErrorReason } from '../../../git/errors';
			} else {
		const { repositories } = this.state;
		const hasRepos = repositories.openCount > 0;
		const value = hasRepos ? 'true' : 'false';

		let $el = document.getElementById('no-repo');
		$el?.setAttribute('aria-hidden', value);
		if (hasRepos) {
			$el?.setAttribute('hidden', value);
		} else {
			$el?.removeAttribute('hidden');
		}
		$el = document.getElementById('no-repo-alert');
		const showUnsafe = repositories.hasUnsafe && !hasRepos;
		const $unsafeEl = document.getElementById('unsafe-repo-alert');
		if (showUnsafe) {
			$el?.setAttribute('aria-hidden', 'true');
			$el?.setAttribute('hidden', 'true');
			$unsafeEl?.setAttribute('aria-hidden', 'false');
			$unsafeEl?.removeAttribute('hidden');
		} else {
			$unsafeEl?.setAttribute('aria-hidden', 'true');
			$unsafeEl?.setAttribute('hidden', 'true');
			$el?.setAttribute('aria-hidden', value);
			if (hasRepos) {
				$el?.setAttribute('hidden', value);
			} else {
				$el?.removeAttribute('hidden');
			}
import { Disposable, window } from 'vscode';
import type { CompleteStepParams, DismissBannerParams, DismissSectionParams, State } from './protocol';
		const sub = await this.getSubscription(subscription);
			visibility: await this.getRepoVisibility(),
	private getRepositoriesState() {
Fixed code: import { StashPushError, StashPushErrorReason, WorkspaceUntrustedError } from '../../../git/errors';
		if (!workspace.isTrusted) throw new WorkspaceUntrustedError();

		if (!workspace.isTrusted) throw new WorkspaceUntrustedError();

			if (ex instanceof WorkspaceUntrustedError) return emptyArray as [];

			} else if (safe === false) {

export class WorkspaceUntrustedError extends Error {
	constructor() {
		super('Unable to perform Git operations because the current workspace is untrusted');

		Error.captureStackTrace?.(this, WorkspaceUntrustedError);
	}
}

const emptyDisposable = Object.freeze({
	dispose: () => {
		/* noop */
	},
});

			!workspace.isTrusted
				? workspace.onDidGrantWorkspaceTrust(() => {
						if (workspace.isTrusted && workspace.workspaceFolders?.length) {
							void this.discoverRepositories(workspace.workspaceFolders, { force: true });
						}
				  })
				: emptyDisposable,
			<div id="untrusted-alert" class="alert alert--info mb-0" aria-hidden="true" hidden>
				<h1 class="alert__title">Untrusted workspace</h1>
				<div class="alert__description">
					<p>Unable to open repositories in Restricted Mode.</p>
					<p class="centered">
						<vscode-button data-action="command:workbench.trust.manage"
							>Manage Workspace Trust</vscode-button
						>
					</p>
				</div>
			</div>
		const {
			repositories: { openCount, hasUnsafe, trusted },
		} = this.state;
		if (!trusted) {
			setElementVisibility('untrusted-alert', true);
			setElementVisibility('no-repo', false);
			setElementVisibility('no-repo-alert', false);
			setElementVisibility('unsafe-repo-alert', false);

			return;

		setElementVisibility('untrusted-alert', false);

		const noRepos = openCount === 0;

		setElementVisibility('no-repo', noRepos);
		setElementVisibility('no-repo-alert', noRepos && !hasUnsafe);
		setElementVisibility('unsafe-repo-alert', hasUnsafe);
function setElementVisibility(elementOrId: string | HTMLElement | null | undefined, visible: boolean) {
	let el;
	if (typeof elementOrId === 'string') {
		el = document.getElementById(elementOrId);
	} else {
		el = elementOrId;
	}
	if (el == null) return;

	if (visible) {
		el.setAttribute('aria-hidden', 'false');
		el.removeAttribute('hidden');
	} else {
		el.setAttribute('aria-hidden', 'true');
		el?.setAttribute('hidden', 'true');
	}
}

import { Disposable, window, workspace } from 'vscode';
import { getSettledValue } from '../../system/promise';
import type {
	CompleteStepParams,
	DidChangeRepositoriesParams,
	DismissBannerParams,
	DismissSectionParams,
	State,
} from './protocol';
const emptyDisposable = Object.freeze({
	dispose: () => {
		/* noop */
	},
});

			!workspace.isTrusted
				? workspace.onDidGrantWorkspaceTrust(this.notifyDidChangeRepositories, this)
				: emptyDisposable,
		const [visibilityResult, subscriptionResult] = await Promise.allSettled([
			this.getRepoVisibility(),
			this.getSubscription(subscription),
		]);

		const sub = getSettledValue(subscriptionResult)!;
			visibility: getSettledValue(visibilityResult)!,
	private getRepositoriesState(): DidChangeRepositoriesParams {
			trusted: workspace.isTrusted,
	trusted: boolean;